kailash 0.1.5__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.
- kailash/__init__.py +1 -1
- kailash/access_control.py +740 -0
- kailash/api/__main__.py +6 -0
- kailash/api/auth.py +668 -0
- kailash/api/custom_nodes.py +285 -0
- kailash/api/custom_nodes_secure.py +377 -0
- kailash/api/database.py +620 -0
- kailash/api/studio.py +915 -0
- kailash/api/studio_secure.py +893 -0
- kailash/mcp/__init__.py +53 -0
- kailash/mcp/__main__.py +13 -0
- kailash/mcp/ai_registry_server.py +712 -0
- kailash/mcp/client.py +447 -0
- kailash/mcp/client_new.py +334 -0
- kailash/mcp/server.py +293 -0
- kailash/mcp/server_new.py +336 -0
- kailash/mcp/servers/__init__.py +12 -0
- kailash/mcp/servers/ai_registry.py +289 -0
- kailash/nodes/__init__.py +4 -2
- kailash/nodes/ai/__init__.py +2 -0
- kailash/nodes/ai/a2a.py +714 -67
- kailash/nodes/ai/intelligent_agent_orchestrator.py +31 -37
- kailash/nodes/ai/iterative_llm_agent.py +1280 -0
- kailash/nodes/ai/llm_agent.py +324 -1
- kailash/nodes/ai/self_organizing.py +5 -6
- kailash/nodes/base.py +15 -2
- kailash/nodes/base_async.py +45 -0
- kailash/nodes/base_cycle_aware.py +374 -0
- kailash/nodes/base_with_acl.py +338 -0
- kailash/nodes/code/python.py +135 -27
- kailash/nodes/data/readers.py +16 -6
- kailash/nodes/data/writers.py +16 -6
- kailash/nodes/logic/__init__.py +8 -0
- kailash/nodes/logic/convergence.py +642 -0
- kailash/nodes/logic/loop.py +153 -0
- kailash/nodes/logic/operations.py +187 -27
- kailash/nodes/mixins/__init__.py +11 -0
- kailash/nodes/mixins/mcp.py +228 -0
- kailash/nodes/mixins.py +387 -0
- kailash/runtime/__init__.py +2 -1
- kailash/runtime/access_controlled.py +458 -0
- kailash/runtime/local.py +106 -33
- kailash/runtime/parallel_cyclic.py +529 -0
- kailash/sdk_exceptions.py +90 -5
- kailash/security.py +845 -0
- kailash/tracking/manager.py +38 -15
- kailash/tracking/models.py +1 -1
- kailash/tracking/storage/filesystem.py +30 -2
- kailash/utils/__init__.py +8 -0
- kailash/workflow/__init__.py +18 -0
- kailash/workflow/convergence.py +270 -0
- kailash/workflow/cycle_analyzer.py +768 -0
- kailash/workflow/cycle_builder.py +573 -0
- kailash/workflow/cycle_config.py +709 -0
- kailash/workflow/cycle_debugger.py +760 -0
- kailash/workflow/cycle_exceptions.py +601 -0
- kailash/workflow/cycle_profiler.py +671 -0
- kailash/workflow/cycle_state.py +338 -0
- kailash/workflow/cyclic_runner.py +985 -0
- kailash/workflow/graph.py +500 -39
- kailash/workflow/migration.py +768 -0
- kailash/workflow/safety.py +365 -0
- kailash/workflow/templates.py +744 -0
- kailash/workflow/validation.py +693 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/METADATA +256 -12
- kailash-0.2.0.dist-info/RECORD +125 -0
- kailash/nodes/mcp/__init__.py +0 -11
- kailash/nodes/mcp/client.py +0 -554
- kailash/nodes/mcp/resource.py +0 -682
- kailash/nodes/mcp/server.py +0 -577
- kailash-0.1.5.dist-info/RECORD +0 -88
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
kailash/api/__main__.py
ADDED
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
|