kailash 0.1.4__py3-none-any.whl → 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.
Files changed (83) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/access_control.py +740 -0
  3. kailash/api/__main__.py +6 -0
  4. kailash/api/auth.py +668 -0
  5. kailash/api/custom_nodes.py +285 -0
  6. kailash/api/custom_nodes_secure.py +377 -0
  7. kailash/api/database.py +620 -0
  8. kailash/api/studio.py +915 -0
  9. kailash/api/studio_secure.py +893 -0
  10. kailash/mcp/__init__.py +53 -0
  11. kailash/mcp/__main__.py +13 -0
  12. kailash/mcp/ai_registry_server.py +712 -0
  13. kailash/mcp/client.py +447 -0
  14. kailash/mcp/client_new.py +334 -0
  15. kailash/mcp/server.py +293 -0
  16. kailash/mcp/server_new.py +336 -0
  17. kailash/mcp/servers/__init__.py +12 -0
  18. kailash/mcp/servers/ai_registry.py +289 -0
  19. kailash/nodes/__init__.py +4 -2
  20. kailash/nodes/ai/__init__.py +38 -0
  21. kailash/nodes/ai/a2a.py +1790 -0
  22. kailash/nodes/ai/agents.py +116 -2
  23. kailash/nodes/ai/ai_providers.py +206 -8
  24. kailash/nodes/ai/intelligent_agent_orchestrator.py +2108 -0
  25. kailash/nodes/ai/iterative_llm_agent.py +1280 -0
  26. kailash/nodes/ai/llm_agent.py +324 -1
  27. kailash/nodes/ai/self_organizing.py +1623 -0
  28. kailash/nodes/api/http.py +106 -25
  29. kailash/nodes/api/rest.py +116 -21
  30. kailash/nodes/base.py +15 -2
  31. kailash/nodes/base_async.py +45 -0
  32. kailash/nodes/base_cycle_aware.py +374 -0
  33. kailash/nodes/base_with_acl.py +338 -0
  34. kailash/nodes/code/python.py +135 -27
  35. kailash/nodes/data/readers.py +116 -53
  36. kailash/nodes/data/writers.py +16 -6
  37. kailash/nodes/logic/__init__.py +8 -0
  38. kailash/nodes/logic/async_operations.py +48 -9
  39. kailash/nodes/logic/convergence.py +642 -0
  40. kailash/nodes/logic/loop.py +153 -0
  41. kailash/nodes/logic/operations.py +212 -27
  42. kailash/nodes/logic/workflow.py +26 -18
  43. kailash/nodes/mixins/__init__.py +11 -0
  44. kailash/nodes/mixins/mcp.py +228 -0
  45. kailash/nodes/mixins.py +387 -0
  46. kailash/nodes/transform/__init__.py +8 -1
  47. kailash/nodes/transform/processors.py +119 -4
  48. kailash/runtime/__init__.py +2 -1
  49. kailash/runtime/access_controlled.py +458 -0
  50. kailash/runtime/local.py +106 -33
  51. kailash/runtime/parallel_cyclic.py +529 -0
  52. kailash/sdk_exceptions.py +90 -5
  53. kailash/security.py +845 -0
  54. kailash/tracking/manager.py +38 -15
  55. kailash/tracking/models.py +1 -1
  56. kailash/tracking/storage/filesystem.py +30 -2
  57. kailash/utils/__init__.py +8 -0
  58. kailash/workflow/__init__.py +18 -0
  59. kailash/workflow/convergence.py +270 -0
  60. kailash/workflow/cycle_analyzer.py +768 -0
  61. kailash/workflow/cycle_builder.py +573 -0
  62. kailash/workflow/cycle_config.py +709 -0
  63. kailash/workflow/cycle_debugger.py +760 -0
  64. kailash/workflow/cycle_exceptions.py +601 -0
  65. kailash/workflow/cycle_profiler.py +671 -0
  66. kailash/workflow/cycle_state.py +338 -0
  67. kailash/workflow/cyclic_runner.py +985 -0
  68. kailash/workflow/graph.py +500 -39
  69. kailash/workflow/migration.py +768 -0
  70. kailash/workflow/safety.py +365 -0
  71. kailash/workflow/templates.py +744 -0
  72. kailash/workflow/validation.py +693 -0
  73. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/METADATA +446 -13
  74. kailash-0.2.0.dist-info/RECORD +125 -0
  75. kailash/nodes/mcp/__init__.py +0 -11
  76. kailash/nodes/mcp/client.py +0 -554
  77. kailash/nodes/mcp/resource.py +0 -682
  78. kailash/nodes/mcp/server.py +0 -577
  79. kailash-0.1.4.dist-info/RECORD +0 -85
  80. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
  81. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
  82. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
  83. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,6 @@
