minder-cli 0.2.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.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/auth/service.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth Service — user registration, API key management, JWT issuance/validation.
|
|
3
|
+
|
|
4
|
+
Error codes follow the project standard:
|
|
5
|
+
AUTH_USER_EXISTS — duplicate registration
|
|
6
|
+
AUTH_USER_NOT_FOUND — user lookup miss
|
|
7
|
+
AUTH_USER_INACTIVE — deactivated account
|
|
8
|
+
AUTH_INVALID_KEY — API key mismatch
|
|
9
|
+
AUTH_TOKEN_EXPIRED — JWT past exp claim
|
|
10
|
+
AUTH_TOKEN_INVALID — malformed or tampered JWT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import secrets
|
|
14
|
+
import uuid
|
|
15
|
+
import json
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Tuple
|
|
19
|
+
|
|
20
|
+
import jwt
|
|
21
|
+
from passlib.context import CryptContext # type: ignore[import-untyped]
|
|
22
|
+
from passlib.exc import UnknownHashError # type: ignore[import-untyped]
|
|
23
|
+
|
|
24
|
+
from minder.auth.principal import AdminUserPrincipal, ClientPrincipal, Principal
|
|
25
|
+
from minder.config import MinderConfig
|
|
26
|
+
from minder.models.user import User
|
|
27
|
+
from minder.store.interfaces import ICacheProvider, IOperationalStore
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Role hierarchy
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UserRole(str, Enum):
|
|
35
|
+
ADMIN = "admin"
|
|
36
|
+
MEMBER = "member"
|
|
37
|
+
READONLY = "readonly"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ROLE_HIERARCHY: dict[UserRole, int] = {
|
|
41
|
+
UserRole.ADMIN: 3,
|
|
42
|
+
UserRole.MEMBER: 2,
|
|
43
|
+
UserRole.READONLY: 1,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Domain exception
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthError(Exception):
|
|
52
|
+
"""Raised for all auth-layer failures. Carries a structured error code."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, code: str, message: str) -> None:
|
|
55
|
+
self.code = code
|
|
56
|
+
self.message = message
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"AuthError(code={self.code!r}, message={self.message!r})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Service
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthService:
|
|
71
|
+
"""Stateless auth service. Injected with store + config at construction."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
store: IOperationalStore,
|
|
76
|
+
config: MinderConfig,
|
|
77
|
+
cache: ICacheProvider | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._store = store
|
|
80
|
+
self._config = config
|
|
81
|
+
self._cache = cache
|
|
82
|
+
self._client_session_cache: dict[str, str] = {}
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Internal helpers
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _generate_api_key(self) -> str:
|
|
89
|
+
"""Return a cryptographically random API key with the configured prefix."""
|
|
90
|
+
token = secrets.token_urlsafe(32)
|
|
91
|
+
return f"{self._config.auth.api_key_prefix}{token}"
|
|
92
|
+
|
|
93
|
+
def _generate_client_api_key(self) -> str:
|
|
94
|
+
token = secrets.token_urlsafe(32)
|
|
95
|
+
return f"{self._config.auth.client_api_key_prefix}{token}"
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _hash_secret(secret: str) -> str:
|
|
99
|
+
return _pwd_context.hash(secret)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _verify_secret(secret: str, hashed: str) -> bool:
|
|
103
|
+
return _pwd_context.verify(secret, hashed)
|
|
104
|
+
|
|
105
|
+
async def _session_store_set(self, key: str, value: dict[str, Any], ttl_seconds: int) -> None:
|
|
106
|
+
encoded = json.dumps(value, default=str)
|
|
107
|
+
if self._cache is not None:
|
|
108
|
+
await self._cache.set(key, encoded, ttl=ttl_seconds)
|
|
109
|
+
return
|
|
110
|
+
self._client_session_cache[key] = encoded
|
|
111
|
+
|
|
112
|
+
async def _session_store_get(self, key: str) -> dict[str, Any] | None:
|
|
113
|
+
raw: str | None
|
|
114
|
+
if self._cache is not None:
|
|
115
|
+
raw = await self._cache.get(key)
|
|
116
|
+
else:
|
|
117
|
+
raw = self._client_session_cache.get(key)
|
|
118
|
+
if raw is None:
|
|
119
|
+
return None
|
|
120
|
+
return json.loads(raw)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Registration
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async def register_user(
|
|
127
|
+
self,
|
|
128
|
+
email: str,
|
|
129
|
+
username: str,
|
|
130
|
+
display_name: str,
|
|
131
|
+
role: UserRole | str = UserRole.MEMBER,
|
|
132
|
+
password: str | None = None,
|
|
133
|
+
) -> Tuple[User, str]:
|
|
134
|
+
"""
|
|
135
|
+
Create a new user account.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
(user, plaintext_api_key) — caller must surface the key once; it
|
|
139
|
+
is never stored in plaintext.
|
|
140
|
+
|
|
141
|
+
If ``password`` is supplied it is hashed and stored separately so the
|
|
142
|
+
user can authenticate with username + password *in addition to* the API
|
|
143
|
+
key.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
AuthError(AUTH_USER_EXISTS) if email or username is already taken.
|
|
147
|
+
"""
|
|
148
|
+
if await self._store.get_user_by_email(email):
|
|
149
|
+
raise AuthError("AUTH_USER_EXISTS", f"Email '{email}' is already registered")
|
|
150
|
+
|
|
151
|
+
if await self._store.get_user_by_username(username):
|
|
152
|
+
raise AuthError("AUTH_USER_EXISTS", f"Username '{username}' is already taken")
|
|
153
|
+
|
|
154
|
+
role_str = role.value if isinstance(role, UserRole) else str(role)
|
|
155
|
+
|
|
156
|
+
api_key = self._generate_api_key()
|
|
157
|
+
password_hash = self._hash_secret(password) if password else None
|
|
158
|
+
user = await self._store.create_user(
|
|
159
|
+
id=uuid.uuid4(),
|
|
160
|
+
email=email,
|
|
161
|
+
username=username,
|
|
162
|
+
display_name=display_name,
|
|
163
|
+
api_key_hash=self._hash_secret(api_key),
|
|
164
|
+
password_hash=password_hash,
|
|
165
|
+
role=role_str,
|
|
166
|
+
is_active=True,
|
|
167
|
+
settings={},
|
|
168
|
+
)
|
|
169
|
+
return user, api_key
|
|
170
|
+
|
|
171
|
+
async def has_admin_users(self) -> bool:
|
|
172
|
+
"""Check if any admin users exist in the system."""
|
|
173
|
+
return await self._store.has_admin_users()
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Authentication
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def authenticate_api_key(self, api_key: str) -> User:
|
|
180
|
+
users = await self._store.list_users(active_only=False)
|
|
181
|
+
import logging
|
|
182
|
+
logger = logging.getLogger("minder.auth")
|
|
183
|
+
logger.debug(f"Checking API key against {len(users)} users")
|
|
184
|
+
for user in users:
|
|
185
|
+
try:
|
|
186
|
+
matches = self._verify_secret(api_key, user.api_key_hash)
|
|
187
|
+
except UnknownHashError:
|
|
188
|
+
continue
|
|
189
|
+
if matches:
|
|
190
|
+
if not user.is_active:
|
|
191
|
+
raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
|
|
192
|
+
await self._store.update_user(user.id, last_login=datetime.now(UTC))
|
|
193
|
+
return user
|
|
194
|
+
raise AuthError("AUTH_INVALID_KEY", "Invalid API key")
|
|
195
|
+
|
|
196
|
+
async def authenticate_username_password(self, username: str, password: str) -> User:
|
|
197
|
+
"""Authenticate with username + password.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
AuthError(AUTH_USER_NOT_FOUND) — username not found
|
|
201
|
+
AuthError(AUTH_PASSWORD_NOT_SET) — user has no password configured
|
|
202
|
+
AuthError(AUTH_INVALID_KEY) — password mismatch
|
|
203
|
+
AuthError(AUTH_USER_INACTIVE) — account deactivated
|
|
204
|
+
"""
|
|
205
|
+
user = await self._store.get_user_by_username(username)
|
|
206
|
+
if user is None:
|
|
207
|
+
# Return a generic message to avoid username enumeration
|
|
208
|
+
raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
|
|
209
|
+
if not user.is_active:
|
|
210
|
+
raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
|
|
211
|
+
password_hash = getattr(user, "password_hash", None)
|
|
212
|
+
if not password_hash:
|
|
213
|
+
raise AuthError(
|
|
214
|
+
"AUTH_PASSWORD_NOT_SET",
|
|
215
|
+
"Password login is not configured for this account. Use your API key instead.",
|
|
216
|
+
)
|
|
217
|
+
try:
|
|
218
|
+
if not self._verify_secret(password, password_hash):
|
|
219
|
+
raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
|
|
220
|
+
except UnknownHashError:
|
|
221
|
+
raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
|
|
222
|
+
await self._store.update_user(user.id, last_login=datetime.now(UTC))
|
|
223
|
+
return user
|
|
224
|
+
|
|
225
|
+
async def set_password(self, user_id: uuid.UUID, password: str) -> None:
|
|
226
|
+
"""Set or update the login password for an existing user."""
|
|
227
|
+
await self._store.update_user(user_id, password_hash=self._hash_secret(password))
|
|
228
|
+
|
|
229
|
+
async def register_client(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
name: str,
|
|
233
|
+
slug: str,
|
|
234
|
+
created_by_user_id: uuid.UUID,
|
|
235
|
+
description: str = "",
|
|
236
|
+
owner_team: str | None = None,
|
|
237
|
+
transport_modes: list[str] | None = None,
|
|
238
|
+
tool_scopes: list[str] | None = None,
|
|
239
|
+
repo_scopes: list[str] | None = None,
|
|
240
|
+
workflow_scopes: list[str] | None = None,
|
|
241
|
+
rate_limit_policy: dict[str, Any] | None = None,
|
|
242
|
+
) -> tuple[Any, str]:
|
|
243
|
+
if await self._store.get_client_by_slug(slug):
|
|
244
|
+
raise AuthError("AUTH_CLIENT_EXISTS", f"Client slug '{slug}' is already registered")
|
|
245
|
+
|
|
246
|
+
creator = await self._store.get_user_by_id(created_by_user_id)
|
|
247
|
+
if creator is None:
|
|
248
|
+
raise AuthError("AUTH_USER_NOT_FOUND", "Creator user not found")
|
|
249
|
+
|
|
250
|
+
client = await self._store.create_client(
|
|
251
|
+
id=uuid.uuid4(),
|
|
252
|
+
name=name,
|
|
253
|
+
slug=slug,
|
|
254
|
+
description=description,
|
|
255
|
+
status="active",
|
|
256
|
+
created_by_user_id=created_by_user_id,
|
|
257
|
+
owner_team=owner_team,
|
|
258
|
+
transport_modes=transport_modes or ["sse", "stdio"],
|
|
259
|
+
tool_scopes=tool_scopes or [],
|
|
260
|
+
repo_scopes=repo_scopes or [],
|
|
261
|
+
workflow_scopes=workflow_scopes or [],
|
|
262
|
+
rate_limit_policy=rate_limit_policy or {},
|
|
263
|
+
)
|
|
264
|
+
client_api_key = self._generate_client_api_key()
|
|
265
|
+
await self._store.create_client_api_key(
|
|
266
|
+
id=uuid.uuid4(),
|
|
267
|
+
client_id=client.id,
|
|
268
|
+
key_prefix=client_api_key[:12],
|
|
269
|
+
secret_hash=self._hash_secret(client_api_key),
|
|
270
|
+
status="active",
|
|
271
|
+
created_by_user_id=created_by_user_id,
|
|
272
|
+
)
|
|
273
|
+
await self._store.create_audit_log(
|
|
274
|
+
id=uuid.uuid4(),
|
|
275
|
+
actor_type="admin_user",
|
|
276
|
+
actor_id=str(created_by_user_id),
|
|
277
|
+
event_type="client.created",
|
|
278
|
+
resource_type="client",
|
|
279
|
+
resource_id=str(client.id),
|
|
280
|
+
outcome="success",
|
|
281
|
+
audit_metadata={"slug": slug},
|
|
282
|
+
)
|
|
283
|
+
return client, client_api_key
|
|
284
|
+
|
|
285
|
+
async def create_client_api_key(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
client_id: uuid.UUID,
|
|
289
|
+
created_by_user_id: uuid.UUID,
|
|
290
|
+
) -> str:
|
|
291
|
+
client = await self._store.get_client_by_id(client_id)
|
|
292
|
+
if client is None:
|
|
293
|
+
raise AuthError("AUTH_CLIENT_NOT_FOUND", "Client not found")
|
|
294
|
+
creator = await self._store.get_user_by_id(created_by_user_id)
|
|
295
|
+
if creator is None:
|
|
296
|
+
raise AuthError("AUTH_USER_NOT_FOUND", "Creator user not found")
|
|
297
|
+
|
|
298
|
+
client_api_key = self._generate_client_api_key()
|
|
299
|
+
await self._store.create_client_api_key(
|
|
300
|
+
id=uuid.uuid4(),
|
|
301
|
+
client_id=client_id,
|
|
302
|
+
key_prefix=client_api_key[:12],
|
|
303
|
+
secret_hash=self._hash_secret(client_api_key),
|
|
304
|
+
status="active",
|
|
305
|
+
created_by_user_id=created_by_user_id,
|
|
306
|
+
)
|
|
307
|
+
await self._store.create_audit_log(
|
|
308
|
+
id=uuid.uuid4(),
|
|
309
|
+
actor_type="admin_user",
|
|
310
|
+
actor_id=str(created_by_user_id),
|
|
311
|
+
event_type="client.key_created",
|
|
312
|
+
resource_type="client",
|
|
313
|
+
resource_id=str(client_id),
|
|
314
|
+
outcome="success",
|
|
315
|
+
audit_metadata={"client_slug": getattr(client, "slug", None)},
|
|
316
|
+
)
|
|
317
|
+
return client_api_key
|
|
318
|
+
|
|
319
|
+
async def revoke_client_api_keys(
|
|
320
|
+
self,
|
|
321
|
+
client_id: uuid.UUID,
|
|
322
|
+
*,
|
|
323
|
+
actor_user_id: uuid.UUID | None = None,
|
|
324
|
+
) -> None:
|
|
325
|
+
now = datetime.now(UTC)
|
|
326
|
+
client = await self._store.get_client_by_id(client_id)
|
|
327
|
+
for key in await self._store.list_client_api_keys(client_id):
|
|
328
|
+
await self._store.update_client_api_key(
|
|
329
|
+
key.id,
|
|
330
|
+
status="revoked",
|
|
331
|
+
revoked_at=now,
|
|
332
|
+
)
|
|
333
|
+
if actor_user_id is not None:
|
|
334
|
+
await self._store.create_audit_log(
|
|
335
|
+
id=uuid.uuid4(),
|
|
336
|
+
actor_type="admin_user",
|
|
337
|
+
actor_id=str(actor_user_id),
|
|
338
|
+
event_type="client.key_revoked",
|
|
339
|
+
resource_type="client",
|
|
340
|
+
resource_id=str(client_id),
|
|
341
|
+
outcome="success",
|
|
342
|
+
audit_metadata={"client_slug": getattr(client, "slug", None)},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def authenticate_client_api_key(self, client_api_key: str) -> Any:
|
|
346
|
+
if not client_api_key.startswith(self._config.auth.client_api_key_prefix):
|
|
347
|
+
raise AuthError("AUTH_INVALID_CLIENT_KEY", "Invalid client API key")
|
|
348
|
+
|
|
349
|
+
clients = await self._store.list_clients()
|
|
350
|
+
for client in clients:
|
|
351
|
+
if getattr(client, "status", "active") != "active":
|
|
352
|
+
continue
|
|
353
|
+
for key in await self._store.list_client_api_keys(client.id):
|
|
354
|
+
if getattr(key, "status", "active") != "active":
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
matches = self._verify_secret(client_api_key, key.secret_hash)
|
|
358
|
+
except UnknownHashError:
|
|
359
|
+
continue
|
|
360
|
+
if matches:
|
|
361
|
+
await self._store.update_client_api_key(
|
|
362
|
+
key.id,
|
|
363
|
+
last_used_at=datetime.now(UTC),
|
|
364
|
+
)
|
|
365
|
+
return client
|
|
366
|
+
raise AuthError("AUTH_INVALID_CLIENT_KEY", "Invalid client API key")
|
|
367
|
+
|
|
368
|
+
async def exchange_client_api_key(
|
|
369
|
+
self,
|
|
370
|
+
client_api_key: str,
|
|
371
|
+
*,
|
|
372
|
+
requested_scopes: list[str] | None = None,
|
|
373
|
+
metadata: dict[str, Any] | None = None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
client = await self.authenticate_client_api_key(client_api_key)
|
|
376
|
+
allowed_scopes = list(getattr(client, "tool_scopes", []))
|
|
377
|
+
requested = requested_scopes or allowed_scopes
|
|
378
|
+
effective_scopes = [scope for scope in requested if scope in allowed_scopes]
|
|
379
|
+
if not effective_scopes:
|
|
380
|
+
effective_scopes = allowed_scopes
|
|
381
|
+
|
|
382
|
+
now = datetime.now(UTC)
|
|
383
|
+
expires_at = now + timedelta(minutes=self._config.auth.client_token_expiry_minutes)
|
|
384
|
+
token_id = str(uuid.uuid4())
|
|
385
|
+
session = await self._store.create_client_session(
|
|
386
|
+
id=uuid.uuid4(),
|
|
387
|
+
client_id=client.id,
|
|
388
|
+
access_token_id=token_id,
|
|
389
|
+
status="active",
|
|
390
|
+
scopes=effective_scopes,
|
|
391
|
+
issued_at=now,
|
|
392
|
+
expires_at=expires_at,
|
|
393
|
+
last_seen_at=now,
|
|
394
|
+
session_metadata=metadata or {},
|
|
395
|
+
)
|
|
396
|
+
await self._session_store_set(
|
|
397
|
+
f"client_session:{token_id}",
|
|
398
|
+
{
|
|
399
|
+
"client_id": str(client.id),
|
|
400
|
+
"client_slug": client.slug,
|
|
401
|
+
"scopes": effective_scopes,
|
|
402
|
+
"repo_scope": list(getattr(client, "repo_scopes", [])),
|
|
403
|
+
"session_id": str(session.id),
|
|
404
|
+
},
|
|
405
|
+
ttl_seconds=self._config.auth.client_token_expiry_minutes * 60,
|
|
406
|
+
)
|
|
407
|
+
payload = {
|
|
408
|
+
"sub": str(client.id),
|
|
409
|
+
"ptype": "client",
|
|
410
|
+
"slug": client.slug,
|
|
411
|
+
"scopes": effective_scopes,
|
|
412
|
+
"repo_scope": list(getattr(client, "repo_scopes", [])),
|
|
413
|
+
"jti": token_id,
|
|
414
|
+
"iat": now,
|
|
415
|
+
"exp": expires_at,
|
|
416
|
+
}
|
|
417
|
+
token = jwt.encode(payload, self._config.auth.jwt_secret, algorithm="HS256")
|
|
418
|
+
await self._store.create_audit_log(
|
|
419
|
+
id=uuid.uuid4(),
|
|
420
|
+
actor_type="client",
|
|
421
|
+
actor_id=str(client.id),
|
|
422
|
+
event_type="client.token_exchanged",
|
|
423
|
+
resource_type="client_session",
|
|
424
|
+
resource_id=str(session.id),
|
|
425
|
+
outcome="success",
|
|
426
|
+
audit_metadata={"scopes": effective_scopes},
|
|
427
|
+
)
|
|
428
|
+
return {
|
|
429
|
+
"access_token": token,
|
|
430
|
+
"expires_in": self._config.auth.client_token_expiry_minutes * 60,
|
|
431
|
+
"token_type": "Bearer",
|
|
432
|
+
"client_id": str(client.id),
|
|
433
|
+
"client_slug": client.slug,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
# JWT
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def issue_jwt(self, user: User) -> str:
|
|
441
|
+
"""Issue a signed JWT for the user."""
|
|
442
|
+
now = datetime.now(UTC)
|
|
443
|
+
payload = {
|
|
444
|
+
"sub": str(user.id),
|
|
445
|
+
"email": user.email,
|
|
446
|
+
"username": user.username,
|
|
447
|
+
"role": user.role,
|
|
448
|
+
"iat": now,
|
|
449
|
+
"exp": now + timedelta(hours=self._config.auth.jwt_expiry_hours),
|
|
450
|
+
}
|
|
451
|
+
return jwt.encode(payload, self._config.auth.jwt_secret, algorithm="HS256")
|
|
452
|
+
|
|
453
|
+
def validate_jwt(self, token: str) -> dict:
|
|
454
|
+
"""
|
|
455
|
+
Decode and validate a JWT.
|
|
456
|
+
|
|
457
|
+
Returns the decoded payload dict.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
AuthError(AUTH_TOKEN_EXPIRED) if past exp.
|
|
461
|
+
AuthError(AUTH_TOKEN_INVALID) on any other JWT error.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
return jwt.decode(
|
|
465
|
+
token,
|
|
466
|
+
self._config.auth.jwt_secret,
|
|
467
|
+
algorithms=["HS256"],
|
|
468
|
+
)
|
|
469
|
+
except jwt.ExpiredSignatureError:
|
|
470
|
+
raise AuthError("AUTH_TOKEN_EXPIRED", "JWT token has expired")
|
|
471
|
+
except jwt.InvalidTokenError as exc:
|
|
472
|
+
raise AuthError("AUTH_TOKEN_INVALID", f"Invalid JWT: {exc}")
|
|
473
|
+
|
|
474
|
+
async def get_user_from_jwt(self, token: str) -> User:
|
|
475
|
+
"""Validate JWT and return the corresponding active user."""
|
|
476
|
+
payload = self.validate_jwt(token)
|
|
477
|
+
user_id = uuid.UUID(payload["sub"])
|
|
478
|
+
user = await self._store.get_user_by_id(user_id)
|
|
479
|
+
if not user:
|
|
480
|
+
raise AuthError("AUTH_USER_NOT_FOUND", "User referenced by token not found")
|
|
481
|
+
if not user.is_active:
|
|
482
|
+
raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
|
|
483
|
+
return user
|
|
484
|
+
|
|
485
|
+
async def get_principal_from_token(self, token: str) -> Principal:
|
|
486
|
+
payload = self.validate_jwt(token)
|
|
487
|
+
if payload.get("ptype") == "client":
|
|
488
|
+
token_id = payload.get("jti")
|
|
489
|
+
if not token_id:
|
|
490
|
+
raise AuthError("AUTH_TOKEN_INVALID", "Client token missing jti")
|
|
491
|
+
cached = await self._session_store_get(f"client_session:{token_id}")
|
|
492
|
+
if cached is None:
|
|
493
|
+
raise AuthError("AUTH_TOKEN_INVALID", "Client session not found or expired")
|
|
494
|
+
client = await self._store.get_client_by_id(uuid.UUID(payload["sub"]))
|
|
495
|
+
if client is None or getattr(client, "status", "active") != "active":
|
|
496
|
+
raise AuthError("AUTH_CLIENT_NOT_FOUND", "Client referenced by token not found")
|
|
497
|
+
session = await self._store.get_client_session_by_token_id(token_id)
|
|
498
|
+
if session is not None:
|
|
499
|
+
await self._store.update_client_session(session.id, last_seen_at=datetime.now(UTC))
|
|
500
|
+
return ClientPrincipal(
|
|
501
|
+
client_id=client.id,
|
|
502
|
+
client_slug=client.slug,
|
|
503
|
+
scopes=list(cached.get("scopes", [])),
|
|
504
|
+
repo_scope=list(cached.get("repo_scope", [])),
|
|
505
|
+
metadata={"session_id": cached.get("session_id")},
|
|
506
|
+
)
|
|
507
|
+
user = await self.get_user_from_jwt(token)
|
|
508
|
+
return AdminUserPrincipal(user)
|
|
509
|
+
|
|
510
|
+
async def get_principal_from_client_key(
|
|
511
|
+
self,
|
|
512
|
+
client_api_key: str,
|
|
513
|
+
*,
|
|
514
|
+
requested_scopes: list[str] | None = None,
|
|
515
|
+
) -> Principal:
|
|
516
|
+
exchange = await self.exchange_client_api_key(
|
|
517
|
+
client_api_key,
|
|
518
|
+
requested_scopes=requested_scopes,
|
|
519
|
+
)
|
|
520
|
+
return await self.get_principal_from_token(exchange["access_token"])
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
# Key rotation
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
async def rotate_api_key(self, user_id: uuid.UUID) -> str:
|
|
527
|
+
"""
|
|
528
|
+
Generate and store a new API key for the user.
|
|
529
|
+
|
|
530
|
+
Returns the new plaintext key (display once).
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
AuthError(AUTH_USER_NOT_FOUND) if user doesn't exist.
|
|
534
|
+
"""
|
|
535
|
+
user = await self._store.get_user_by_id(user_id)
|
|
536
|
+
if not user:
|
|
537
|
+
raise AuthError("AUTH_USER_NOT_FOUND", f"User {user_id} not found")
|
|
538
|
+
|
|
539
|
+
new_key = self._generate_api_key()
|
|
540
|
+
await self._store.update_user(user_id, api_key_hash=self._hash_secret(new_key))
|
|
541
|
+
return new_key
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from minder.cache.providers import RedisCacheProvider
|
|
6
|
+
from minder.config import MinderConfig
|
|
7
|
+
from minder.store.interfaces import ICacheProvider, IGraphRepository, IOperationalStore, IVectorStore
|
|
8
|
+
from minder.store.vector import VectorStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sqlite_db_url(raw_path: str) -> str:
|
|
12
|
+
db_path = Path(raw_path).expanduser()
|
|
13
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
return f"sqlite+aiosqlite:///{db_path}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_store(config: MinderConfig) -> IOperationalStore:
|
|
18
|
+
provider = config.relational_store.provider
|
|
19
|
+
|
|
20
|
+
if provider == "mongodb":
|
|
21
|
+
from minder.store.mongodb.client import MongoClient
|
|
22
|
+
from minder.store.mongodb.operational_store import MongoOperationalStore
|
|
23
|
+
|
|
24
|
+
client = MongoClient(
|
|
25
|
+
uri=config.mongodb.uri,
|
|
26
|
+
database=config.mongodb.database,
|
|
27
|
+
min_pool_size=config.mongodb.min_pool_size,
|
|
28
|
+
max_pool_size=config.mongodb.max_pool_size,
|
|
29
|
+
)
|
|
30
|
+
return MongoOperationalStore(client) # type: ignore[return-value]
|
|
31
|
+
|
|
32
|
+
if provider in ("sqlite", "postgresql"):
|
|
33
|
+
from minder.store.relational import RelationalStore
|
|
34
|
+
|
|
35
|
+
if provider == "sqlite":
|
|
36
|
+
db_url = _sqlite_db_url(config.relational_store.db_path)
|
|
37
|
+
else:
|
|
38
|
+
db_url = config.relational_store.uri
|
|
39
|
+
|
|
40
|
+
return RelationalStore(db_url) # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Unsupported relational_store.provider '{provider}'. "
|
|
44
|
+
"Supported: 'mongodb', 'sqlite', 'postgresql'."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_cache(config: MinderConfig) -> ICacheProvider:
|
|
49
|
+
provider = config.cache.provider
|
|
50
|
+
|
|
51
|
+
if provider == "redis":
|
|
52
|
+
return RedisCacheProvider(
|
|
53
|
+
uri=config.redis.uri,
|
|
54
|
+
prefix=config.redis.prefix,
|
|
55
|
+
default_ttl=config.redis.cache_ttl,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Unsupported cache.provider '{provider}'. "
|
|
60
|
+
"Only 'redis' is supported. Set [cache] provider = \"redis\" in minder.toml."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_vector_store(config: MinderConfig, store: IOperationalStore) -> IVectorStore:
|
|
65
|
+
if config.vector_store.provider == "milvus":
|
|
66
|
+
from minder.store.milvus.client import MilvusClient
|
|
67
|
+
from minder.store.milvus.vector_store import MilvusVectorStore
|
|
68
|
+
|
|
69
|
+
client = MilvusClient(uri=config.vector_store.uri)
|
|
70
|
+
return MilvusVectorStore(
|
|
71
|
+
client,
|
|
72
|
+
store,
|
|
73
|
+
prefix=config.vector_store.collection_prefix,
|
|
74
|
+
dimensions=config.embedding.dimensions,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return VectorStore(store, store)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_graph_store(config: MinderConfig) -> IGraphRepository | None:
|
|
81
|
+
if not config.graph_store.enabled:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
provider = config.graph_store.provider
|
|
85
|
+
if provider == "auto":
|
|
86
|
+
provider = config.relational_store.provider
|
|
87
|
+
if provider == "mongodb":
|
|
88
|
+
provider = "sqlite"
|
|
89
|
+
|
|
90
|
+
if provider in ("sqlite", "postgresql"):
|
|
91
|
+
from minder.store.graph import KnowledgeGraphStore
|
|
92
|
+
|
|
93
|
+
if provider == "sqlite":
|
|
94
|
+
if config.graph_store.provider == "auto" and config.relational_store.provider == "sqlite":
|
|
95
|
+
db_url = _sqlite_db_url(config.relational_store.db_path)
|
|
96
|
+
else:
|
|
97
|
+
db_url = _sqlite_db_url(config.graph_store.db_path)
|
|
98
|
+
else:
|
|
99
|
+
if config.graph_store.provider == "auto" and config.relational_store.provider == "postgresql":
|
|
100
|
+
db_url = config.relational_store.uri
|
|
101
|
+
else:
|
|
102
|
+
db_url = config.graph_store.uri
|
|
103
|
+
|
|
104
|
+
return KnowledgeGraphStore(db_url)
|
|
105
|
+
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Unsupported graph_store.provider '{provider}'. "
|
|
108
|
+
"Supported: 'auto', 'sqlite', 'postgresql'."
|
|
109
|
+
)
|