remdb 0.3.146__py3-none-any.whl → 0.3.181__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (57) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +36 -9
  5. rem/agentic/mcp/tool_wrapper.py +43 -14
  6. rem/agentic/providers/pydantic_ai.py +76 -34
  7. rem/agentic/schema.py +4 -3
  8. rem/agentic/tools/rem_tools.py +11 -0
  9. rem/api/deps.py +3 -5
  10. rem/api/main.py +22 -3
  11. rem/api/mcp_router/resources.py +75 -14
  12. rem/api/mcp_router/server.py +28 -23
  13. rem/api/mcp_router/tools.py +177 -2
  14. rem/api/middleware/tracking.py +5 -5
  15. rem/api/routers/auth.py +352 -6
  16. rem/api/routers/chat/completions.py +5 -3
  17. rem/api/routers/chat/streaming.py +95 -22
  18. rem/api/routers/messages.py +24 -15
  19. rem/auth/__init__.py +13 -3
  20. rem/auth/jwt.py +352 -0
  21. rem/auth/middleware.py +70 -30
  22. rem/auth/providers/__init__.py +4 -1
  23. rem/auth/providers/email.py +215 -0
  24. rem/cli/commands/ask.py +1 -1
  25. rem/cli/commands/db.py +118 -54
  26. rem/models/entities/__init__.py +4 -0
  27. rem/models/entities/ontology.py +93 -101
  28. rem/models/entities/subscriber.py +175 -0
  29. rem/models/entities/user.py +1 -0
  30. rem/schemas/agents/core/agent-builder.yaml +235 -0
  31. rem/services/__init__.py +3 -1
  32. rem/services/content/service.py +4 -3
  33. rem/services/email/__init__.py +10 -0
  34. rem/services/email/service.py +522 -0
  35. rem/services/email/templates.py +360 -0
  36. rem/services/embeddings/worker.py +26 -12
  37. rem/services/postgres/README.md +38 -0
  38. rem/services/postgres/diff_service.py +19 -3
  39. rem/services/postgres/pydantic_to_sqlalchemy.py +37 -2
  40. rem/services/postgres/register_type.py +1 -1
  41. rem/services/postgres/repository.py +37 -25
  42. rem/services/postgres/schema_generator.py +5 -5
  43. rem/services/postgres/sql_builder.py +6 -5
  44. rem/services/session/compression.py +113 -50
  45. rem/services/session/reload.py +14 -7
  46. rem/services/user_service.py +41 -9
  47. rem/settings.py +182 -1
  48. rem/sql/background_indexes.sql +5 -0
  49. rem/sql/migrations/001_install.sql +33 -4
  50. rem/sql/migrations/002_install_models.sql +204 -186
  51. rem/sql/migrations/005_schema_update.sql +145 -0
  52. rem/utils/model_helpers.py +101 -0
  53. rem/utils/schema_loader.py +45 -7
  54. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/METADATA +1 -1
  55. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/RECORD +57 -48
  56. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/WHEEL +0 -0
  57. {remdb-0.3.146.dist-info → remdb-0.3.181.dist-info}/entry_points.txt +0 -0
