agentic-composite-memory 0.1.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.
- agentic_composite_memory/__init__.py +2 -0
- agentic_composite_memory/py.typed +0 -0
- agentic_composite_memory-0.1.0.dist-info/METADATA +167 -0
- agentic_composite_memory-0.1.0.dist-info/RECORD +31 -0
- agentic_composite_memory-0.1.0.dist-info/WHEEL +4 -0
- agentic_composite_memory-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_composite_memory-0.1.0.dist-info/licenses/LICENSE +17 -0
- agentic_memory_sdk/__init__.py +163 -0
- agentic_memory_sdk/adapters/__init__.py +33 -0
- agentic_memory_sdk/adapters/in_memory.py +439 -0
- agentic_memory_sdk/adapters/neo4j_cypher.py +65 -0
- agentic_memory_sdk/adapters/neptune_cypher.py +309 -0
- agentic_memory_sdk/adapters/opensearch_seed.py +322 -0
- agentic_memory_sdk/adapters/postgres_pgvector.py +484 -0
- agentic_memory_sdk/adapters/postgres_unified.py +511 -0
- agentic_memory_sdk/adapters/redis_playbook.py +157 -0
- agentic_memory_sdk/adapters/redis_unified.py +490 -0
- agentic_memory_sdk/adk_integration.py +250 -0
- agentic_memory_sdk/auth.py +380 -0
- agentic_memory_sdk/cli.py +137 -0
- agentic_memory_sdk/governance.py +713 -0
- agentic_memory_sdk/llm_extractors.py +616 -0
- agentic_memory_sdk/models.py +443 -0
- agentic_memory_sdk/ontology_loader.py +189 -0
- agentic_memory_sdk/ports.py +159 -0
- agentic_memory_sdk/py.typed +0 -0
- agentic_memory_sdk/server/__init__.py +4 -0
- agentic_memory_sdk/server/api.py +328 -0
- agentic_memory_sdk/server/main.py +384 -0
- agentic_memory_sdk/server/mcp_server.py +198 -0
- agentic_memory_sdk/service.py +1214 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .models import AppMemoryManifest, ReasoningTraceStep
|
|
10
|
+
from .service import BaseMemoryService, Event, MemoryEntry, SearchMemoryResponse, Session, types
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemoteCompositeMemoryService(BaseMemoryService):
|
|
16
|
+
"""
|
|
17
|
+
Lightweight HTTP Client implementing Google ADK's `BaseMemoryService` contract.
|
|
18
|
+
Designed for platforms hosting 100s of ADK agent apps:
|
|
19
|
+
- Agents do NOT need direct Postgres/OpenSearch/Neptune credentials or connection pools.
|
|
20
|
+
- Plugs directly into `google.adk.runners.Runner(memory_service=RemoteCompositeMemoryService(...))`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str = "http://localhost:8080",
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
timeout_sec: float = 10.0,
|
|
28
|
+
client: httpx.AsyncClient | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.timeout_sec = timeout_sec
|
|
33
|
+
self._external_client = client
|
|
34
|
+
|
|
35
|
+
def _headers(self) -> dict[str, str]:
|
|
36
|
+
headers = {"Content-Type": "application/json"}
|
|
37
|
+
if self.api_key:
|
|
38
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
async def _request(self, method: str, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
42
|
+
if self._external_client is not None:
|
|
43
|
+
resp = await self._external_client.request(
|
|
44
|
+
method, f"{self.base_url}{path}", json=json_body, headers=self._headers()
|
|
45
|
+
)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return resp.json()
|
|
48
|
+
|
|
49
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
50
|
+
resp = await client.request(
|
|
51
|
+
method, f"{self.base_url}{path}", json=json_body, headers=self._headers()
|
|
52
|
+
)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
return resp.json()
|
|
55
|
+
|
|
56
|
+
async def register_manifest(self, manifest: AppMemoryManifest) -> dict[str, Any]:
|
|
57
|
+
"""Registers an ADK app's ontology manifest with the remote Memory Platform Service."""
|
|
58
|
+
return await self._request("POST", "/v1/apps/register", manifest.model_dump(mode="json"))
|
|
59
|
+
|
|
60
|
+
async def add_session_to_memory(self, session: Session) -> None:
|
|
61
|
+
await self.add_events_to_memory(
|
|
62
|
+
app_name=session.app_name,
|
|
63
|
+
user_id=session.user_id,
|
|
64
|
+
events=session.events,
|
|
65
|
+
session_id=session.id,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def add_events_to_memory(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
app_name: str,
|
|
72
|
+
user_id: str,
|
|
73
|
+
events: Sequence[Event],
|
|
74
|
+
session_id: str | None = None,
|
|
75
|
+
custom_metadata: dict[str, Any] | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
serialized_events = []
|
|
78
|
+
for ev in events:
|
|
79
|
+
if not getattr(ev, "content", None) or not getattr(ev.content, "parts", None):
|
|
80
|
+
continue
|
|
81
|
+
text_parts = [p.text for p in ev.content.parts if getattr(p, "text", None)]
|
|
82
|
+
if text_parts:
|
|
83
|
+
serialized_events.append(
|
|
84
|
+
{
|
|
85
|
+
"author": getattr(ev, "author", None) or "agent",
|
|
86
|
+
"text": "\n".join(text_parts),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
if not serialized_events:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
await self._request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/v1/memory/events",
|
|
95
|
+
{
|
|
96
|
+
"app_name": app_name,
|
|
97
|
+
"user_id": user_id,
|
|
98
|
+
"session_id": session_id or "default_session",
|
|
99
|
+
"events": serialized_events,
|
|
100
|
+
"custom_metadata": custom_metadata or {},
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def add_memory(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
app_name: str,
|
|
108
|
+
user_id: str,
|
|
109
|
+
memories: Sequence[MemoryEntry],
|
|
110
|
+
custom_metadata: dict[str, Any] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
synthetic_events = [
|
|
113
|
+
Event(author=m.author or "user", content=m.content)
|
|
114
|
+
for m in memories
|
|
115
|
+
if getattr(m, "content", None)
|
|
116
|
+
]
|
|
117
|
+
await self.add_events_to_memory(
|
|
118
|
+
app_name=app_name,
|
|
119
|
+
user_id=user_id,
|
|
120
|
+
events=synthetic_events,
|
|
121
|
+
custom_metadata=custom_metadata,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def search_memory(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
app_name: str,
|
|
128
|
+
user_id: str,
|
|
129
|
+
query: str,
|
|
130
|
+
as_of_iso: str | None = None,
|
|
131
|
+
max_hops: int = 2,
|
|
132
|
+
) -> SearchMemoryResponse:
|
|
133
|
+
data = await self._request(
|
|
134
|
+
"POST",
|
|
135
|
+
"/v1/memory/search",
|
|
136
|
+
{
|
|
137
|
+
"app_name": app_name,
|
|
138
|
+
"user_id": user_id,
|
|
139
|
+
"query": query,
|
|
140
|
+
"as_of_iso": as_of_iso,
|
|
141
|
+
"max_hops": max_hops,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
entries: list[MemoryEntry] = []
|
|
145
|
+
for item in data.get("memories", []):
|
|
146
|
+
entries.append(
|
|
147
|
+
MemoryEntry(
|
|
148
|
+
author=item.get("author", "memory"),
|
|
149
|
+
timestamp=item.get("timestamp"),
|
|
150
|
+
content=types.Content(
|
|
151
|
+
role="user",
|
|
152
|
+
parts=[types.Part.from_text(text=item.get("text", ""))],
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return SearchMemoryResponse(memories=entries)
|
|
157
|
+
|
|
158
|
+
async def record_decision_trace(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
app_name: str,
|
|
162
|
+
user_id: str,
|
|
163
|
+
run_id: str,
|
|
164
|
+
session_id: str,
|
|
165
|
+
steps: Sequence[ReasoningTraceStep],
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
return await self._request(
|
|
168
|
+
"POST",
|
|
169
|
+
"/v1/traces",
|
|
170
|
+
{
|
|
171
|
+
"app_name": app_name,
|
|
172
|
+
"user_id": user_id,
|
|
173
|
+
"run_id": run_id,
|
|
174
|
+
"session_id": session_id,
|
|
175
|
+
"steps": [s.model_dump(mode="json") for s in steps],
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ============================================================================
|
|
181
|
+
# DROP-IN GOOGLE ADK CALLBACKS & EXPLICIT AGENT TOOLS
|
|
182
|
+
# ============================================================================
|
|
183
|
+
async def non_blocking_memory_commit_callback(callback_context: Any) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Google ADK `after_agent_callback`.
|
|
186
|
+
Commits the latest user + agent turn delta asynchronously off the critical user response path.
|
|
187
|
+
"""
|
|
188
|
+
session = getattr(callback_context, "session", None)
|
|
189
|
+
if not session or not getattr(session, "events", None):
|
|
190
|
+
return None
|
|
191
|
+
recent_events = session.events[-2:]
|
|
192
|
+
if recent_events and hasattr(callback_context, "add_events_to_memory"):
|
|
193
|
+
asyncio.create_task(callback_context.add_events_to_memory(events=recent_events))
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_adk_memory_tools(
|
|
198
|
+
memory_service: BaseMemoryService,
|
|
199
|
+
default_app_name: str = "adk_agent",
|
|
200
|
+
default_user_id: str = "default_user",
|
|
201
|
+
) -> list[Any]:
|
|
202
|
+
"""
|
|
203
|
+
Returns ready-to-attach Google ADK tool functions (`recall_memory_context`,
|
|
204
|
+
`commit_memory_episode`, `record_agent_decision_trace`) backed by either
|
|
205
|
+
`RemoteCompositeMemoryService` (no DB credentials needed) or in-process `CompositeMemoryService`.
|
|
206
|
+
Can be added alongside `PreloadMemoryTool()` in `LlmAgent(tools=[...])`.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
async def recall_memory_context(
|
|
210
|
+
query: str,
|
|
211
|
+
as_of_timestamp: str | None = None,
|
|
212
|
+
app_name: str = default_app_name,
|
|
213
|
+
user_id: str = default_user_id,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Search composite memory (Bi-Temporal Knowledge Graph + Episodic Memory), optionally at a historical point in time (`as_of_timestamp`)."""
|
|
216
|
+
resp = await memory_service.search_memory( # type: ignore[attr-defined]
|
|
217
|
+
app_name=app_name,
|
|
218
|
+
user_id=user_id,
|
|
219
|
+
query=query,
|
|
220
|
+
as_of_iso=as_of_timestamp,
|
|
221
|
+
)
|
|
222
|
+
blocks = []
|
|
223
|
+
for m in resp.memories:
|
|
224
|
+
if getattr(m, "content", None) and getattr(m.content, "parts", None):
|
|
225
|
+
text = "\n".join(
|
|
226
|
+
p.text for p in m.content.parts if getattr(p, "text", None)
|
|
227
|
+
)
|
|
228
|
+
blocks.append({"author": m.author, "timestamp": m.timestamp, "text": text})
|
|
229
|
+
return {"query": query, "as_of": as_of_timestamp, "memories": blocks}
|
|
230
|
+
|
|
231
|
+
async def commit_memory_episode(
|
|
232
|
+
fact_or_observation: str,
|
|
233
|
+
app_name: str = default_app_name,
|
|
234
|
+
user_id: str = default_user_id,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Explicitly commit an important observation or domain fact into Episodic Memory and the Bi-Temporal Knowledge Graph."""
|
|
237
|
+
ev = Event(
|
|
238
|
+
author="agent",
|
|
239
|
+
content=types.Content(
|
|
240
|
+
role="user", parts=[types.Part.from_text(text=fact_or_observation)]
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
await memory_service.add_events_to_memory( # type: ignore[attr-defined]
|
|
244
|
+
app_name=app_name,
|
|
245
|
+
user_id=user_id,
|
|
246
|
+
events=[ev],
|
|
247
|
+
)
|
|
248
|
+
return {"status": "committed", "app_name": app_name}
|
|
249
|
+
|
|
250
|
+
return [recall_memory_context, commit_memory_episode]
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class EnterpriseTlsConfig:
|
|
12
|
+
"""
|
|
13
|
+
Enterprise TLS / mTLS configuration for cloud-managed databases
|
|
14
|
+
(AWS RDS/Aurora, ElastiCache, MemoryDB, GCP Cloud SQL/AlloyDB, Azure Postgres, Neo4j Aura).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
verify_hostname: bool = True
|
|
19
|
+
ca_file: str | None = None
|
|
20
|
+
client_cert_file: str | None = None
|
|
21
|
+
client_key_file: str | None = None
|
|
22
|
+
min_tls_version: int = ssl.TLSVersion.TLSv1_2
|
|
23
|
+
|
|
24
|
+
def build_ssl_context(self) -> ssl.SSLContext | bool:
|
|
25
|
+
if not self.enabled:
|
|
26
|
+
return False
|
|
27
|
+
ctx = ssl.create_default_context(
|
|
28
|
+
purpose=ssl.Purpose.SERVER_AUTH,
|
|
29
|
+
cafile=self.ca_file,
|
|
30
|
+
)
|
|
31
|
+
ctx.minimum_version = ssl.TLSVersion(self.min_tls_version)
|
|
32
|
+
if self.verify_hostname:
|
|
33
|
+
ctx.check_hostname = True
|
|
34
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
35
|
+
else:
|
|
36
|
+
ctx.check_hostname = False
|
|
37
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
38
|
+
if self.client_cert_file:
|
|
39
|
+
ctx.load_cert_chain(
|
|
40
|
+
certfile=self.client_cert_file,
|
|
41
|
+
keyfile=self.client_key_file,
|
|
42
|
+
)
|
|
43
|
+
return ctx
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class CloudAuthProvider(Protocol):
|
|
48
|
+
"""
|
|
49
|
+
Protocol for enterprise cloud database authentication (IAM tokens, Entra ID,
|
|
50
|
+
GCP Workload Identity, Vault dynamic secrets, or rotating Secrets Manager credentials).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
async def get_credentials(self) -> tuple[str, str]:
|
|
54
|
+
"""Returns `(username, password_or_token)` with automatic caching & pre-expiry refresh."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def get_username(self) -> str:
|
|
58
|
+
"""Returns the current database principal/username."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
async def get_password(self) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Returns the current database password or short-lived IAM/OAuth token.
|
|
64
|
+
Directly compatible with `asyncpg.create_pool(..., password=auth_provider.get_password)`.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def invalidate(self) -> None:
|
|
69
|
+
"""Forces immediate token/secret rotation on next connection attempt."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CachedTokenAuthProviderBase:
|
|
74
|
+
"""
|
|
75
|
+
Base class providing concurrency-safe token caching and proactive pre-expiry rotation
|
|
76
|
+
for short-lived cloud IAM tokens (typically 15–60 minutes TTL).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
username: str,
|
|
82
|
+
token_ttl_sec: int = 900,
|
|
83
|
+
refresh_margin_sec: int = 120,
|
|
84
|
+
):
|
|
85
|
+
self._username = username
|
|
86
|
+
self.token_ttl_sec = max(10, token_ttl_sec)
|
|
87
|
+
self.refresh_margin_sec = min(refresh_margin_sec, self.token_ttl_sec // 2)
|
|
88
|
+
self._cached_username: str | None = None
|
|
89
|
+
self._cached_token: str | None = None
|
|
90
|
+
self._expires_at: float = 0.0
|
|
91
|
+
self._refresh_count: int = 0
|
|
92
|
+
self._lock = asyncio.Lock()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def refresh_count(self) -> int:
|
|
96
|
+
return self._refresh_count
|
|
97
|
+
|
|
98
|
+
def invalidate(self) -> None:
|
|
99
|
+
self._cached_token = None
|
|
100
|
+
self._expires_at = 0.0
|
|
101
|
+
|
|
102
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
103
|
+
raise NotImplementedError
|
|
104
|
+
|
|
105
|
+
async def get_credentials(self) -> tuple[str, str]:
|
|
106
|
+
now = time.monotonic()
|
|
107
|
+
if (
|
|
108
|
+
self._cached_token is not None
|
|
109
|
+
and self._cached_username is not None
|
|
110
|
+
and now < (self._expires_at - self.refresh_margin_sec)
|
|
111
|
+
):
|
|
112
|
+
return self._cached_username, self._cached_token
|
|
113
|
+
|
|
114
|
+
async with self._lock:
|
|
115
|
+
now = time.monotonic()
|
|
116
|
+
if (
|
|
117
|
+
self._cached_token is not None
|
|
118
|
+
and self._cached_username is not None
|
|
119
|
+
and now < (self._expires_at - self.refresh_margin_sec)
|
|
120
|
+
):
|
|
121
|
+
return self._cached_username, self._cached_token
|
|
122
|
+
|
|
123
|
+
user, token = await self._mint_credentials()
|
|
124
|
+
self._cached_username = user
|
|
125
|
+
self._cached_token = token
|
|
126
|
+
self._expires_at = time.monotonic() + self.token_ttl_sec
|
|
127
|
+
self._refresh_count += 1
|
|
128
|
+
return user, token
|
|
129
|
+
|
|
130
|
+
async def get_username(self) -> str:
|
|
131
|
+
user, _ = await self.get_credentials()
|
|
132
|
+
return user
|
|
133
|
+
|
|
134
|
+
async def get_password(self) -> str:
|
|
135
|
+
_, token = await self.get_credentials()
|
|
136
|
+
return token
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class StaticOrEnvAuthProvider(CachedTokenAuthProviderBase):
|
|
140
|
+
"""
|
|
141
|
+
Static or environment-backed credentials provider implementing `CloudAuthProvider`.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, username: str, password: str):
|
|
145
|
+
super().__init__(username=username, token_ttl_sec=86400 * 365, refresh_margin_sec=0)
|
|
146
|
+
self._password = password
|
|
147
|
+
|
|
148
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
149
|
+
return self._username, self._password
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AwsIamDatabaseAuthProvider(CachedTokenAuthProviderBase):
|
|
153
|
+
"""
|
|
154
|
+
Enterprise AWS IAM Authentication Provider for:
|
|
155
|
+
- Amazon RDS / Aurora PostgreSQL (`service="rds"`, uses `generate_db_auth_token`)
|
|
156
|
+
- Amazon ElastiCache / MemoryDB for Redis (`service="elasticache"` or `"memorydb"`, SigV4 presigned token)
|
|
157
|
+
Supports optional cross-account IAM Role assumption (`assume_role_arn` + `external_id`)
|
|
158
|
+
and custom token generators for offline/unit testing.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
host: str,
|
|
164
|
+
port: int,
|
|
165
|
+
username: str,
|
|
166
|
+
region: str = "us-east-1",
|
|
167
|
+
service: str = "rds",
|
|
168
|
+
cluster_name: str | None = None,
|
|
169
|
+
assume_role_arn: str | None = None,
|
|
170
|
+
external_id: str | None = None,
|
|
171
|
+
token_ttl_sec: int = 900,
|
|
172
|
+
refresh_margin_sec: int = 120,
|
|
173
|
+
custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
|
|
174
|
+
):
|
|
175
|
+
super().__init__(
|
|
176
|
+
username=username,
|
|
177
|
+
token_ttl_sec=token_ttl_sec,
|
|
178
|
+
refresh_margin_sec=refresh_margin_sec,
|
|
179
|
+
)
|
|
180
|
+
self.host = host
|
|
181
|
+
self.port = port
|
|
182
|
+
self.region = region
|
|
183
|
+
self.service = service.lower()
|
|
184
|
+
self.cluster_name = cluster_name or host
|
|
185
|
+
self.assume_role_arn = assume_role_arn
|
|
186
|
+
self.external_id = external_id
|
|
187
|
+
self.custom_token_generator = custom_token_generator
|
|
188
|
+
|
|
189
|
+
def get_boto3_session(self) -> Any:
|
|
190
|
+
import boto3
|
|
191
|
+
|
|
192
|
+
if not self.assume_role_arn:
|
|
193
|
+
return boto3.Session(region_name=self.region)
|
|
194
|
+
|
|
195
|
+
sts = boto3.client("sts", region_name=self.region)
|
|
196
|
+
assume_kwargs: dict[str, Any] = {
|
|
197
|
+
"RoleArn": self.assume_role_arn,
|
|
198
|
+
"RoleSessionName": "adk-memory-platform-db-auth",
|
|
199
|
+
"DurationSeconds": max(900, self.token_ttl_sec),
|
|
200
|
+
}
|
|
201
|
+
if self.external_id:
|
|
202
|
+
assume_kwargs["ExternalId"] = self.external_id
|
|
203
|
+
creds = sts.assume_role(**assume_kwargs)["Credentials"]
|
|
204
|
+
return boto3.Session(
|
|
205
|
+
aws_access_key_id=creds["AccessKeyId"],
|
|
206
|
+
aws_secret_access_key=creds["SecretAccessKey"],
|
|
207
|
+
aws_session_token=creds["SessionToken"],
|
|
208
|
+
region_name=self.region,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _generate_sync(self) -> str:
|
|
212
|
+
session = self.get_boto3_session()
|
|
213
|
+
if self.service == "rds":
|
|
214
|
+
rds = session.client("rds", region_name=self.region)
|
|
215
|
+
return rds.generate_db_auth_token(
|
|
216
|
+
DBHostname=self.host,
|
|
217
|
+
Port=self.port,
|
|
218
|
+
DBUsername=self. _username,
|
|
219
|
+
Region=self.region,
|
|
220
|
+
)
|
|
221
|
+
# ElastiCache / MemoryDB IAM auth uses a SigV4 presigned URL token
|
|
222
|
+
from botocore.signers import RequestSigner
|
|
223
|
+
|
|
224
|
+
credentials = session.get_credentials().get_frozen_credentials()
|
|
225
|
+
signer = RequestSigner(
|
|
226
|
+
service_name=self.service,
|
|
227
|
+
region_name=self.region,
|
|
228
|
+
signing_name=self.service,
|
|
229
|
+
signature_version="v4",
|
|
230
|
+
credentials=credentials,
|
|
231
|
+
event_emitter=session.events,
|
|
232
|
+
)
|
|
233
|
+
url = signer.generate_presigned_url(
|
|
234
|
+
request_dict={
|
|
235
|
+
"url_path": "/",
|
|
236
|
+
"query_string": {"Action": "connect", "User": self._username},
|
|
237
|
+
"headers": {"Host": self.cluster_name},
|
|
238
|
+
"method": "GET",
|
|
239
|
+
"body": b"",
|
|
240
|
+
},
|
|
241
|
+
region_name=self.region,
|
|
242
|
+
expires_in=self.token_ttl_sec,
|
|
243
|
+
operation_name="connect",
|
|
244
|
+
)
|
|
245
|
+
return url.replace("https://", "").replace("http://", "")
|
|
246
|
+
|
|
247
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
248
|
+
if self.custom_token_generator is not None:
|
|
249
|
+
res = self.custom_token_generator()
|
|
250
|
+
token = await res if asyncio.iscoroutine(res) else str(res)
|
|
251
|
+
return self._username, token
|
|
252
|
+
token = await asyncio.to_thread(self._generate_sync)
|
|
253
|
+
return self._username, token
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class GcpCloudSqlIamAuthProvider(CachedTokenAuthProviderBase):
|
|
257
|
+
"""
|
|
258
|
+
Enterprise Google Cloud IAM Database Authentication Provider for:
|
|
259
|
+
- Cloud SQL for PostgreSQL / AlloyDB (`https://www.googleapis.com/auth/cloud-platform`)
|
|
260
|
+
- Cloud Memorystore for Redis IAM Auth
|
|
261
|
+
Uses Application Default Credentials (ADC) / Workload Identity Federation.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
username: str,
|
|
267
|
+
scopes: list[str] | None = None,
|
|
268
|
+
target_service_account: str | None = None,
|
|
269
|
+
token_ttl_sec: int = 3300,
|
|
270
|
+
refresh_margin_sec: int = 300,
|
|
271
|
+
custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
|
|
272
|
+
):
|
|
273
|
+
super().__init__(
|
|
274
|
+
username=username,
|
|
275
|
+
token_ttl_sec=token_ttl_sec,
|
|
276
|
+
refresh_margin_sec=refresh_margin_sec,
|
|
277
|
+
)
|
|
278
|
+
self.scopes = scopes or ["https://www.googleapis.com/auth/cloud-platform"]
|
|
279
|
+
self.target_service_account = target_service_account
|
|
280
|
+
self.custom_token_generator = custom_token_generator
|
|
281
|
+
|
|
282
|
+
def _generate_sync(self) -> str:
|
|
283
|
+
import google.auth # type: ignore
|
|
284
|
+
from google.auth.transport.requests import Request # type: ignore
|
|
285
|
+
|
|
286
|
+
creds, _ = google.auth.default(scopes=self.scopes)
|
|
287
|
+
if self.target_service_account:
|
|
288
|
+
from google.auth import impersonated_credentials # type: ignore
|
|
289
|
+
|
|
290
|
+
creds = impersonated_credentials.Credentials(
|
|
291
|
+
source_credentials=creds,
|
|
292
|
+
target_principal=self.target_service_account,
|
|
293
|
+
target_scopes=self.scopes,
|
|
294
|
+
lifetime=self.token_ttl_sec,
|
|
295
|
+
)
|
|
296
|
+
creds.refresh(Request())
|
|
297
|
+
return str(creds.token)
|
|
298
|
+
|
|
299
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
300
|
+
if self.custom_token_generator is not None:
|
|
301
|
+
res = self.custom_token_generator()
|
|
302
|
+
token = await res if asyncio.iscoroutine(res) else str(res)
|
|
303
|
+
return self._username, token
|
|
304
|
+
token = await asyncio.to_thread(self._generate_sync)
|
|
305
|
+
return self._username, token
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class AzureEntraDatabaseAuthProvider(CachedTokenAuthProviderBase):
|
|
309
|
+
"""
|
|
310
|
+
Enterprise Microsoft Entra ID (Azure AD / Managed Identity) Authentication Provider for:
|
|
311
|
+
- Azure Database for PostgreSQL Flexible Server (`scope="https://ossrdbms-aad.database.windows.net/.default"`)
|
|
312
|
+
- Azure Managed Redis / Azure Cache for Redis (`scope="https://redis.azure.com/.default"`)
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
POSTGRES_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
|
|
316
|
+
REDIS_SCOPE = "https://redis.azure.com/.default"
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
username: str,
|
|
321
|
+
scope: str = POSTGRES_SCOPE,
|
|
322
|
+
managed_identity_client_id: str | None = None,
|
|
323
|
+
token_ttl_sec: int = 3300,
|
|
324
|
+
refresh_margin_sec: int = 300,
|
|
325
|
+
custom_token_generator: Callable[[], str | Awaitable[str]] | None = None,
|
|
326
|
+
):
|
|
327
|
+
super().__init__(
|
|
328
|
+
username=username,
|
|
329
|
+
token_ttl_sec=token_ttl_sec,
|
|
330
|
+
refresh_margin_sec=refresh_margin_sec,
|
|
331
|
+
)
|
|
332
|
+
self.scope = scope
|
|
333
|
+
self.managed_identity_client_id = managed_identity_client_id
|
|
334
|
+
self.custom_token_generator = custom_token_generator
|
|
335
|
+
|
|
336
|
+
def _generate_sync(self) -> str:
|
|
337
|
+
from azure.identity import DefaultAzureCredential # type: ignore
|
|
338
|
+
|
|
339
|
+
cred = DefaultAzureCredential(
|
|
340
|
+
managed_identity_client_id=self.managed_identity_client_id
|
|
341
|
+
)
|
|
342
|
+
token_obj = cred.get_token(self.scope)
|
|
343
|
+
return str(token_obj.token)
|
|
344
|
+
|
|
345
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
346
|
+
if self.custom_token_generator is not None:
|
|
347
|
+
res = self.custom_token_generator()
|
|
348
|
+
token = await res if asyncio.iscoroutine(res) else str(res)
|
|
349
|
+
return self._username, token
|
|
350
|
+
token = await asyncio.to_thread(self._generate_sync)
|
|
351
|
+
return self._username, token
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class VaultOrSecretManagerAuthProvider(CachedTokenAuthProviderBase):
|
|
355
|
+
"""
|
|
356
|
+
Dynamic Secret Rotation Provider for HashiCorp Vault Database Secrets Engine,
|
|
357
|
+
AWS Secrets Manager, or GCP Secret Manager.
|
|
358
|
+
Accepts an async or sync callable `secret_fetcher` returning `(username, password)`.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(
|
|
362
|
+
self,
|
|
363
|
+
secret_fetcher: Callable[[], tuple[str, str] | Awaitable[tuple[str, str]]],
|
|
364
|
+
token_ttl_sec: int = 1800,
|
|
365
|
+
refresh_margin_sec: int = 180,
|
|
366
|
+
):
|
|
367
|
+
super().__init__(
|
|
368
|
+
username="dynamic",
|
|
369
|
+
token_ttl_sec=token_ttl_sec,
|
|
370
|
+
refresh_margin_sec=refresh_margin_sec,
|
|
371
|
+
)
|
|
372
|
+
self.secret_fetcher = secret_fetcher
|
|
373
|
+
|
|
374
|
+
async def _mint_credentials(self) -> tuple[str, str]:
|
|
375
|
+
res = self.secret_fetcher()
|
|
376
|
+
if asyncio.iscoroutine(res):
|
|
377
|
+
user, pwd = await res
|
|
378
|
+
else:
|
|
379
|
+
user, pwd = res # type: ignore
|
|
380
|
+
return str(user), str(pwd)
|