1
+ """Entry point for running the Workflow Studio API."""
2
+
3
+ from .studio import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
kailash/api/auth.py ADDED
@@ -0,0 +1,668 @@
1
+ """
2
+ JWT Authentication and Tenant Isolation for Kailash Workflow Studio
3
+
4
+ This module provides:
5
+ - JWT token generation and validation
6
+ - User authentication and authorization
7
+ - Tenant isolation middleware
8
+ - Permission-based access control
9
+
10
+ Design Principles:
11
+ - Stateless authentication via JWT tokens
12
+ - Tenant data isolation at all levels
13
+ - Role-based access control (RBAC)
14
+ - Secure token storage and rotation
15
+
16
+ Dependencies:
17
+ - python-jose[cryptography]: JWT token handling
18
+ - passlib: Password hashing
19
+ - fastapi-security: Security utilities
20
+
21
+ Usage:
22
+ >>> from kailash.api.auth import JWTAuth, get_current_user
23
+ >>> auth = JWTAuth(secret_key="your-secret-key")
24
+ >>> token = auth.create_access_token({"sub": "user@example.com", "tenant_id": "tenant1"})
25
+ >>> decoded = auth.verify_token(token)
26
+
27
+ Implementation:
28
+ The auth system uses JWT tokens with the following claims:
29
+ - sub: User identifier (email or user_id)
30
+ - tenant_id: Tenant identifier for isolation
31
+ - roles: List of user roles
32
+ - exp: Token expiration time
33
+ - iat: Token issued at time
34
+
35
+ Security Considerations:
36
+ - Tokens expire after 24 hours by default
37
+ - Refresh tokens supported for seamless rotation
38
+ - All tenant data queries filtered by tenant_id
39
+ - Passwords hashed using bcrypt with salt
40
+
41
+ Testing:
42
+ See tests/test_api/test_auth.py for comprehensive tests
43
+
44
+ Future Enhancements:
45
+ - OAuth2/OIDC integration
46
+ - Multi-factor authentication
47
+ - API key authentication for service accounts
48
+ """
49
+
50
+ import os
51
+ import secrets
52
+ import threading
53
+ from datetime import datetime, timedelta, timezone
54
+ from typing import Any, Dict, List, Optional
55
+
56
+ from fastapi import Depends, HTTPException, Request, status
57
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
58
+ from jose import JWTError, jwt
59
+ from passlib.context import CryptContext
60
+ from pydantic import BaseModel, EmailStr, Field
61
+ from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, String
62
+ from sqlalchemy.orm import Session, relationship
63
+
64
+ # Import after database module to avoid circular imports
65
+ import kailash.api.database as db
66
+
67
+ Base = db.Base
68
+ get_db_session = db.get_db_session
69
+
70
+ # Security configuration
71
+ DEFAULT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
72
+ ALGORITHM = "HS256"
73
+ ACCESS_TOKEN_EXPIRE_HOURS = 24
74
+ REFRESH_TOKEN_EXPIRE_DAYS = 30
75
+
76
+ # Password hashing
77
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
78
+
79
+ # Bearer token security
80
+ security = HTTPBearer()
81
+
82
+
83
+ # Database Models
84
+ class User(Base):
85
+ """User account model with tenant association"""
86
+
87
+ __tablename__ = "users"
88
+
89
+ id = Column(String(36), primary_key=True)
90
+ email = Column(String(255), unique=True, nullable=False, index=True)
91
+ username = Column(String(100), nullable=False)
92
+ hashed_password = Column(String(255), nullable=False)
93
+
94
+ # Tenant association
95
+ tenant_id = Column(String(36), ForeignKey("tenants.id"), nullable=False)
96
+
97
+ # User status
98
+ is_active = Column(Boolean, default=True)
99
+ is_verified = Column(Boolean, default=False)
100
+ is_superuser = Column(Boolean, default=False)
101
+
102
+ # User roles and permissions
103
+ roles = Column(JSON, default=lambda: ["user"])
104
+ permissions = Column(JSON, default=lambda: [])
105
+
106
+ # Timestamps
107
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
108
+ updated_at = Column(
109
+ DateTime,
110
+ default=lambda: datetime.now(timezone.utc),
111
+ onupdate=lambda: datetime.now(timezone.utc),
112
+ )
113
+ last_login = Column(DateTime)
114
+
115
+ # Relationships
116
+ tenant = relationship("Tenant", back_populates="users")
117
+ api_keys = relationship(
118
+ "APIKey", back_populates="user", cascade="all, delete-orphan"
119
+ )
120
+
121
+ __table_args__ = (
122
+ Index("idx_user_tenant", "tenant_id"),
123
+ Index("idx_user_email_tenant", "email", "tenant_id", unique=True),
124
+ )
125
+
126
+
127
+ class Tenant(Base):
128
+ """Tenant model for multi-tenancy"""
129
+
130
+ __tablename__ = "tenants"
131
+
132
+ id = Column(String(36), primary_key=True)
133
+ name = Column(String(255), nullable=False)
134
+ slug = Column(String(100), unique=True, nullable=False, index=True)
135
+
136
+ # Tenant configuration
137
+ settings = Column(JSON, default=dict)
138
+ features = Column(JSON, default=lambda: ["workflows", "custom_nodes", "executions"])
139
+
140
+ # Limits and quotas
141
+ max_users = Column(JSON, default=lambda: {"limit": 10, "current": 0})
142
+ max_workflows = Column(JSON, default=lambda: {"limit": 100, "current": 0})
143
+ max_executions_per_month = Column(
144
+ JSON, default=lambda: {"limit": 1000, "current": 0}
145
+ )
146
+ storage_quota_mb = Column(JSON, default=lambda: {"limit": 1024, "current": 0})
147
+
148
+ # Status
149
+ is_active = Column(Boolean, default=True)
150
+ subscription_tier = Column(String(50), default="free")
151
+
152
+ # Timestamps
153
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
154
+ updated_at = Column(
155
+ DateTime,
156
+ default=lambda: datetime.now(timezone.utc),
157
+ onupdate=lambda: datetime.now(timezone.utc),
158
+ )
159
+
160
+ # Relationships
161
+ users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
162
+ api_keys = relationship(
163
+ "APIKey", back_populates="tenant", cascade="all, delete-orphan"
164
+ )
165
+
166
+
167
+ class APIKey(Base):
168
+ """API Key model for service authentication"""
169
+
170
+ __tablename__ = "api_keys"
171
+
172
+ id = Column(String(36), primary_key=True)
173
+ key_hash = Column(String(255), unique=True, nullable=False, index=True)
174
+ name = Column(String(255), nullable=False)
175
+
176
+ # Association
177
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
178
+ tenant_id = Column(String(36), ForeignKey("tenants.id"), nullable=False)
179
+
180
+ # Permissions
181
+ scopes = Column(JSON, default=lambda: ["read:workflows", "execute:workflows"])
182
+
183
+ # Status and limits
184
+ is_active = Column(Boolean, default=True)
185
+ expires_at = Column(DateTime)
186
+ last_used_at = Column(DateTime)
187
+ usage_count = Column(JSON, default=lambda: {"total": 0, "monthly": 0})
188
+
189
+ # Timestamps
190
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
191
+
192
+ # Relationships
193
+ user = relationship("User", back_populates="api_keys")
194
+ tenant = relationship("Tenant", back_populates="api_keys")
195
+
196
+ __table_args__ = (Index("idx_apikey_tenant", "tenant_id"),)
197
+
198
+
199
+ # Pydantic models
200
+ class UserCreate(BaseModel):
201
+ """User registration model"""
202
+
203
+ email: EmailStr
204
+ username: str = Field(..., min_length=3, max_length=100)
205
+ password: str = Field(..., min_length=8)
206
+ tenant_id: Optional[str] = None # If None, create new tenant
207
+
208
+
209
+ class UserLogin(BaseModel):
210
+ """User login model"""
211
+
212
+ email: EmailStr
213
+ password: str
214
+
215
+
216
+ class TokenResponse(BaseModel):
217
+ """JWT token response"""
218
+
219
+ access_token: str
220
+ refresh_token: str
221
+ token_type: str = "bearer"
222
+ expires_in: int
223
+
224
+
225
+ class TokenData(BaseModel):
226
+ """Decoded token data"""
227
+
228
+ sub: str
229
+ tenant_id: str
230
+ roles: List[str] = ["user"]
231
+ permissions: List[str] = []
232
+ exp: Optional[datetime] = None
233
+
234
+
235
+ class JWTAuth:
236
+ """JWT authentication handler"""
237
+
238
+ def __init__(self, secret_key: str = DEFAULT_SECRET_KEY):
239
+ self.secret_key = secret_key
240
+ self.algorithm = ALGORITHM
241
+
242
+ def create_access_token(
243
+ self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None
244
+ ) -> str:
245
+ """Create a JWT access token"""
246
+ to_encode = data.copy()
247
+
248
+ if expires_delta:
249
+ expire = datetime.now(timezone.utc) + expires_delta
250
+ else:
251
+ expire = datetime.now(timezone.utc) + timedelta(
252
+ hours=ACCESS_TOKEN_EXPIRE_HOURS
253
+ )
254
+
255
+ to_encode.update(
256
+ {"exp": expire, "iat": datetime.now(timezone.utc), "type": "access"}
257
+ )
258
+
259
+ encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
260
+ return encoded_jwt
261
+
262
+ def create_refresh_token(self, data: Dict[str, Any]) -> str:
263
+ """Create a JWT refresh token"""
264
+ to_encode = data.copy()
265
+ expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
266
+
267
+ to_encode.update(
268
+ {"exp": expire, "iat": datetime.now(timezone.utc), "type": "refresh"}
269
+ )
270
+
271
+ encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
272
+ return encoded_jwt
273
+
274
+ def verify_token(self, token: str, token_type: str = "access") -> TokenData:
275
+ """Verify and decode a JWT token"""
276
+ try:
277
+ payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
278
+
279
+ # Verify token type
280
+ if payload.get("type") != token_type:
281
+ raise HTTPException(
282
+ status_code=status.HTTP_401_UNAUTHORIZED,
283
+ detail="Invalid token type",
284
+ )
285
+
286
+ # Extract claims
287
+ sub: str = payload.get("sub")
288
+ tenant_id: str = payload.get("tenant_id")
289
+
290
+ if sub is None or tenant_id is None:
291
+ raise HTTPException(
292
+ status_code=status.HTTP_401_UNAUTHORIZED,
293
+ detail="Invalid token claims",
294
+ )
295
+
296
+ return TokenData(
297
+ sub=sub,
298
+ tenant_id=tenant_id,
299
+ roles=payload.get("roles", ["user"]),
300
+ permissions=payload.get("permissions", []),
301
+ exp=payload.get("exp"),
302
+ )
303
+
304
+ except JWTError:
305
+ raise HTTPException(
306
+ status_code=status.HTTP_401_UNAUTHORIZED,
307
+ detail="Could not validate credentials",
308
+ headers={"WWW-Authenticate": "Bearer"},
309
+ )
310
+
311
+ def create_tokens(self, user: User) -> TokenResponse:
312
+ """Create both access and refresh tokens for a user"""
313
+ token_data = {
314
+ "sub": user.email,
315
+ "tenant_id": user.tenant_id,
316
+ "roles": user.roles,
317
+ "permissions": user.permissions,
318
+ }
319
+
320
+ access_token = self.create_access_token(token_data)
321
+ refresh_token = self.create_refresh_token(token_data)
322
+
323
+ return TokenResponse(
324
+ access_token=access_token,
325
+ refresh_token=refresh_token,
326
+ expires_in=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
327
+ )
328
+
329
+
330
+ # Authentication utilities
331
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
332
+ """Verify a password against its hash"""
333
+ return pwd_context.verify(plain_password, hashed_password)
334
+
335
+
336
+ def get_password_hash(password: str) -> str:
337
+ """Hash a password"""
338
+ return pwd_context.hash(password)
339
+
340
+
341
+ def create_api_key() -> tuple[str, str]:
342
+ """Create an API key and return (key, hash)"""
343
+ key = f"kls_{secrets.token_urlsafe(32)}"
344
+ key_hash = pwd_context.hash(key)
345
+ return key, key_hash
346
+
347
+
348
+ # FastAPI dependencies
349
+ auth = JWTAuth()
350
+
351
+
352
+ async def get_current_user(
353
+ credentials: HTTPAuthorizationCredentials = Depends(security),
354
+ session: Session = Depends(get_db_session),
355
+ ) -> User:
356
+ """Get current authenticated user from JWT token"""
357
+ token = credentials.credentials
358
+ token_data = auth.verify_token(token)
359
+
360
+ user = (
361
+ session.query(User)
362
+ .filter(User.email == token_data.sub, User.tenant_id == token_data.tenant_id)
363
+ .first()
364
+ )
365
+
366
+ if not user:
367
+ raise HTTPException(
368
+ status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
369
+ )
370
+
371
+ if not user.is_active:
372
+ raise HTTPException(
373
+ status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
374
+ )
375
+
376
+ return user
377
+
378
+
379
+ async def get_current_tenant(
380
+ user: User = Depends(get_current_user), session: Session = Depends(get_db_session)
381
+ ) -> Tenant:
382
+ """Get current tenant from authenticated user"""
383
+ tenant = session.query(Tenant).filter(Tenant.id == user.tenant_id).first()
384
+
385
+ if not tenant:
386
+ raise HTTPException(
387
+ status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found"
388
+ )
389
+
390
+ if not tenant.is_active:
391
+ raise HTTPException(
392
+ status_code=status.HTTP_403_FORBIDDEN, detail="Tenant account is inactive"
393
+ )
394
+
395
+ return tenant
396
+
397
+
398
+ async def verify_api_key(
399
+ request: Request, session: Session = Depends(get_db_session)
400
+ ) -> APIKey:
401
+ """Verify API key from request header"""
402
+ api_key = request.headers.get("X-API-Key")
403
+
404
+ if not api_key:
405
+ raise HTTPException(
406
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required"
407
+ )
408
+
409
+ # Find API key by verifying against hashes
410
+ api_keys = session.query(APIKey).filter(APIKey.is_active).all()
411
+
412
+ valid_key = None
413
+ for key_record in api_keys:
414
+ if pwd_context.verify(api_key, key_record.key_hash):
415
+ valid_key = key_record
416
+ break
417
+
418
+ if not valid_key:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key"
421
+ )
422
+
423
+ # Check expiration
424
+ if valid_key.expires_at and valid_key.expires_at < datetime.now(timezone.utc):
425
+ raise HTTPException(
426
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="API key expired"
427
+ )
428
+
429
+ # Update usage
430
+ valid_key.last_used_at = datetime.now(timezone.utc)
431
+ valid_key.usage_count["total"] += 1
432
+ valid_key.usage_count["monthly"] += 1
433
+ session.commit()
434
+
435
+ return valid_key
436
+
437
+
438
+ # Permission checking
439
+ def check_permission(user: User, permission: str) -> bool:
440
+ """Check if user has a specific permission"""
441
+ # Superusers have all permissions
442
+ if user.is_superuser:
443
+ return True
444
+
445
+ # Check explicit permissions
446
+ if permission in user.permissions:
447
+ return True
448
+
449
+ # Check role-based permissions
450
+ role_permissions = {
451
+ "admin": [
452
+ "read:all",
453
+ "write:all",
454
+ "delete:all",
455
+ "manage:users",
456
+ "manage:tenant",
457
+ ],
458
+ "editor": [
459
+ "read:workflows",
460
+ "write:workflows",
461
+ "delete:workflows",
462
+ "read:nodes",
463
+ "write:nodes",
464
+ "execute:workflows",
465
+ ],
466
+ "viewer": ["read:workflows", "read:nodes", "read:executions"],
467
+ "user": ["read:own", "write:own", "execute:own"],
468
+ }
469
+
470
+ for role in user.roles:
471
+ if permission in role_permissions.get(role, []):
472
+ return True
473
+
474
+ return False
475
+
476
+
477
+ def require_permission(permission: str):
478
+ """Decorator to require specific permission"""
479
+
480
+ def permission_checker(user: User = Depends(get_current_user)):
481
+ if not check_permission(user, permission):
482
+ raise HTTPException(
483
+ status_code=status.HTTP_403_FORBIDDEN,
484
+ detail=f"Permission '{permission}' required",
485
+ )
486
+ return user
487
+
488
+ return permission_checker
489
+
490
+
491
+ # Tenant isolation utilities
492
+ class TenantContext:
493
+ """Context manager for tenant-scoped operations"""
494
+
495
+ def __init__(self, tenant_id: str):
496
+ self.tenant_id = tenant_id
497
+ self._previous_tenant = None
498
+
499
+ def __enter__(self):
500
+ # Store current tenant context
501
+ self._previous_tenant = getattr(_tenant_context, "tenant_id", None)
502
+ _tenant_context.tenant_id = self.tenant_id
503
+ return self
504
+
505
+ def __exit__(self, exc_type, exc_val, exc_tb):
506
+ # Restore previous tenant context
507
+ if self._previous_tenant:
508
+ _tenant_context.tenant_id = self._previous_tenant
509
+ else:
510
+ delattr(_tenant_context, "tenant_id")
511
+
512
+
513
+ # Thread-local storage for tenant context
514
+ _tenant_context = threading.local()
515
+
516
+
517
+ def get_current_tenant_id() -> Optional[str]:
518
+ """Get current tenant ID from context"""
519
+ return getattr(_tenant_context, "tenant_id", None)
520
+
521
+
522
+ def set_current_tenant_id(tenant_id: str):
523
+ """Set current tenant ID in context"""
524
+ _tenant_context.tenant_id = tenant_id
525
+
526
+
527
+ # Authentication service
528
+ class AuthService:
529
+ """High-level authentication service"""
530
+
531
+ def __init__(self, session: Session):
532
+ self.session = session
533
+ self.auth = JWTAuth()
534
+
535
+ def register_user(self, user_data: UserCreate) -> tuple[User, TokenResponse]:
536
+ """Register a new user"""
537
+ # Check if email already exists
538
+ existing = (
539
+ self.session.query(User).filter(User.email == user_data.email).first()
540
+ )
541
+
542
+ if existing:
543
+ raise HTTPException(
544
+ status_code=status.HTTP_400_BAD_REQUEST,
545
+ detail="Email already registered",
546
+ )
547
+
548
+ # Create or get tenant
549
+ if user_data.tenant_id:
550
+ tenant = (
551
+ self.session.query(Tenant)
552
+ .filter(Tenant.id == user_data.tenant_id)
553
+ .first()
554
+ )
555
+
556
+ if not tenant:
557
+ raise HTTPException(
558
+ status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found"
559
+ )
560
+
561
+ # Check tenant user limit
562
+ if tenant.max_users["current"] >= tenant.max_users["limit"]:
563
+ raise HTTPException(
564
+ status_code=status.HTTP_403_FORBIDDEN,
565
+ detail="Tenant user limit reached",
566
+ )
567
+ else:
568
+ # Create new tenant for user
569
+ import uuid
570
+
571
+ tenant = Tenant(
572
+ id=str(uuid.uuid4()),
573
+ name=f"{user_data.username}'s Workspace",
574
+ slug=f"tenant-{uuid.uuid4().hex[:8]}",
575
+ )
576
+ self.session.add(tenant)
577
+ self.session.flush()
578
+
579
+ # Create user
580
+ import uuid
581
+
582
+ user = User(
583
+ id=str(uuid.uuid4()),
584
+ email=user_data.email,
585
+ username=user_data.username,
586
+ hashed_password=get_password_hash(user_data.password),
587
+ tenant_id=tenant.id,
588
+ )
589
+
590
+ self.session.add(user)
591
+
592
+ # Update tenant user count
593
+ tenant.max_users["current"] += 1
594
+
595
+ self.session.commit()
596
+
597
+ # Generate tokens
598
+ tokens = self.auth.create_tokens(user)
599
+
600
+ return user, tokens
601
+
602
+ def login_user(self, credentials: UserLogin) -> tuple[User, TokenResponse]:
603
+ """Authenticate user and generate tokens"""
604
+ user = self.session.query(User).filter(User.email == credentials.email).first()
605
+
606
+ if not user or not verify_password(credentials.password, user.hashed_password):
607
+ raise HTTPException(
608
+ status_code=status.HTTP_401_UNAUTHORIZED,
609
+ detail="Invalid email or password",
610
+ )
611
+
612
+ if not user.is_active:
613
+ raise HTTPException(
614
+ status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
615
+ )
616
+
617
+ # Update last login
618
+ user.last_login = datetime.now(timezone.utc)
619
+ self.session.commit()
620
+
621
+ # Generate tokens
622
+ tokens = self.auth.create_tokens(user)
623
+
624
+ return user, tokens
625
+
626
+ def refresh_token(self, refresh_token: str) -> TokenResponse:
627
+ """Refresh access token using refresh token"""
628
+ token_data = self.auth.verify_token(refresh_token, token_type="refresh")
629
+
630
+ # Get user to ensure they still exist and are active
631
+ user = (
632
+ self.session.query(User)
633
+ .filter(
634
+ User.email == token_data.sub, User.tenant_id == token_data.tenant_id
635
+ )
636
+ .first()
637
+ )
638
+
639
+ if not user or not user.is_active:
640
+ raise HTTPException(
641
+ status_code=status.HTTP_401_UNAUTHORIZED,
642
+ detail="User not found or inactive",
643
+ )
644
+
645
+ # Generate new tokens
646
+ return self.auth.create_tokens(user)
647
+
648
+ def create_api_key(
649
+ self, name: str, user: User, scopes: List[str] = None
650
+ ) -> tuple[str, APIKey]:
651
+ """Create an API key for a user"""
652
+ key, key_hash = create_api_key()
653
+
654
+ import uuid
655
+
656
+ api_key = APIKey(
657
+ id=str(uuid.uuid4()),
658
+ key_hash=key_hash,
659
+ name=name,
660
+ user_id=user.id,
661
+ tenant_id=user.tenant_id,
662
+ scopes=scopes or ["read:workflows", "execute:workflows"],
663
+ )
664
+
665
+ self.session.add(api_key)
666
+ self.session.commit()
667
+
668
+ return key, api_key