rem/auth/jwt.py ADDED
@@ -0,0 +1,352 @@
1
+ """
2
+ JWT Token Service for REM Authentication.
3
+
4
+ Provides JWT token generation and validation for stateless authentication.
5
+ Uses HS256 algorithm with the session secret for signing.
6
+
7
+ Token Types:
8
+ - Access Token: Short-lived (default 1 hour), used for API authentication
9
+ - Refresh Token: Long-lived (default 7 days), used to obtain new access tokens
10
+
11
+ Token Claims:
12
+ - sub: User ID (UUID string)
13
+ - email: User email
14
+ - name: User display name
15
+ - role: User role (user, admin)
16
+ - tier: User subscription tier
17
+ - roles: List of roles for authorization
18
+ - provider: Auth provider (email, google, microsoft)
19
+ - tenant_id: Tenant identifier for multi-tenancy
20
+ - exp: Expiration timestamp
21
+ - iat: Issued at timestamp
22
+ - type: Token type (access, refresh)
23
+
24
+ Usage:
25
+ from rem.auth.jwt import JWTService
26
+
27
+ jwt_service = JWTService()
28
+
29
+ # Generate tokens after successful authentication
30
+ tokens = jwt_service.create_tokens(user_dict)
31
+ # Returns: {"access_token": "...", "refresh_token": "...", "token_type": "bearer", "expires_in": 3600}
32
+
33
+ # Validate token from Authorization header
34
+ user = jwt_service.verify_token(token)
35
+ # Returns user dict or None if invalid
36
+
37
+ # Refresh access token
38
+ new_tokens = jwt_service.refresh_access_token(refresh_token)
39
+ """
40
+
41
+ import time
42
+ import hmac
43
+ import hashlib
44
+ import base64
45
+ import json
46
+ from datetime import datetime, timezone
47
+ from typing import Optional
48
+
49
+ from loguru import logger
50
+
51
+
52
+ class JWTService:
53
+ """
54
+ JWT token service for authentication.
55
+
56
+ Uses HMAC-SHA256 for signing - simple and secure for single-service deployment.
57
+ For multi-service deployments, consider switching to RS256 with public/private keys.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ secret: str | None = None,
63
+ access_token_expiry_seconds: int = 3600, # 1 hour
64
+ refresh_token_expiry_seconds: int = 604800, # 7 days
65
+ issuer: str = "rem",
66
+ ):
67
+ """
68
+ Initialize JWT service.
69
+
70
+ Args:
71
+ secret: Secret key for signing (uses settings.auth.session_secret if not provided)
72
+ access_token_expiry_seconds: Access token lifetime in seconds
73
+ refresh_token_expiry_seconds: Refresh token lifetime in seconds
74
+ issuer: Token issuer identifier
75
+ """
76
+ if secret:
77
+ self._secret = secret
78
+ else:
79
+ from ..settings import settings
80
+ self._secret = settings.auth.session_secret
81
+
82
+ self._access_expiry = access_token_expiry_seconds
83
+ self._refresh_expiry = refresh_token_expiry_seconds
84
+ self._issuer = issuer
85
+
86
+ def _base64url_encode(self, data: bytes) -> str:
87
+ """Base64url encode without padding."""
88
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
89
+
90
+ def _base64url_decode(self, data: str) -> bytes:
91
+ """Base64url decode with padding restoration."""
92
+ padding = 4 - len(data) % 4
93
+ if padding != 4:
94
+ data += "=" * padding
95
+ return base64.urlsafe_b64decode(data)
96
+
97
+ def _sign(self, message: str) -> str:
98
+ """Create HMAC-SHA256 signature."""
99
+ signature = hmac.new(
100
+ self._secret.encode("utf-8"),
101
+ message.encode("utf-8"),
102
+ hashlib.sha256
103
+ ).digest()
104
+ return self._base64url_encode(signature)
105
+
106
+ def _create_token(self, payload: dict) -> str:
107
+ """
108
+ Create a JWT token.
109
+
110
+ Args:
111
+ payload: Token claims
112
+
113
+ Returns:
114
+ Encoded JWT string
115
+ """
116
+ header = {"alg": "HS256", "typ": "JWT"}
117
+
118
+ header_encoded = self._base64url_encode(json.dumps(header, separators=(",", ":")).encode())
119
+ payload_encoded = self._base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
120
+
121
+ message = f"{header_encoded}.{payload_encoded}"
122
+ signature = self._sign(message)
123
+
124
+ return f"{message}.{signature}"
125
+
126
+ def _verify_signature(self, token: str) -> dict | None:
127
+ """
128
+ Verify token signature and decode payload.
129
+
130
+ Args:
131
+ token: JWT token string
132
+
133
+ Returns:
134
+ Decoded payload dict or None if invalid
135
+ """
136
+ try:
137
+ parts = token.split(".")
138
+ if len(parts) != 3:
139
+ return None
140
+
141
+ header_encoded, payload_encoded, signature = parts
142
+
143
+ # Verify signature
144
+ message = f"{header_encoded}.{payload_encoded}"
145
+ expected_signature = self._sign(message)
146
+
147
+ if not hmac.compare_digest(signature, expected_signature):
148
+ logger.debug("JWT signature verification failed")
149
+ return None
150
+
151
+ # Decode payload
152
+ payload = json.loads(self._base64url_decode(payload_encoded))
153
+ return payload
154
+
155
+ except Exception as e:
156
+ logger.debug(f"JWT decode error: {e}")
157
+ return None
158
+
159
+ def create_tokens(
160
+ self,
161
+ user: dict,
162
+ access_expiry: int | None = None,
163
+ refresh_expiry: int | None = None,
164
+ ) -> dict:
165
+ """
166
+ Create access and refresh tokens for a user.
167
+
168
+ Args:
169
+ user: User dict with id, email, name, role, tier, roles, provider, tenant_id
170
+ access_expiry: Override access token expiry (seconds)
171
+ refresh_expiry: Override refresh token expiry (seconds)
172
+
173
+ Returns:
174
+ Dict with access_token, refresh_token, token_type, expires_in
175
+ """
176
+ now = int(time.time())
177
+ access_exp = access_expiry or self._access_expiry
178
+ refresh_exp = refresh_expiry or self._refresh_expiry
179
+
180
+ # Common claims
181
+ base_claims = {
182
+ "sub": user.get("id", ""),
183
+ "email": user.get("email", ""),
184
+ "name": user.get("name", ""),
185
+ "role": user.get("role"),
186
+ "tier": user.get("tier", "free"),
187
+ "roles": user.get("roles", ["user"]),
188
+ "provider": user.get("provider", "email"),
189
+ "tenant_id": user.get("tenant_id", "default"),
190
+ "iss": self._issuer,
191
+ "iat": now,
192
+ }
193
+
194
+ # Access token
195
+ access_payload = {
196
+ **base_claims,
197
+ "type": "access",
198
+ "exp": now + access_exp,
199
+ }
200
+ access_token = self._create_token(access_payload)
201
+
202
+ # Refresh token (minimal claims for security)
203
+ refresh_payload = {
204
+ "sub": user.get("id", ""),
205
+ "email": user.get("email", ""),
206
+ "type": "refresh",
207
+ "iss": self._issuer,
208
+ "iat": now,
209
+ "exp": now + refresh_exp,
210
+ }
211
+ refresh_token = self._create_token(refresh_payload)
212
+
213
+ return {
214
+ "access_token": access_token,
215
+ "refresh_token": refresh_token,
216
+ "token_type": "bearer",
217
+ "expires_in": access_exp,
218
+ }
219
+
220
+ def verify_token(self, token: str, token_type: str = "access") -> dict | None:
221
+ """
222
+ Verify a token and return user claims.
223
+
224
+ Args:
225
+ token: JWT token string
226
+ token_type: Expected token type ("access" or "refresh")
227
+
228
+ Returns:
229
+ User dict with claims or None if invalid/expired
230
+ """
231
+ payload = self._verify_signature(token)
232
+ if not payload:
233
+ return None
234
+
235
+ # Check token type
236
+ if payload.get("type") != token_type:
237
+ logger.debug(f"Token type mismatch: expected {token_type}, got {payload.get('type')}")
238
+ return None
239
+
240
+ # Check expiration
241
+ exp = payload.get("exp", 0)
242
+ if exp < time.time():
243
+ logger.debug("Token expired")
244
+ return None
245
+
246
+ # Check issuer
247
+ if payload.get("iss") != self._issuer:
248
+ logger.debug(f"Token issuer mismatch: expected {self._issuer}, got {payload.get('iss')}")
249
+ return None
250
+
251
+ # Return user dict (compatible with session user format)
252
+ return {
253
+ "id": payload.get("sub"),
254
+ "email": payload.get("email"),
255
+ "name": payload.get("name"),
256
+ "role": payload.get("role"),
257
+ "tier": payload.get("tier", "free"),
258
+ "roles": payload.get("roles", ["user"]),
259
+ "provider": payload.get("provider", "email"),
260
+ "tenant_id": payload.get("tenant_id", "default"),
261
+ }
262
+
263
+ def refresh_access_token(self, refresh_token: str) -> dict | None:
264
+ """
265
+ Create new access token using refresh token.
266
+
267
+ Args:
268
+ refresh_token: Valid refresh token
269
+
270
+ Returns:
271
+ New token dict or None if refresh token is invalid
272
+ """
273
+ # Verify refresh token
274
+ payload = self._verify_signature(refresh_token)
275
+ if not payload:
276
+ return None
277
+
278
+ if payload.get("type") != "refresh":
279
+ logger.debug("Not a refresh token")
280
+ return None
281
+
282
+ # Check expiration
283
+ exp = payload.get("exp", 0)
284
+ if exp < time.time():
285
+ logger.debug("Refresh token expired")
286
+ return None
287
+
288
+ # Create new access token with minimal info from refresh token
289
+ # In production, you'd look up the full user from database
290
+ user = {
291
+ "id": payload.get("sub"),
292
+ "email": payload.get("email"),
293
+ "name": payload.get("email", "").split("@")[0],
294
+ "provider": "email",
295
+ "tenant_id": "default",
296
+ "tier": "free",
297
+ "roles": ["user"],
298
+ }
299
+
300
+ # Only return new access token, keep same refresh token
301
+ now = int(time.time())
302
+ access_payload = {
303
+ "sub": user["id"],
304
+ "email": user["email"],
305
+ "name": user["name"],
306
+ "role": user.get("role"),
307
+ "tier": user["tier"],
308
+ "roles": user["roles"],
309
+ "provider": user["provider"],
310
+ "tenant_id": user["tenant_id"],
311
+ "type": "access",
312
+ "iss": self._issuer,
313
+ "iat": now,
314
+ "exp": now + self._access_expiry,
315
+ }
316
+
317
+ return {
318
+ "access_token": self._create_token(access_payload),
319
+ "token_type": "bearer",
320
+ "expires_in": self._access_expiry,
321
+ }
322
+
323
+ def decode_without_verification(self, token: str) -> dict | None:
324
+ """
325
+ Decode token without verification (for debugging only).
326
+
327
+ Args:
328
+ token: JWT token string
329
+
330
+ Returns:
331
+ Decoded payload or None
332
+ """
333
+ try:
334
+ parts = token.split(".")
335
+ if len(parts) != 3:
336
+ return None
337
+ payload = json.loads(self._base64url_decode(parts[1]))
338
+ return payload
339
+ except Exception:
340
+ return None
341
+
342
+
343
+ # Singleton instance for convenience
344
+ _jwt_service: Optional[JWTService] = None
345
+
346
+
347
+ def get_jwt_service() -> JWTService:
348
+ """Get or create the JWT service singleton."""
349
+ global _jwt_service
350
+ if _jwt_service is None:
351
+ _jwt_service = JWTService()
352
+ return _jwt_service
rem/auth/middleware.py CHANGED
@@ -1,18 +1,28 @@
1
1
  """
