@feltdb/core 0.5.7 → 0.6.0

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 (48) hide show
  1. package/dist/cli/commands.js +68 -1
  2. package/dist/cli/workspace-integration.js +96 -0
  3. package/dist/create/cli.js +1 -1
  4. package/dist/create/create.js +21 -0
  5. package/dist/create/package-versions.js +1 -1
  6. package/dist/create/server-source/Cargo.lock +10 -0
  7. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
  8. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -1
  9. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
  10. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
  12. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +9 -0
  14. package/dist/create/server-source/crates/feltdb-server/src/main.rs +157 -9
  15. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +49 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
  21. package/dist/create/template/dot-feltdb-README.md +58 -0
  22. package/dist/create/workspace-initialization.js +77 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -0
  26. package/dist/studio-app/assets/{feltdb_wasm-h9mxesnH.js → feltdb_wasm-B4wq4mqp.js} +1 -1
  27. package/dist/studio-app/assets/feltdb_wasm_bg-Ceyi7l21.wasm +0 -0
  28. package/dist/studio-app/assets/{index-B_EMnTaE.js → index-LQmvJSq6.js} +2 -2
  29. package/dist/studio-app/index.html +1 -1
  30. package/dist/telemetry.js +1 -1
  31. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  32. package/dist/workspace/development-node.d.ts +42 -0
  33. package/dist/workspace/development-node.d.ts.map +1 -0
  34. package/dist/workspace/development-node.js +208 -0
  35. package/dist/workspace/index.d.ts +20 -0
  36. package/dist/workspace/index.d.ts.map +1 -0
  37. package/dist/workspace/index.js +16 -0
  38. package/dist/workspace/workspace-connection.d.ts +174 -0
  39. package/dist/workspace/workspace-connection.d.ts.map +1 -0
  40. package/dist/workspace/workspace-connection.js +290 -0
  41. package/dist/workspace/workspace-identity.d.ts +13 -0
  42. package/dist/workspace/workspace-identity.d.ts.map +1 -0
  43. package/dist/workspace/workspace-identity.js +70 -0
  44. package/dist/workspace/workspace-types.d.ts +82 -0
  45. package/dist/workspace/workspace-types.d.ts.map +1 -0
  46. package/dist/workspace/workspace-types.js +7 -0
  47. package/package.json +5 -1
  48. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
