jac-scale 0.1.1__py3-none-any.whl → 0.1.4__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.
- jac_scale/abstractions/config/app_config.jac +5 -2
- jac_scale/config_loader.jac +2 -1
- jac_scale/context.jac +2 -1
- jac_scale/factories/storage_factory.jac +75 -0
- jac_scale/google_sso_provider.jac +85 -0
- jac_scale/impl/config_loader.impl.jac +28 -3
- jac_scale/impl/context.impl.jac +1 -0
- jac_scale/impl/serve.impl.jac +749 -266
- jac_scale/impl/user_manager.impl.jac +349 -0
- jac_scale/impl/webhook.impl.jac +212 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
- jac_scale/memory_hierarchy.jac +3 -1
- jac_scale/plugin.jac +46 -3
- jac_scale/plugin_config.jac +28 -1
- jac_scale/serve.jac +33 -16
- jac_scale/sso_provider.jac +72 -0
- jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
- jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
- jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
- jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
- jac_scale/tests/fixtures/test_api.jac +89 -0
- jac_scale/tests/fixtures/test_restspec.jac +88 -0
- jac_scale/tests/test_deploy_k8s.py +2 -1
- jac_scale/tests/test_examples.py +180 -5
- jac_scale/tests/test_hooks.py +39 -0
- jac_scale/tests/test_restspec.py +289 -0
- jac_scale/tests/test_serve.py +411 -4
- jac_scale/tests/test_sso.py +273 -284
- jac_scale/tests/test_storage.py +274 -0
- jac_scale/user_manager.jac +49 -0
- jac_scale/webhook.jac +93 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/METADATA +11 -4
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/RECORD +36 -23
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +1 -1
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
impl JacScaleUserManager.postinit -> None {
|
|
2
|
+
super.postinit();
|
|
3
|
+
# Create SSO accounts table for tracking linked external identities
|
|
4
|
+
self._ensure_connection();
|
|
5
|
+
self._conn.execute(
|
|
6
|
+
"""
|
|
7
|
+
CREATE TABLE IF NOT EXISTS sso_accounts (
|
|
8
|
+
user_id TEXT NOT NULL,
|
|
9
|
+
platform TEXT NOT NULL,
|
|
10
|
+
external_id TEXT NOT NULL,
|
|
11
|
+
email TEXT,
|
|
12
|
+
linked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
PRIMARY KEY (platform, external_id),
|
|
14
|
+
FOREIGN KEY (user_id) REFERENCES users(username) ON DELETE CASCADE
|
|
15
|
+
)
|
|
16
|
+
"""
|
|
17
|
+
);
|
|
18
|
+
# Create index for faster lookups by user_id
|
|
19
|
+
self._conn.execute(
|
|
20
|
+
"CREATE INDEX IF NOT EXISTS idx_sso_accounts_user_id ON sso_accounts(user_id)"
|
|
21
|
+
);
|
|
22
|
+
self._conn.commit();
|
|
23
|
+
# Load SSO config
|
|
24
|
+
sso_config = get_scale_config().get_sso_config();
|
|
25
|
+
for platform in Platforms {
|
|
26
|
+
key = platform.lower();
|
|
27
|
+
platform_config = sso_config.get(key, {});
|
|
28
|
+
|
|
29
|
+
client_id = platform_config.get('client_id', '');
|
|
30
|
+
client_secret = platform_config.get('client_secret', '');
|
|
31
|
+
|
|
32
|
+
if not client_id or not client_secret {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
self.SUPPORTED_PLATFORMS[platform.value] = {
|
|
37
|
+
"client_id": client_id,
|
|
38
|
+
"client_secret": client_secret
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl JacScaleUserManager.create_jwt_token(username: str) -> str {
|
|
44
|
+
now = datetime.now(UTC);
|
|
45
|
+
payload: dict[(str, Any)] = {
|
|
46
|
+
'username': username,
|
|
47
|
+
'exp': (now + timedelta(days=JWT_EXP_DELTA_DAYS)),
|
|
48
|
+
'iat': now.timestamp()
|
|
49
|
+
};
|
|
50
|
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl JacScaleUserManager.validate_jwt_token(token: str) -> (str | None) {
|
|
54
|
+
try {
|
|
55
|
+
decoded = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
|
|
56
|
+
return decoded['username'];
|
|
57
|
+
} except Exception {
|
|
58
|
+
return None;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl JacScaleUserManager.refresh_jwt_token(token: str) -> (str | None) {
|
|
63
|
+
try {
|
|
64
|
+
decoded = jwt.decode(
|
|
65
|
+
token, JWT_SECRET, algorithms=[JWT_ALGORITHM], options={"verify_exp": True}
|
|
66
|
+
);
|
|
67
|
+
username = decoded.get('username');
|
|
68
|
+
|
|
69
|
+
if not username {
|
|
70
|
+
return None;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return self.create_jwt_token(username);
|
|
74
|
+
} except Exception {
|
|
75
|
+
return None;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
impl JacScaleUserManager.validate_token(token: str) -> (str | None) {
|
|
80
|
+
return self.validate_jwt_token(token);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
impl JacScaleUserManager.get_sso(platform: str, operation: str) -> (SSOProvider | None) {
|
|
84
|
+
if (platform not in self.SUPPORTED_PLATFORMS) {
|
|
85
|
+
return None;
|
|
86
|
+
}
|
|
87
|
+
credentials = self.SUPPORTED_PLATFORMS[platform];
|
|
88
|
+
redirect_uri = f"{SSO_HOST}/{platform}/{operation}/callback";
|
|
89
|
+
if (platform == Platforms.GOOGLE.value) {
|
|
90
|
+
import from jac_scale.google_sso_provider { GoogleSSOProvider }
|
|
91
|
+
return GoogleSSOProvider(
|
|
92
|
+
client_id=credentials['client_id'],
|
|
93
|
+
client_secret=credentials['client_secret'],
|
|
94
|
+
redirect_uri=redirect_uri,
|
|
95
|
+
allow_insecure_http=True
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return None;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
impl JacScaleUserManager.sso_initiate(
|
|
102
|
+
platform: str, operation: str
|
|
103
|
+
) -> (Response | TransportResponse) {
|
|
104
|
+
import from jaclang.runtimelib.server { JsonValue }
|
|
105
|
+
if (platform not in [p.value for p in Platforms]) {
|
|
106
|
+
return TransportResponse.fail(
|
|
107
|
+
code='INVALID_PLATFORM',
|
|
108
|
+
message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
|
|
109
|
+
[p.value for p in Platforms]
|
|
110
|
+
)}",
|
|
111
|
+
meta=Meta(extra={'http_status': 400})
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (platform not in self.SUPPORTED_PLATFORMS) {
|
|
115
|
+
return TransportResponse.fail(
|
|
116
|
+
code='SSO_NOT_CONFIGURED',
|
|
117
|
+
message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
|
|
118
|
+
meta=Meta(extra={'http_status': 501})
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (operation not in [o.value for o in Operations]) {
|
|
122
|
+
return TransportResponse.fail(
|
|
123
|
+
code='INVALID_OPERATION',
|
|
124
|
+
message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
|
|
125
|
+
meta=Meta(extra={'http_status': 400})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
sso = self.get_sso(platform, operation);
|
|
129
|
+
if not sso {
|
|
130
|
+
return TransportResponse.fail(
|
|
131
|
+
code='SSO_INIT_FAILED',
|
|
132
|
+
message=f"Failed to initialize SSO for platform '{platform}'",
|
|
133
|
+
meta=Meta(extra={'http_status': 500})
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return await sso.initiate_auth(operation);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
impl JacScaleUserManager.sso_callback(
|
|
140
|
+
request: Request, platform: str, operation: str
|
|
141
|
+
) -> TransportResponse {
|
|
142
|
+
import from jaclang.runtimelib.server { JsonValue }
|
|
143
|
+
if (platform not in [p.value for p in Platforms]) {
|
|
144
|
+
return TransportResponse.fail(
|
|
145
|
+
code='INVALID_PLATFORM',
|
|
146
|
+
message=f"Invalid platform '{platform}'. Supported platforms: {', '.join(
|
|
147
|
+
[p.value for p in Platforms]
|
|
148
|
+
)}",
|
|
149
|
+
meta=Meta(extra={'http_status': 400})
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (platform not in self.SUPPORTED_PLATFORMS) {
|
|
153
|
+
return TransportResponse.fail(
|
|
154
|
+
code='SSO_NOT_CONFIGURED',
|
|
155
|
+
message=f"SSO for platform '{platform}' is not configured. Please set SSO_{platform.upper()}_CLIENT_ID and SSO_{platform.upper()}_CLIENT_SECRET environment variables.",
|
|
156
|
+
meta=Meta(extra={'http_status': 501})
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (operation not in [o.value for o in Operations]) {
|
|
160
|
+
return TransportResponse.fail(
|
|
161
|
+
code='INVALID_OPERATION',
|
|
162
|
+
message=f"Invalid operation '{operation}'. Must be 'login' or 'register'",
|
|
163
|
+
meta=Meta(extra={'http_status': 400})
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
sso = self.get_sso(platform, operation);
|
|
167
|
+
if not sso {
|
|
168
|
+
return TransportResponse.fail(
|
|
169
|
+
code='SSO_INIT_FAILED',
|
|
170
|
+
message=f"Failed to initialize SSO for platform '{platform}'",
|
|
171
|
+
meta=Meta(extra={'http_status': 500})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
user_info = await sso.handle_callback(request);
|
|
176
|
+
email = user_info.email;
|
|
177
|
+
if not email {
|
|
178
|
+
return TransportResponse.fail(
|
|
179
|
+
code='EMAIL_MISSING',
|
|
180
|
+
message=f"Email not provided by {platform}",
|
|
181
|
+
meta=Meta(extra={'http_status': 400})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (operation == Operations.LOGIN.value) {
|
|
185
|
+
user = self.get_user(email);
|
|
186
|
+
if not user {
|
|
187
|
+
return TransportResponse.fail(
|
|
188
|
+
code='USER_NOT_FOUND',
|
|
189
|
+
message='User not found. Please register first.',
|
|
190
|
+
meta=Meta(extra={'http_status': 404})
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
token = self.create_jwt_token(email);
|
|
194
|
+
return TransportResponse.success(
|
|
195
|
+
data={
|
|
196
|
+
'message': 'Login successful',
|
|
197
|
+
'email': email,
|
|
198
|
+
'token': token,
|
|
199
|
+
'platform': platform,
|
|
200
|
+
'user': dict[(str, JsonValue)](user)
|
|
201
|
+
},
|
|
202
|
+
meta=Meta(extra={'http_status': 200})
|
|
203
|
+
);
|
|
204
|
+
} elif (operation == Operations.REGISTER.value) {
|
|
205
|
+
existing_user = self.get_user(email);
|
|
206
|
+
if existing_user {
|
|
207
|
+
return TransportResponse.fail(
|
|
208
|
+
code='USER_EXISTS',
|
|
209
|
+
message='User already exists. Please login instead.',
|
|
210
|
+
meta=Meta(extra={'http_status': 400})
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
random_password = generate_random_password();
|
|
214
|
+
result = self.create_user(email, random_password);
|
|
215
|
+
if ('error' in result) {
|
|
216
|
+
return TransportResponse.fail(
|
|
217
|
+
code='USER_CREATION_FAILED',
|
|
218
|
+
message=result.get('error', 'User creation failed'),
|
|
219
|
+
meta=Meta(extra={'http_status': 400})
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
token = self.create_jwt_token(email);
|
|
223
|
+
result['token'] = token;
|
|
224
|
+
result['platform'] = platform;
|
|
225
|
+
return TransportResponse.success(
|
|
226
|
+
data=result, meta=Meta(extra={'http_status': 201})
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
} except Exception as e {
|
|
230
|
+
return TransportResponse.fail(
|
|
231
|
+
code='AUTHENTICATION_FAILED',
|
|
232
|
+
message=f"Authentication failed: {str(e)}",
|
|
233
|
+
meta=Meta(extra={'http_status': 500})
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return TransportResponse.fail(
|
|
237
|
+
code='UNKNOWN_ERROR',
|
|
238
|
+
message='An unknown error occurred',
|
|
239
|
+
meta=Meta(extra={'http_status': 500})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# SSO Account Linking Methods
|
|
244
|
+
"""Link an SSO account to a user.
|
|
245
|
+
|
|
246
|
+
This allows users to have multiple SSO providers linked to their account.
|
|
247
|
+
"""
|
|
248
|
+
impl JacScaleUserManager.link_sso_account(
|
|
249
|
+
user_id: str, platform: str, external_id: str, email: str
|
|
250
|
+
) -> dict[str, str] {
|
|
251
|
+
self._ensure_connection();
|
|
252
|
+
# Check if this SSO account is already linked to another user
|
|
253
|
+
cursor = self._conn.execute(
|
|
254
|
+
"SELECT user_id FROM sso_accounts WHERE platform = ? AND external_id = ?",
|
|
255
|
+
(platform, external_id)
|
|
256
|
+
);
|
|
257
|
+
existing = cursor.fetchone();
|
|
258
|
+
if existing {
|
|
259
|
+
if existing[0] == user_id {
|
|
260
|
+
return {'message': 'SSO account already linked to this user'};
|
|
261
|
+
}
|
|
262
|
+
return {'error': 'This SSO account is already linked to another user'};
|
|
263
|
+
}
|
|
264
|
+
# Link the SSO account
|
|
265
|
+
self._conn.execute(
|
|
266
|
+
"""
|
|
267
|
+
INSERT INTO sso_accounts (user_id, platform, external_id, email)
|
|
268
|
+
VALUES (?, ?, ?, ?)
|
|
269
|
+
""",
|
|
270
|
+
(user_id, platform, external_id, email)
|
|
271
|
+
);
|
|
272
|
+
self._conn.commit();
|
|
273
|
+
return {
|
|
274
|
+
'message': 'SSO account linked successfully',
|
|
275
|
+
'user_id': user_id,
|
|
276
|
+
'platform': platform
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
"""Unlink an SSO account from a user."""
|
|
281
|
+
impl JacScaleUserManager.unlink_sso_account(
|
|
282
|
+
user_id: str, platform: str
|
|
283
|
+
) -> dict[str, str] {
|
|
284
|
+
self._ensure_connection();
|
|
285
|
+
cursor = self._conn.execute(
|
|
286
|
+
"DELETE FROM sso_accounts WHERE user_id = ? AND platform = ?",
|
|
287
|
+
(user_id, platform)
|
|
288
|
+
);
|
|
289
|
+
self._conn.commit();
|
|
290
|
+
if cursor.rowcount == 0 {
|
|
291
|
+
return {'error': 'SSO account not found'};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
'message': 'SSO account unlinked successfully',
|
|
295
|
+
'user_id': user_id,
|
|
296
|
+
'platform': platform
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
"""Get all SSO accounts linked to a user."""
|
|
301
|
+
impl JacScaleUserManager.get_sso_accounts(user_id: str) -> list[dict[str, str]] {
|
|
302
|
+
self._ensure_connection();
|
|
303
|
+
cursor = self._conn.execute(
|
|
304
|
+
"""
|
|
305
|
+
SELECT platform, external_id, email, linked_at
|
|
306
|
+
FROM sso_accounts
|
|
307
|
+
WHERE user_id = ?
|
|
308
|
+
ORDER BY linked_at DESC
|
|
309
|
+
""",
|
|
310
|
+
(user_id, )
|
|
311
|
+
);
|
|
312
|
+
accounts = [];
|
|
313
|
+
for row in cursor.fetchall() {
|
|
314
|
+
accounts.append(
|
|
315
|
+
{
|
|
316
|
+
'platform': row[0],
|
|
317
|
+
'external_id': row[1],
|
|
318
|
+
'email': row[2],
|
|
319
|
+
'linked_at': row[3]
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return accounts;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
"""Find a user by their SSO account credentials.
|
|
327
|
+
|
|
328
|
+
This is used during SSO login to find the user associated with
|
|
329
|
+
an external SSO identity.
|
|
330
|
+
"""
|
|
331
|
+
impl JacScaleUserManager.get_user_by_sso(
|
|
332
|
+
platform: str, external_id: str
|
|
333
|
+
) -> (dict[str, str] | None) {
|
|
334
|
+
self._ensure_connection();
|
|
335
|
+
cursor = self._conn.execute(
|
|
336
|
+
"""
|
|
337
|
+
SELECT sa.user_id, u.token, u.root_id
|
|
338
|
+
FROM sso_accounts sa
|
|
339
|
+
JOIN users u ON sa.user_id = u.username
|
|
340
|
+
WHERE sa.platform = ? AND sa.external_id = ?
|
|
341
|
+
""",
|
|
342
|
+
(platform, external_id)
|
|
343
|
+
);
|
|
344
|
+
row = cursor.fetchone();
|
|
345
|
+
if not row {
|
|
346
|
+
return None;
|
|
347
|
+
}
|
|
348
|
+
return {'email': row[0], 'token': row[1], 'root_id': row[2]};
|
|
349
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Implementation of Webhook utilities for Jac Scale.
|
|
2
|
+
|
|
3
|
+
This module implements HMAC-SHA256 signature generation/verification
|
|
4
|
+
and API key management for webhook authentication.
|
|
5
|
+
"""
|
|
6
|
+
import hmac;
|
|
7
|
+
import hashlib;
|
|
8
|
+
import secrets;
|
|
9
|
+
import jwt;
|
|
10
|
+
import from datetime { UTC, datetime, timedelta }
|
|
11
|
+
import from typing { Any }
|
|
12
|
+
import from jaclang.runtimelib.transport { TransportResponse, Meta }
|
|
13
|
+
import from jac_scale.config_loader { get_scale_config }
|
|
14
|
+
|
|
15
|
+
# Load JWT config for signing API keys
|
|
16
|
+
glob _jwt_config = get_scale_config().get_jwt_config(),
|
|
17
|
+
JWT_SECRET = _jwt_config['secret'],
|
|
18
|
+
JWT_ALGORITHM = _jwt_config['algorithm'];
|
|
19
|
+
|
|
20
|
+
"""Generate HMAC-SHA256 signature for webhook payload."""
|
|
21
|
+
impl WebhookUtils.generate_signature(payload: bytes, secret: str) -> str {
|
|
22
|
+
return hmac.new(secret.encode('utf-8'), payload, hashlib.sha256).hexdigest();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
"""Verify HMAC-SHA256 signature for webhook payload."""
|
|
26
|
+
impl WebhookUtils.verify_signature(payload: bytes, signature: str, secret: str) -> bool {
|
|
27
|
+
expected_signature = WebhookUtils.generate_signature(payload, secret);
|
|
28
|
+
# Use constant-time comparison to prevent timing attacks
|
|
29
|
+
return hmac.compare_digest(signature.lower(), expected_signature.lower());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
"""Generate a cryptographically secure API key."""
|
|
33
|
+
impl WebhookUtils.generate_api_key -> str {
|
|
34
|
+
return secrets.token_hex(32);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
"""Create a JWT-wrapped API key token."""
|
|
38
|
+
impl WebhookUtils.create_api_key_token(
|
|
39
|
+
api_key_id: str, username: str, name: str, expiry_days: int | None = None
|
|
40
|
+
) -> str {
|
|
41
|
+
_webhook_config = get_scale_config().get_webhook_config();
|
|
42
|
+
default_expiry = _webhook_config.get('api_key_expiry_days', 365);
|
|
43
|
+
payload: dict[str, Any] = {
|
|
44
|
+
'type': 'api_key',
|
|
45
|
+
'api_key_id': api_key_id,
|
|
46
|
+
'sub': username,
|
|
47
|
+
'name': name,
|
|
48
|
+
'iat': datetime.now(UTC)
|
|
49
|
+
};
|
|
50
|
+
# Add expiry if specified
|
|
51
|
+
if expiry_days is not None {
|
|
52
|
+
payload['exp'] = datetime.now(UTC) + timedelta(days=expiry_days);
|
|
53
|
+
} elif default_expiry > 0 {
|
|
54
|
+
payload['exp'] = datetime.now(UTC) + timedelta(days=default_expiry);
|
|
55
|
+
}
|
|
56
|
+
# If expiry_days is 0 or negative, no expiry is set (permanent key)
|
|
57
|
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
"""Validate an API key token and extract user information."""
|
|
61
|
+
impl WebhookUtils.validate_api_key(api_key: str) -> dict[str, str] | None {
|
|
62
|
+
try {
|
|
63
|
+
payload = jwt.decode(api_key, JWT_SECRET, algorithms=[JWT_ALGORITHM]);
|
|
64
|
+
|
|
65
|
+
# Verify this is an API key token
|
|
66
|
+
if payload.get('type') != 'api_key' {
|
|
67
|
+
return None;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
'username': payload.get('sub', ''),
|
|
72
|
+
'api_key_id': payload.get('api_key_id', ''),
|
|
73
|
+
'name': payload.get('name', '')
|
|
74
|
+
};
|
|
75
|
+
} except jwt.ExpiredSignatureError {
|
|
76
|
+
return None;
|
|
77
|
+
} except jwt.InvalidTokenError {
|
|
78
|
+
return None;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
"""Extract signature from request header value."""
|
|
83
|
+
impl WebhookUtils.extract_signature(header_value: str) -> str {
|
|
84
|
+
# Handle prefixed signatures like "sha256=abc123..."
|
|
85
|
+
if '=' in header_value {
|
|
86
|
+
parts = header_value.split('=', 1);
|
|
87
|
+
if len(parts) == 2 {
|
|
88
|
+
return parts[1];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return header_value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
"""Create a new API key for a user."""
|
|
95
|
+
impl ApiKeyManager.create_api_key(
|
|
96
|
+
username: str, name: str, expiry_days: int | None = None
|
|
97
|
+
) -> TransportResponse {
|
|
98
|
+
# Generate unique API key ID
|
|
99
|
+
api_key_id = secrets.token_hex(16);
|
|
100
|
+
# Create the JWT-wrapped API key
|
|
101
|
+
api_key = WebhookUtils.create_api_key_token(
|
|
102
|
+
api_key_id=api_key_id, username=username, name=name, expiry_days=expiry_days
|
|
103
|
+
);
|
|
104
|
+
created_at = datetime.now(UTC);
|
|
105
|
+
expires_at: datetime | None = None;
|
|
106
|
+
_webhook_config = get_scale_config().get_webhook_config();
|
|
107
|
+
default_expiry = _webhook_config.get('api_key_expiry_days', 365);
|
|
108
|
+
if expiry_days is not None and expiry_days > 0 {
|
|
109
|
+
expires_at = created_at + timedelta(days=expiry_days);
|
|
110
|
+
} elif default_expiry > 0 {
|
|
111
|
+
expires_at = created_at + timedelta(days=default_expiry);
|
|
112
|
+
}
|
|
113
|
+
# Store key metadata (not the key itself for security)
|
|
114
|
+
self._api_keys[api_key_id] = {
|
|
115
|
+
'username': username,
|
|
116
|
+
'name': name,
|
|
117
|
+
'created_at': created_at.isoformat(),
|
|
118
|
+
'expires_at': expires_at.isoformat() if expires_at else None,
|
|
119
|
+
'revoked': False
|
|
120
|
+
};
|
|
121
|
+
# Track keys by user
|
|
122
|
+
if username not in self._user_keys {
|
|
123
|
+
self._user_keys[username] = [];
|
|
124
|
+
}
|
|
125
|
+
self._user_keys[username].append(api_key_id);
|
|
126
|
+
return TransportResponse.success(
|
|
127
|
+
data={
|
|
128
|
+
'api_key': api_key,
|
|
129
|
+
'api_key_id': api_key_id,
|
|
130
|
+
'name': name,
|
|
131
|
+
'created_at': created_at.isoformat(),
|
|
132
|
+
'expires_at': expires_at.isoformat() if expires_at else None
|
|
133
|
+
},
|
|
134
|
+
meta=Meta(extra={'http_status': 201})
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
"""List all API keys for a user (metadata only, not the keys)."""
|
|
139
|
+
impl ApiKeyManager.list_api_keys(username: str) -> TransportResponse {
|
|
140
|
+
user_key_ids = self._user_keys.get(username, []);
|
|
141
|
+
keys: list[dict[str, Any]] = [];
|
|
142
|
+
for key_id in user_key_ids {
|
|
143
|
+
key_info = self._api_keys.get(key_id);
|
|
144
|
+
if key_info and not key_info.get('revoked', False) {
|
|
145
|
+
keys.append(
|
|
146
|
+
{
|
|
147
|
+
'api_key_id': key_id,
|
|
148
|
+
'name': key_info.get('name', ''),
|
|
149
|
+
'created_at': key_info.get('created_at'),
|
|
150
|
+
'expires_at': key_info.get('expires_at'),
|
|
151
|
+
'active': True
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return TransportResponse.success(
|
|
157
|
+
data={'api_keys': keys}, meta=Meta(extra={'http_status': 200})
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
"""Revoke an API key."""
|
|
162
|
+
impl ApiKeyManager.revoke_api_key(username: str, api_key_id: str) -> TransportResponse {
|
|
163
|
+
# Check if key exists
|
|
164
|
+
if api_key_id not in self._api_keys {
|
|
165
|
+
return TransportResponse.fail(
|
|
166
|
+
code='NOT_FOUND',
|
|
167
|
+
message=f"API key '{api_key_id}' not found",
|
|
168
|
+
meta=Meta(extra={'http_status': 404})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
# Check if key belongs to user
|
|
172
|
+
key_info = self._api_keys[api_key_id];
|
|
173
|
+
if key_info.get('username') != username {
|
|
174
|
+
return TransportResponse.fail(
|
|
175
|
+
code='FORBIDDEN',
|
|
176
|
+
message='Cannot revoke API key owned by another user',
|
|
177
|
+
meta=Meta(extra={'http_status': 403})
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
# Mark as revoked
|
|
181
|
+
self._api_keys[api_key_id]['revoked'] = True;
|
|
182
|
+
return TransportResponse.success(
|
|
183
|
+
data={'message': f"API key '{api_key_id}' has been revoked"},
|
|
184
|
+
meta=Meta(extra={'http_status': 200})
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
"""Validate an API key and return the associated username."""
|
|
189
|
+
impl ApiKeyManager.validate_api_key(api_key: str) -> str | None {
|
|
190
|
+
# Decode the JWT to get key info
|
|
191
|
+
key_info = WebhookUtils.validate_api_key(api_key);
|
|
192
|
+
if not key_info {
|
|
193
|
+
return None;
|
|
194
|
+
}
|
|
195
|
+
api_key_id = key_info.get('api_key_id', '');
|
|
196
|
+
# Check if key is still active (not revoked)
|
|
197
|
+
if not self.is_key_active(api_key_id) {
|
|
198
|
+
return None;
|
|
199
|
+
}
|
|
200
|
+
return key_info.get('username');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
"""Check if an API key ID exists and is not revoked."""
|
|
204
|
+
impl ApiKeyManager.is_key_active(api_key_id: str) -> bool {
|
|
205
|
+
key_info = self._api_keys.get(api_key_id);
|
|
206
|
+
if not key_info {
|
|
207
|
+
# Key not in our store - could be from before server restart
|
|
208
|
+
# Allow it if JWT is valid (handled by validate_api_key)
|
|
209
|
+
return True;
|
|
210
|
+
}
|
|
211
|
+
return not key_info.get('revoked', False);
|
|
212
|
+
}
|
|
@@ -379,6 +379,10 @@ impl JFastApiServer._create_endpoint_function(
|
|
|
379
379
|
if accepts_kwargs {
|
|
380
380
|
param_strs.append('request: Request');
|
|
381
381
|
param_mapping['__request__'] = 'request';
|
|
382
|
+
# If the callback also explicitly accepts a request parameter, map it
|
|
383
|
+
if needs_request {
|
|
384
|
+
param_mapping['request'] = 'request';
|
|
385
|
+
}
|
|
382
386
|
} elif needs_request {
|
|
383
387
|
param_strs.append('request: Request');
|
|
384
388
|
param_mapping['request'] = 'request';
|
jac_scale/memory_hierarchy.jac
CHANGED
|
@@ -74,7 +74,9 @@ MongoDB persistence backend - implements PersistentMemory for durable L3 storage
|
|
|
74
74
|
Replaces SqliteMemory when MongoDB is available.
|
|
75
75
|
"""
|
|
76
76
|
obj MongoBackend(PersistentMemory) {
|
|
77
|
-
has client:
|
|
77
|
+
has client: MongoClient | None = None,
|
|
78
|
+
db: Any by postinit,
|
|
79
|
+
collection: Any by postinit,
|
|
78
80
|
db_name: str = 'jac_db',
|
|
79
81
|
collection_name: str = 'anchors',
|
|
80
82
|
mongo_url: str = _db_config['mongodb_uri'];
|
jac_scale/plugin.jac
CHANGED
|
@@ -4,6 +4,7 @@ import pathlib;
|
|
|
4
4
|
import from dotenv { load_dotenv }
|
|
5
5
|
import from jaclang.cli.registry { get_registry }
|
|
6
6
|
import from jaclang.cli.command { Arg, ArgKind, CommandPriority, HookContext }
|
|
7
|
+
import from jaclang.cli.console { console }
|
|
7
8
|
import from jaclang.pycore.runtime { hookimpl, plugin_manager }
|
|
8
9
|
import from jaclang.runtimelib.context { ExecutionContext }
|
|
9
10
|
import from jaclang.runtimelib.server { JacAPIServer as JacServer }
|
|
@@ -15,6 +16,9 @@ import from .factories.deployment_factory { DeploymentTargetFactory }
|
|
|
15
16
|
import from .factories.registry_factory { ImageRegistryFactory }
|
|
16
17
|
import from .factories.utility_factory { UtilityFactory }
|
|
17
18
|
import from .abstractions.config.app_config { AppConfig }
|
|
19
|
+
import from .user_manager { JacScaleUserManager }
|
|
20
|
+
import from jaclang.runtimelib.server { UserManager }
|
|
21
|
+
import from jaclang.runtimelib.storage { Storage }
|
|
18
22
|
|
|
19
23
|
"""Pre-hook for jac start command to handle --scale flag."""
|
|
20
24
|
def _scale_pre_hook(context: HookContext) -> None {
|
|
@@ -23,6 +27,7 @@ def _scale_pre_hook(context: HookContext) -> None {
|
|
|
23
27
|
# Handle deployment instead of local server
|
|
24
28
|
filename = context.get_arg("filename");
|
|
25
29
|
build = context.get_arg("build", False);
|
|
30
|
+
experimental = context.get_arg("experimental", False);
|
|
26
31
|
target = context.get_arg("target", "kubernetes");
|
|
27
32
|
registry = context.get_arg("registry", "dockerhub");
|
|
28
33
|
if not os.path.exists(filename) {
|
|
@@ -57,15 +62,27 @@ def _scale_pre_hook(context: HookContext) -> None {
|
|
|
57
62
|
}
|
|
58
63
|
# Create app config
|
|
59
64
|
app_config = AppConfig(
|
|
60
|
-
code_folder=code_folder,
|
|
65
|
+
code_folder=code_folder,
|
|
66
|
+
file_name=base_file_path,
|
|
67
|
+
build=build,
|
|
68
|
+
experimental=experimental
|
|
61
69
|
);
|
|
70
|
+
if experimental {
|
|
71
|
+
console.print(
|
|
72
|
+
"Installing Jaseci packages from repository (experimental mode)..."
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
console.print("Installing Jaseci packages from PyPI...");
|
|
76
|
+
}
|
|
62
77
|
# Deploy
|
|
63
78
|
result = deployment_target.deploy(app_config);
|
|
64
79
|
if not result.success {
|
|
65
80
|
raise RuntimeError(result.message or "Deployment failed") ;
|
|
66
81
|
}
|
|
67
82
|
if result.service_url {
|
|
68
|
-
print(
|
|
83
|
+
console.print(
|
|
84
|
+
f"Deployment complete! Service available at: {result.service_url}"
|
|
85
|
+
);
|
|
69
86
|
}
|
|
70
87
|
# Cancel normal start execution since we handled it
|
|
71
88
|
context.set_data("cancel_execution", True);
|
|
@@ -99,6 +116,13 @@ class JacCmd {
|
|
|
99
116
|
help="Build and push Docker image (with --scale)",
|
|
100
117
|
short="b"
|
|
101
118
|
),
|
|
119
|
+
Arg.create(
|
|
120
|
+
"experimental",
|
|
121
|
+
typ=bool,
|
|
122
|
+
default=False,
|
|
123
|
+
help="Use experimental mode (install from repo instead of PyPI)",
|
|
124
|
+
short="e"
|
|
125
|
+
),
|
|
102
126
|
Arg.create(
|
|
103
127
|
"target",
|
|
104
128
|
typ=str,
|
|
@@ -166,7 +190,9 @@ class JacCmd {
|
|
|
166
190
|
app_name = os.getenv('APP_NAME') or target_config.get('app_name', 'jaseci');
|
|
167
191
|
deployment_target.destroy(app_name);
|
|
168
192
|
|
|
169
|
-
print(
|
|
193
|
+
console.print(
|
|
194
|
+
f"Successfully destroyed deployment '{app_name}' from {target}"
|
|
195
|
+
);
|
|
170
196
|
return 0;
|
|
171
197
|
}
|
|
172
198
|
}
|
|
@@ -197,6 +223,23 @@ class JacScalePlugin {
|
|
|
197
223
|
static def get_api_server_class -> type {
|
|
198
224
|
return JacAPIServer;
|
|
199
225
|
}
|
|
226
|
+
|
|
227
|
+
"""Provide jac-scale's UserManager."""
|
|
228
|
+
@hookimpl
|
|
229
|
+
static def get_user_manager(base_path: str) -> UserManager {
|
|
230
|
+
return JacScaleUserManager(base_path=base_path);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
"""Provide jac-scale's storage backend.
|
|
234
|
+
|
|
235
|
+
This overrides the core store() to use jac-scale's StorageFactory,
|
|
236
|
+
which supports cloud backends (S3, GCS, Azure) via configuration.
|
|
237
|
+
"""
|
|
238
|
+
@hookimpl
|
|
239
|
+
static def store(base_path: str = "./storage", create_dirs: bool = True) -> Storage {
|
|
240
|
+
import from .factories.storage_factory { StorageFactory }
|
|
241
|
+
return StorageFactory.get_default(base_path, create_dirs);
|
|
242
|
+
}
|
|
200
243
|
}
|
|
201
244
|
|
|
202
245
|
# Pluggy's varnames() puts parameters with defaults into kwargnames, not argnames.
|