2
- OAuth Authentication Middleware for FastAPI.
2
+ Authentication Middleware for FastAPI.
3
3
 
4
- Protects API endpoints by requiring valid session.
5
- Supports anonymous access with rate limiting when allow_anonymous=True.
4
+ Protects API endpoints by requiring valid authentication.
5
+ Supports multiple auth methods: JWT, API Key, Session, Dev Token.
6
+ Anonymous access with rate limiting when allow_anonymous=True.
6
7
  MCP endpoints are always protected unless explicitly disabled.
7
8
 
8
9
  Design Pattern:
9
- - Check X-API-Key header first (if API key auth enabled)
10
- - Check session for user on protected paths
11
- - Check Bearer token for dev token (non-production only)
10
+ - API Key (X-API-Key): Access control guardrail, NOT user identity
11
+ - JWT (Authorization: Bearer): Primary method for user identity
12
+ - Dev token: Non-production testing (starts with "dev_")
13
+ - Session: Backward compatibility for browser-based auth
12
14
  - MCP paths always require authentication (protected service)
13
- - If allow_anonymous=True: Allow unauthenticated requests (marked as ANONYMOUS tier)
14
- - If allow_anonymous=False: Return 401 for API calls, redirect browsers to login
15
- - Exclude auth endpoints and public paths
15
+
16
+ Authentication Flow:
17
+ 1. If API key enabled: Validate X-API-Key header (access gate)
18
+ 2. Check JWT token for user identity (primary)
19
+ 3. Check dev token for testing (non-production only)
20
+ 4. Check session for user (backward compatibility)
21
+ 5. If allow_anonymous=True: Allow as anonymous (rate-limited)
22
+ 6. If allow_anonymous=False: Return 401 / redirect to login
23
+
24
+ IMPORTANT: API key validates ACCESS, JWT identifies USER.
25
+ Both can be required: API key for access + JWT for user identity.
16
26
 