@@ -0,0 +1,525 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use serde_json::{json, Value};
3
+ use std::collections::HashMap;
4
+
5
+ /// Declarative tenant policy for resource authorization.
6
+ /// Defines which field(s) contain the tenant ID for a resource.
7
+ #[derive(Debug, Clone, Serialize, Deserialize)]
8
+ pub struct TenantPolicy {
9
+ /// Resource name (e.g., "release_jobs", "workflows", "users").
10
+ pub resource: String,
11
+
12
+ /// Field in the resource that contains the tenant ID.
13
+ /// For nested fields, use dot notation: "organization.id"
14
+ pub tenant_field: String,
15
+
16
+ /// Optional: Additional ownership fields that must match the actor.
17
+ /// Prevents one actor from managing another actor's resources.
18
+ pub owner_field: Option<String>,
19
+
20
+ /// Operations this policy applies to.
21
+ pub operations: PolicyOperations,
22
+ }
23
+
24
+ #[derive(Debug, Clone, Serialize, Deserialize)]
25
+ pub struct PolicyOperations {
26
+ /// Read operations (SELECT, GET)
27
+ pub read: bool,
28
+ /// Write operations (INSERT, UPDATE)
29
+ pub write: bool,
30
+ /// Delete operations (DELETE)
31
+ pub delete: bool,
32
+ }
33
+
34
+ /// Store for tenant policies.
35
+ #[derive(Debug, Clone)]
36
+ pub struct TenantPolicyStore {
37
+ policies: HashMap<String, TenantPolicy>,
38
+ }
39
+
40
+ impl TenantPolicyStore {
41
+ /// Creates a new policy store.
42
+ pub fn new() -> Self {
43
+ Self {
44
+ policies: HashMap::new(),
45
+ }
46
+ }
47
+
48
+ /// Registers a new tenant policy.
49
+ pub fn register(&mut self, policy: TenantPolicy) {
50
+ self.policies.insert(policy.resource.clone(), policy);
51
+ }
52
+
53
+ /// Gets a policy for a resource.
54
+ pub fn get(&self, resource: &str) -> Option<&TenantPolicy> {
55
+ self.policies.get(resource)
56
+ }
57
+
58
+ /// Loads default policies (can be overridden).
59
+ pub fn with_defaults() -> Self {
60
+ let mut store = Self::new();
61
+
62
+ // Release jobs policy
63
+ store.register(TenantPolicy {
64
+ resource: "release_jobs".into(),
65
+ tenant_field: "tenant_id".into(),
66
+ owner_field: Some("created_by".into()),
67
+ operations: PolicyOperations {
68
+ read: true,
69
+ write: true,
70
+ delete: true,
71
+ },
72
+ });
73
+
74
+ // Workflows policy
75
+ store.register(TenantPolicy {
76
+ resource: "workflows".into(),
77
+ tenant_field: "tenant_id".into(),
78
+ owner_field: Some("owner_id".into()),
79
+ operations: PolicyOperations {
80
+ read: true,
81
+ write: true,
82
+ delete: false,
83
+ },
84
+ });
85
+
86
+ // Users policy (no tenant field - organization members)
87
+ store.register(TenantPolicy {
88
+ resource: "organization_members".into(),
89
+ tenant_field: "organization_id".into(),
90
+ owner_field: Some("user_id".into()),
91
+ operations: PolicyOperations {
92
+ read: true,
93
+ write: false,
94
+ delete: true,
95
+ },
96
+ });
97
+
98
+ store
99
+ }
100
+ }
101
+
102
+ impl Default for TenantPolicyStore {
103
+ fn default() -> Self {
104
+ Self::new()
105
+ }
106
+ }
107
+
108
+ /// Authorization context for policy evaluation.
109
+ #[derive(Debug, Clone)]
110
+ pub struct PolicyAuthorizationContext {
111
+ /// Actor ID performing the operation.
112
+ pub actor_id: String,
113
+ /// Tenant ID from authenticated principal.
114
+ pub tenant_id: String,
115
+ /// Operation: "read", "write", "delete"
116
+ pub operation: String,
117
+ }
118
+
119
+ /// Policy evaluation result.
120
+ #[derive(Debug, Clone, PartialEq, Eq)]
121
+ pub enum PolicyDecision {
122
+ Allowed,
123
+ Denied(String),
124
+ }
125
+
126
+ impl PolicyDecision {
127
+ pub fn is_allowed(&self) -> bool {
128
+ matches!(self, PolicyDecision::Allowed)
129
+ }
130
+
131
+ pub fn error_message(&self) -> Option<&str> {
132
+ match self {
133
+ PolicyDecision::Allowed => None,
134
+ PolicyDecision::Denied(msg) => Some(msg),
135
+ }
136
+ }
137
+ }
138
+
139
+ /// Policy evaluator for authorization decisions.
140
+ pub struct PolicyEvaluator {
141
+ store: TenantPolicyStore,
142
+ }
143
+
144
+ impl PolicyEvaluator {
145
+ pub fn new(store: TenantPolicyStore) -> Self {
146
+ Self { store }
147
+ }
148
+
149
+ /// Authorizes a read operation.
150
+ pub fn authorize_read(
151
+ &self,
152
+ context: &PolicyAuthorizationContext,
153
+ resource: &str,
154
+ record: &Value,
155
+ ) -> PolicyDecision {
156
+ let policy = match self.store.get(resource) {
157
+ Some(p) => p,
158
+ None => return PolicyDecision::Allowed, // No policy = allowed
159
+ };
160
+
161
+ if !policy.operations.read {
162
+ return PolicyDecision::Denied(format!(
163
+ "read operation not allowed on {}",
164
+ resource
165
+ ));
166
+ }
167
+
168
+ self.check_tenant_access(context, &policy, record)
169
+ }
170
+
171
+ /// Authorizes a write (insert) operation.
172
+ pub fn authorize_write(
173
+ &self,
174
+ context: &PolicyAuthorizationContext,
175
+ resource: &str,
176
+ new_record: &Value,
177
+ ) -> PolicyDecision {
178
+ let policy = match self.store.get(resource) {
179
+ Some(p) => p,
180
+ None => return PolicyDecision::Allowed,
181
+ };
182
+
183
+ if !policy.operations.write {
184
+ return PolicyDecision::Denied(format!(
185
+ "write operation not allowed on {}",
186
+ resource
187
+ ));
188
+ }
189
+
190
+ // Check that new record has correct tenant_id
191
+ self.check_tenant_access(context, &policy, new_record)
192
+ }
193
+
194
+ /// Authorizes an update operation.
195
+ /// Must authorize both the existing record and the new state.
196
+ pub fn authorize_update(
197
+ &self,
198
+ context: &PolicyAuthorizationContext,
199
+ resource: &str,
200
+ existing_record: &Value,
201
+ new_record: &Value,
202
+ ) -> PolicyDecision {
203
+ let policy = match self.store.get(resource) {
204
+ Some(p) => p,
205
+ None => return PolicyDecision::Allowed,
206
+ };
207
+
208
+ if !policy.operations.write {
209
+ return PolicyDecision::Denied(format!(
210
+ "write operation not allowed on {}",
211
+ resource
212
+ ));
213
+ }
214
+
215
+ // Must authorize existing record
216
+ if let PolicyDecision::Denied(msg) = self.check_tenant_access(context, &policy, existing_record)
217
+ {
218
+ return PolicyDecision::Denied(format!("cannot update: {}", msg));
219
+ }
220
+
221
+ // Must authorize new record state
222
+ if let PolicyDecision::Denied(msg) = self.check_tenant_access(context, &policy, new_record) {
223
+ return PolicyDecision::Denied(format!("update would result in: {}", msg));
224
+ }
225
+
226
+ // Check that tenant_id hasn't changed
227
+ let existing_tenant = self.extract_tenant_id(&policy, existing_record);
228
+ let new_tenant = self.extract_tenant_id(&policy, new_record);
229
+
230
+ if existing_tenant != new_tenant {
231
+ return PolicyDecision::Denied("tenant_id cannot be changed".into());
232
+ }
233
+
234
+ // Check that owner_id hasn't changed (if policy has owner_field)
235
+ if let Some(owner_field) = &policy.owner_field {
236
+ let existing_owner = self.extract_field(owner_field, existing_record);
237
+ let new_owner = self.extract_field(owner_field, new_record);
238
+
239
+ if existing_owner != new_owner {
240
+ return PolicyDecision::Denied(format!("{} cannot be changed", owner_field));
241
+ }
242
+ }
243
+
244
+ PolicyDecision::Allowed
245
+ }
246
+
247
+ /// Authorizes a delete operation.
248
+ pub fn authorize_delete(
249
+ &self,
250
+ context: &PolicyAuthorizationContext,
251
+ resource: &str,
252
+ record: &Value,
253
+ ) -> PolicyDecision {
254
+ let policy = match self.store.get(resource) {
255
+ Some(p) => p,
256
+ None => return PolicyDecision::Allowed,
257
+ };
258
+
259
+ if !policy.operations.delete {
260
+ return PolicyDecision::Denied(format!(
261
+ "delete operation not allowed on {}",
262
+ resource
263
+ ));
264
+ }
265
+
266
+ self.check_tenant_access(context, &policy, record)
267
+ }
268
+
269
+ /// Filters records to only those the actor can access.
270
+ pub fn filter_records(
271
+ &self,
272
+ context: &PolicyAuthorizationContext,
273
+ resource: &str,
274
+ records: Vec<Value>,
275
+ ) -> Vec<Value> {
276
+ let policy = match self.store.get(resource) {
277
+ Some(p) => p,
278
+ None => return records, // No policy = all allowed
279
+ };
280
+
281
+ records
282
+ .into_iter()
283
+ .filter(|record| {
284
+ self.check_tenant_access(context, &policy, record)
285
+ .is_allowed()
286
+ })
287
+ .collect()
288
+ }
289
+
290
+ /// Checks if a record belongs to the actor's tenant.
291
+ fn check_tenant_access(
292
+ &self,
293
+ context: &PolicyAuthorizationContext,
294
+ policy: &TenantPolicy,
295
+ record: &Value,
296
+ ) -> PolicyDecision {
297
+ let record_tenant = self.extract_tenant_id(policy, record);
298
+
299
+ if record_tenant != Some(context.tenant_id.clone()) {
300
+ return PolicyDecision::Denied(format!(
301
+ "record belongs to different tenant"
302
+ ));
303
+ }
304
+
305
+ // Check owner field if policy specifies one
306
+ if let Some(owner_field) = &policy.owner_field {
307
+ let record_owner = self.extract_field(owner_field, record);
308
+ if let Some(owner) = record_owner {
309
+ if owner != context.actor_id {
310
+ return PolicyDecision::Denied(format!(
311
+ "record owned by different actor: {}",
312
+ owner
313
+ ));
314
+ }
315
+ }
316
+ }
317
+
318
+ PolicyDecision::Allowed
319
+ }
320
+
321
+ /// Extracts tenant ID from a record using the policy's tenant_field.
322
+ fn extract_tenant_id(&self, policy: &TenantPolicy, record: &Value) -> Option<String> {
323
+ self.extract_field(&policy.tenant_field, record)
324
+ }
325
+
326
+ /// Extracts a field value from a record (supports dot notation for nested fields).
327
+ fn extract_field(&self, field_path: &str, record: &Value) -> Option<String> {
328
+ let parts: Vec<&str> = field_path.split('.').collect();
329
+ let mut current = record;
330
+
331
+ for part in parts {
332
+ current = &current[part];
333
+ if current.is_null() {
334
+ return None;
335
+ }
336
+ }
337
+
338
+ current.as_str().map(|s| s.to_string())
339
+ }
340
+ }
341
+
342
+ #[cfg(test)]
343
+ mod tests {
344
+ use super::*;
345
+
346
+ fn test_context() -> PolicyAuthorizationContext {
347
+ PolicyAuthorizationContext {
348
+ actor_id: "user_123".into(),
349
+ tenant_id: "tenant_prod".into(),
350
+ operation: "read".into(),
351
+ }
352
+ }
353
+
354
+ fn test_record(tenant_id: &str, owner: Option<&str>) -> Value {
355
+ let mut record = json!({
356
+ "id": "job_123",
357
+ "tenant_id": tenant_id,
358
+ "name": "Deploy Release",
359
+ "status": "pending"
360
+ });
361
+
362
+ if let Some(owner_id) = owner {
363
+ record["created_by"] = json!(owner_id);
364
+ }
365
+
366
+ record
367
+ }
368
+
369
+ fn test_evaluator() -> PolicyEvaluator {
370
+ PolicyEvaluator::new(TenantPolicyStore::with_defaults())
371
+ }
372
+
373
+ #[test]
374
+ fn allow_read_same_tenant() {
375
+ let evaluator = test_evaluator();
376
+ let context = test_context();
377
+ let record = test_record("tenant_prod", Some("user_123"));
378
+
379
+ let decision = evaluator.authorize_read(&context, "release_jobs", &record);
380
+ assert_eq!(decision, PolicyDecision::Allowed);
381
+ }
382
+
383
+ #[test]
384
+ fn deny_read_cross_tenant() {
385
+ let evaluator = test_evaluator();
386
+ let context = test_context();
387
+ let record = test_record("tenant_other", Some("user_123"));
388
+
389
+ let decision = evaluator.authorize_read(&context, "release_jobs", &record);
390
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
391
+ }
392
+
393
+ #[test]
394
+ fn deny_insert_cross_tenant() {
395
+ let evaluator = test_evaluator();
396
+ let context = test_context();
397
+ let record = test_record("tenant_other", Some("user_123"));
398
+
399
+ let decision = evaluator.authorize_write(&context, "release_jobs", &record);
400
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
401
+ }
402
+
403
+ #[test]
404
+ fn deny_update_tenant_change() {
405
+ let evaluator = test_evaluator();
406
+ let context = test_context();
407
+ let existing = test_record("tenant_prod", Some("user_123"));
408
+ let mut updated = existing.clone();
409
+ updated["tenant_id"] = json!("tenant_other");
410
+
411
+ let decision = evaluator.authorize_update(&context, "release_jobs", &existing, &updated);
412
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
413
+ }
414
+
415
+ #[test]
416
+ fn deny_update_owner_change() {
417
+ let evaluator = test_evaluator();
418
+ let context = test_context();
419
+ let existing = test_record("tenant_prod", Some("user_123"));
420
+ let mut updated = existing.clone();
421
+ updated["created_by"] = json!("user_other");
422
+
423
+ let decision = evaluator.authorize_update(&context, "release_jobs", &existing, &updated);
424
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
425
+ }
426
+
427
+ #[test]
428
+ fn allow_update_same_tenant_and_owner() {
429
+ let evaluator = test_evaluator();
430
+ let context = test_context();
431
+ let existing = test_record("tenant_prod", Some("user_123"));
432
+ let mut updated = existing.clone();
433
+ updated["name"] = json!("Deploy Release Updated");
434
+
435
+ let decision = evaluator.authorize_update(&context, "release_jobs", &existing, &updated);
436
+ assert_eq!(decision, PolicyDecision::Allowed);
437
+ }
438
+
439
+ #[test]
440
+ fn deny_delete_cross_tenant() {
441
+ let evaluator = test_evaluator();
442
+ let context = test_context();
443
+ let record = test_record("tenant_other", None);
444
+
445
+ let decision = evaluator.authorize_delete(&context, "release_jobs", &record);
446
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
447
+ }
448
+
449
+ #[test]
450
+ fn filter_records_by_tenant() {
451
+ let evaluator = test_evaluator();
452
+ let context = test_context();
453
+
454
+ let records = vec![
455
+ test_record("tenant_prod", Some("user_123")),
456
+ test_record("tenant_other", Some("user_123")),
457
+ test_record("tenant_prod", Some("user_123")),
458
+ ];
459
+
460
+ let filtered = evaluator.filter_records(&context, "release_jobs", records);
461
+ assert_eq!(filtered.len(), 2);
462
+ }
463
+
464
+ #[test]
465
+ fn no_policy_allows_operation() {
466
+ let evaluator = PolicyEvaluator::new(TenantPolicyStore::new());
467
+ let context = test_context();
468
+ let record = json!({"any": "record"});
469
+
470
+ let decision = evaluator.authorize_read(&context, "unknown_resource", &record);
471
+ assert_eq!(decision, PolicyDecision::Allowed);
472
+ }
473
+
474
+ #[test]
475
+ fn deny_operation_when_not_permitted() {
476
+ let evaluator = test_evaluator();
477
+ let context = test_context();
478
+ let record = test_record("tenant_prod", None);
479
+
480
+ // Workflows policy doesn't allow delete
481
+ let decision = evaluator.authorize_delete(&context, "workflows", &record);
482
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
483
+ }
484
+
485
+ #[test]
486
+ fn extract_nested_tenant_field() {
487
+ let mut store = TenantPolicyStore::new();
488
+ store.register(TenantPolicy {
489
+ resource: "nested_test".into(),
490
+ tenant_field: "organization.id".into(),
491
+ owner_field: None,
492
+ operations: PolicyOperations {
493
+ read: true,
494
+ write: true,
495
+ delete: true,
496
+ },
497
+ });
498
+
499
+ let evaluator = PolicyEvaluator::new(store);
500
+ let mut context = test_context();
501
+ context.tenant_id = "org_123".into();
502
+
503
+ let record = json!({
504
+ "id": "item_1",
505
+ "organization": {
506
+ "id": "org_123"
507
+ }
508
+ });
509
+
510
+ let decision = evaluator.authorize_read(&context, "nested_test", &record);
511
+ assert_eq!(decision, PolicyDecision::Allowed);
512
+ }
513
+
514
+ #[test]
515
+ fn owner_isolation_prevents_cross_actor_access() {
516
+ let evaluator = test_evaluator();
517
+ let mut context = test_context();
518
+ context.actor_id = "user_different".into();
519
+
520
+ let record = test_record("tenant_prod", Some("user_123"));
521
+
522
+ let decision = evaluator.authorize_read(&context, "release_jobs", &record);
523
+ assert!(matches!(decision, PolicyDecision::Denied(_)));
524
+ }
525
+ }