17
27
  Access Modes (configured in settings.auth):
18
28
  - enabled=true, allow_anonymous=true: Auth available, anonymous gets rate-limited access
@@ -22,10 +32,9 @@ Access Modes (configured in settings.auth):
22
32
  - mcp_requires_auth=false: MCP follows normal allow_anonymous rules (dev only)
23
33
 
24
34
  API Key Authentication (configured in settings.api):
25
- - api_key_enabled=true: Require X-API-Key header for protected endpoints
35
+ - api_key_enabled=true: Require X-API-Key header for access
26
36
  - api_key: The secret key to validate against
27
- - Provides simple programmatic access without OAuth flow
28
- - X-API-Key header takes precedence over session auth
37
+ - API key is an ACCESS GATE, not user identity - JWT still needed for user
29
38
 
30
39
  Dev Token Support (non-production only):
31
40
  - GET /api/auth/dev/token returns a Bearer token for test-user
@@ -122,6 +131,34 @@ class AuthMiddleware(BaseHTTPMiddleware):
122
131
  logger.warning("Invalid X-API-Key provided")
123
132
  return None
124
133
 
134
+ def _check_jwt_token(self, request: Request) -> dict | None:
135
+ """
136
+ Check for valid JWT in Authorization header.
137
+
138
+ Returns:
139
+ User dict if valid JWT, None otherwise
140
+ """
141
+ auth_header = request.headers.get("authorization", "")
142
+ if not auth_header.startswith("Bearer "):
143
+ return None
144
+
145
+ token = auth_header[7:] # Strip "Bearer "
146
+
147
+ # Skip dev tokens (handled separately)
148
+ if token.startswith("dev_"):
149
+ return None
150
+
151
+ # Verify JWT token
152
+ from .jwt import get_jwt_service
153
+ jwt_service = get_jwt_service()
154
+ user = jwt_service.verify_token(token)
155
+
156
+ if user:
157
+ logger.debug(f"JWT authenticated: {user.get('email')}")
158
+ return user
159
+
160
+ return None
161
+
125
162
  def _check_dev_token(self, request: Request) -> dict | None:
126
163
  """
127
164
  Check for valid dev token in Authorization header (non-production only).
@@ -182,30 +219,33 @@ class AuthMiddleware(BaseHTTPMiddleware):
182
219
  if not is_protected or is_excluded:
183
220
  return await call_next(request)
184
221
 
185
- # Check for X-API-Key header first (if enabled)
186
- api_key_user = self._check_api_key(request)
187
- if api_key_user:
188
- request.state.user = api_key_user
189
- request.state.is_anonymous = False
190
- return await call_next(request)
191
-
192
- # If API key auth is enabled but no valid key provided, reject immediately
222
+ # API key validation (access control, not user identity)
223
+ # API key is a guardrail for access - JWT identifies the actual user
193
224
  if settings.api.api_key_enabled:
194
- # Check if X-API-Key header was provided but invalid
195
- if request.headers.get("x-api-key"):
225
+ api_key = request.headers.get("x-api-key")
226
+ if not api_key:
227
+ logger.debug(f"Missing X-API-Key for: {path}")
228
+ return JSONResponse(
229
+ status_code=401,
230
+ content={"detail": "API key required. Include X-API-Key header."},
231
+ headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
232
+ )
233
+ if api_key != settings.api.api_key:
196
234
  logger.warning(f"Invalid X-API-Key for: {path}")
197
235
  return JSONResponse(
198
236
  status_code=401,
199
237
  content={"detail": "Invalid API key"},
200
238
  headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
201
239
  )
202
- # No API key provided when required
203
- logger.debug(f"Missing X-API-Key for: {path}")
204
- return JSONResponse(
205
- status_code=401,
206
- content={"detail": "API key required. Include X-API-Key header."},
207
- headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
208
- )
240
+ logger.debug("X-API-Key validated for access")
241
+ # API key valid - continue to check JWT for user identity
242
+
243
+ # Check for JWT token in Authorization header (primary user identity)
244
+ jwt_user = self._check_jwt_token(request)
245
+ if jwt_user:
246
+ request.state.user = jwt_user
247
+ request.state.is_anonymous = False
248
+ return await call_next(request)
209
249
 
210
250
  # Check for dev token (non-production only)
211
251
  dev_user = self._check_dev_token(request)
@@ -214,7 +254,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
214
254
  request.state.is_anonymous = False
215
255
  return await call_next(request)
216
256
 
217
- # Check for valid session
257
+ # Check for valid session (backward compatibility)
218
258
  user = request.session.get("user")
219
259
 
220
260
  if user:
@@ -1,6 +1,7 @@
1
- """OAuth provider implementations."""
1
+ """Authentication provider implementations."""
2
2
 
3
3
  from .base import OAuthProvider, OAuthTokens, OAuthUserInfo
4
+ from .email import EmailAuthProvider, EmailAuthResult
4
5
  from .google import GoogleOAuthProvider
5
6
  from .microsoft import MicrosoftOAuthProvider
6
7
 
@@ -8,6 +9,8 @@ __all__ = [
8
9
  "OAuthProvider",
9
10
  "OAuthTokens",
10
11
  "OAuthUserInfo",
12
+ "EmailAuthProvider",
13
+ "EmailAuthResult",
11
14
  "GoogleOAuthProvider",
12
15
  "MicrosoftOAuthProvider",
13
16
  ]