@feltdb/core 0.4.15 → 0.4.16

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 (37) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/create/package-versions.js +1 -1
  3. package/dist/create/server-source/crates/feltdb/Cargo.toml +1 -1
  4. package/dist/create/server-source/crates/feltdb/src/application.rs +93 -0
  5. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +787 -0
  6. package/dist/create/server-source/crates/feltdb/src/lib.rs +139 -0
  7. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +1669 -0
  8. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +1269 -15
  9. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +406 -0
  10. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +908 -0
  11. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +1028 -0
  12. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +332 -0
  13. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +342 -0
  14. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +313 -0
  15. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +90 -0
  16. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +571 -0
  17. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +506 -0
  18. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +81 -0
  19. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +434 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/main.rs +130 -23
  21. package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +2 -2
  22. package/dist/db.d.ts +10 -0
  23. package/dist/db.d.ts.map +1 -1
  24. package/dist/db.js +3 -1
  25. package/dist/file-db.d.ts +69 -0
  26. package/dist/file-db.d.ts.map +1 -0
  27. package/dist/file-db.js +355 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/studio-app/assets/{feltdb_wasm-CJEJryDx.js → feltdb_wasm-CBGD0zRu.js} +1 -1
  32. package/dist/studio-app/assets/feltdb_wasm_bg-C6ATF9mJ.wasm +0 -0
  33. package/dist/studio-app/assets/{index-D_p8T7nO.js → index-D74dfBgZ.js} +9 -9
  34. package/dist/studio-app/index.html +1 -1
  35. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  36. package/package.json +7 -2
  37. package/dist/studio-app/assets/feltdb_wasm_bg-BJxQXtoo.wasm +0 -0
@@ -22,6 +22,7 @@
22
22
 
23
23
  use crate::{
24
24
  application::{ApplicationManifest, ApplicationRevision, FieldDefinition},
25
+ policy_evaluation::{PolicyEvaluator, PolicySubject, RecordAuthorizationContext, Actor, AuthorizationState},
25
26
  AtomicMutation, AtomicPrecondition, FeltDb, StoredRow,
26
27
  };
27
28
  use serde::{Deserialize, Serialize};
@@ -30,6 +31,7 @@ use sha2::{Digest, Sha256};
30
31
  use std::{
31
32
  cmp::Ordering,
32
33
  collections::{BTreeMap, BTreeSet},
34
+ sync::Arc,
33
35
  time::Instant,
34
36
  };
35
37
 
@@ -1064,6 +1066,7 @@ pub fn execute_query(
1064
1066
  schema: &StateSchema,
1065
1067
  context: &ReadContext,
1066
1068
  query: &CanonicalQuery,
1069
+ read_policy: Option<PolicySubject>,
1067
1070
  ) -> Result<QueryResult, StateFailure> {
1068
1071
  let query_start = Instant::now();
1069
1072
 
@@ -1201,7 +1204,9 @@ pub fn execute_query(
1201
1204
  .any(|agg| matches!(agg.function, AggregateFunction::Count));
1202
1205
 
1203
1206
  // If unfiltered COUNT(*), use maintained cardinality instead of scanning rows
1204
- if is_unfiltered_count {
1207
+ // NOTE: Skip this optimization if record-level authorization is in effect,
1208
+ // since maintained cardinality doesn't account for per-record filtering
1209
+ if is_unfiltered_count && read_policy.is_none() {
1205
1210
  let cardinality = _db.collection_cardinality(&capability).unwrap_or(0);
1206
1211
 
1207
1212
  let count_result = serde_json::json!(cardinality);
@@ -1337,6 +1342,39 @@ pub fn execute_query(
1337
1342
  query.filter.as_ref().is_none_or(|f| matches(value, f))
1338
1343
  })
1339
1344
  .collect::<Vec<_>>();
1345
+
1346
+ if let Some(policy) = &read_policy {
1347
+ let actor = if context.authorization.subject.is_empty() || context.authorization.subject == ":" {
1348
+ None
1349
+ } else {
1350
+ context.authorization.subject.split(':').nth(1).map(|id| Actor::new(id))
1351
+ };
1352
+
1353
+ // Create authorization state for policies that need to verify relationships
1354
+ let auth_state = AuthorizationState::new(
1355
+ Arc::new(schema.clone()),
1356
+ context.state_namespace.clone(),
1357
+ Arc::new(snapshot_rows.clone()),
1358
+ );
1359
+
1360
+ values.retain(|record_value| {
1361
+ if let Some(record_id) = record_value.get("_id").and_then(Value::as_str) {
1362
+ let record_context = RecordAuthorizationContext {
1363
+ actor: actor.clone(),
1364
+ collection: query.collection.clone(),
1365
+ record_id: record_id.to_string(),
1366
+ record_value: record_value.clone(),
1367
+ authorization_state: Some(auth_state.clone()),
1368
+ };
1369
+
1370
+ PolicyEvaluator::evaluate_record(policy.clone(), &record_context)
1371
+ .is_ok()
1372
+ } else {
1373
+ true
1374
+ }
1375
+ });
1376
+ }
1377
+
1340
1378
  for value in &mut values {
1341
1379
  for reference in &query.references {
1342
1380
  let id = value.get(&reference.field).and_then(Value::as_str);
@@ -1874,6 +1912,7 @@ pub fn execute_transaction(
1874
1912
  db: &FeltDb,
1875
1913
  schema: &StateSchema,
1876
1914
  request: &TransactionRequest,
1915
+ write_policy: Option<PolicySubject>,
1877
1916
  ) -> Result<TransactionResult, StateFailure> {
1878
1917
  if request.application_id != schema.application_id
1879
1918
  || request.revision_id != schema.revision_id
@@ -2047,6 +2086,68 @@ pub fn execute_transaction(
2047
2086
  .get(&(capability.clone(), key.clone()))
2048
2087
  .cloned()
2049
2088
  .unwrap_or_else(|| persisted.map(|row| Some(row.value.clone())).unwrap_or(None));
2089
+
2090
+ // Per-operation record authorization
2091
+ if let Some(policy) = &write_policy {
2092
+ let actor = if request.authorization.subject.is_empty()
2093
+ || request.authorization.subject == ":"
2094
+ {
2095
+ None
2096
+ } else {
2097
+ request
2098
+ .authorization
2099
+ .subject
2100
+ .split(':')
2101
+ .nth(1)
2102
+ .map(|id| Actor::new(id))
2103
+ };
2104
+
2105
+ // Select authorization record based on operation type
2106
+ let auth_record_value = match operation.kind {
2107
+ TransactionOperationKind::Insert => {
2108
+ // For create: authorize against proposed record
2109
+ operation.value.clone()
2110
+ }
2111
+ TransactionOperationKind::Update | TransactionOperationKind::Delete => {
2112
+ // For update/delete: authorize against EXISTING record (before changes)
2113
+ // This prevents authorization bypass via ownership/org changes
2114
+ existing_value.clone().ok_or_else(|| {
2115
+ StateFailure::new("CONFLICT", "record does not exist")
2116
+ })?
2117
+ }
2118
+ };
2119
+
2120
+ // Create authorization state for policies that need to verify relationships
2121
+ let auth_state = AuthorizationState::new(
2122
+ Arc::new(schema.clone()),
2123
+ request
2124
+ .state_namespace
2125
+ .as_deref()
2126
+ .unwrap_or(&request.application_id)
2127
+ .to_string(),
2128
+ Arc::new(snapshot.clone()),
2129
+ );
2130
+
2131
+ let record_context = RecordAuthorizationContext {
2132
+ actor,
2133
+ collection: operation.collection.clone(),
2134
+ record_id: key.clone(),
2135
+ record_value: auth_record_value,
2136
+ authorization_state: Some(auth_state),
2137
+ };
2138
+
2139
+ PolicyEvaluator::evaluate_record(policy.clone(), &record_context)
2140
+ .map_err(|_| {
2141
+ StateFailure::new(
2142
+ "AUTHORIZATION_DENIED",
2143
+ &format!(
2144
+ "record authorization failed for operation on {}:{}",
2145
+ operation.collection, key
2146
+ ),
2147
+ )
2148
+ })?;
2149
+ }
2150
+
2050
2151
  let value = match operation.kind {
2051
2152
  TransactionOperationKind::Insert => {
2052
2153
  if existing_value.is_some() {
@@ -2362,6 +2463,44 @@ mod tests {
2362
2463
  field_projections: Default::default(),
2363
2464
  }
2364
2465
  }
2466
+ fn schema_with_owner() -> StateSchema {
2467
+ StateSchema {
2468
+ contract_version: 1,
2469
+ schema_version: 1,
2470
+ application_id: "app".into(),
2471
+ revision_id: "rev".into(),
2472
+ collections: vec![CollectionSchema {
2473
+ name: "incidents".into(),
2474
+ version: 1,
2475
+ fields: vec![
2476
+ FieldSchema {
2477
+ name: "title".into(),
2478
+ field_type: FieldType::Primitive {
2479
+ primitive: PrimitiveType::String,
2480
+ },
2481
+ nullable: false,
2482
+ required: true,
2483
+ default: None,
2484
+ constraints: Default::default(),
2485
+ computed: None,
2486
+ },
2487
+ FieldSchema {
2488
+ name: "owner_id".into(),
2489
+ field_type: FieldType::Primitive {
2490
+ primitive: PrimitiveType::String,
2491
+ },
2492
+ nullable: false,
2493
+ required: false,
2494
+ default: None,
2495
+ constraints: Default::default(),
2496
+ computed: None,
2497
+ },
2498
+ ],
2499
+ indexes: vec![],
2500
+ }],
2501
+ }
2502
+ }
2503
+
2365
2504
  fn db(name: &str) -> FeltDb {
2366
2505
  FeltDb::open(
2367
2506
  std::env::temp_dir().join(format!("state-contract-{name}-{}.log", crate::now_ms())),
@@ -2438,14 +2577,14 @@ mod tests {
2438
2577
  },
2439
2578
  ],
2440
2579
  };
2441
- let result = execute_transaction(&db, &schema, &request).unwrap();
2580
+ let result = execute_transaction(&db, &schema, &request, None).unwrap();
2442
2581
  assert_eq!(result.state_after, 2);
2443
2582
  assert_eq!(db.export_snapshot().unwrap().rows.len(), 2);
2444
2583
  let mut stale = request;
2445
2584
  stale.transaction_id = Some("stale".into());
2446
2585
  stale.causal_parent = Some(0);
2447
2586
  assert_eq!(
2448
- execute_transaction(&db, &schema, &stale).unwrap_err().code,
2587
+ execute_transaction(&db, &schema, &stale, None).unwrap_err().code,
2449
2588
  "CONFLICT"
2450
2589
  );
2451
2590
  }
@@ -2481,7 +2620,7 @@ mod tests {
2481
2620
  ],
2482
2621
  };
2483
2622
  assert_eq!(
2484
- execute_transaction(&db, &schema, &request)
2623
+ execute_transaction(&db, &schema, &request, None)
2485
2624
  .unwrap_err()
2486
2625
  .code,
2487
2626
  "VALIDATION_FAILED"
@@ -2514,6 +2653,7 @@ mod tests {
2514
2653
  if_version: None,
2515
2654
  }],
2516
2655
  },
2656
+ None,
2517
2657
  )
2518
2658
  .unwrap();
2519
2659
  }
@@ -2544,6 +2684,7 @@ mod tests {
2544
2684
  }],
2545
2685
  references: vec![],
2546
2686
  },
2687
+ None,
2547
2688
  )
2548
2689
  .unwrap();
2549
2690
  assert_eq!(result.records, vec![serde_json::json!({"title":"A"})]);
@@ -2574,6 +2715,7 @@ mod tests {
2574
2715
  aggregates: vec![],
2575
2716
  references: vec![],
2576
2717
  },
2718
+ None,
2577
2719
  )
2578
2720
  .unwrap_err();
2579
2721
  assert_eq!(error.code, "UNAUTHORIZED_PROJECTION");
@@ -2600,8 +2742,8 @@ mod tests {
2600
2742
  if_version: None,
2601
2743
  }],
2602
2744
  };
2603
- let first = execute_transaction(&db, &schema, &request).unwrap();
2604
- let duplicate = execute_transaction(&db, &schema, &request).unwrap();
2745
+ let first = execute_transaction(&db, &schema, &request, None).unwrap();
2746
+ let duplicate = execute_transaction(&db, &schema, &request, None).unwrap();
2605
2747
  assert_eq!(first.transaction_id, duplicate.transaction_id);
2606
2748
  assert!(duplicate.duplicate);
2607
2749
  request.transaction_id = Some("stale-update".into());
@@ -2612,7 +2754,7 @@ mod tests {
2612
2754
  value: serde_json::json!({"title":"Changed"}),
2613
2755
  if_version: Some(99),
2614
2756
  }];
2615
- let failure = execute_transaction(&db, &schema, &request).unwrap_err();
2757
+ let failure = execute_transaction(&db, &schema, &request, None).unwrap_err();
2616
2758
  assert_eq!(failure.code, "PRECONDITION_FAILED");
2617
2759
  assert_eq!(failure.expected, Some(Value::from(99)));
2618
2760
  assert_eq!(failure.actual, Some(Value::from(1)));
@@ -2639,9 +2781,9 @@ mod tests {
2639
2781
  if_version: None,
2640
2782
  }],
2641
2783
  };
2642
- execute_transaction(&db, &schema, &insert("one")).unwrap();
2784
+ execute_transaction(&db, &schema, &insert("one"), None).unwrap();
2643
2785
  let context = begin_read(&db, &schema, "app", auth()).unwrap();
2644
- execute_transaction(&db, &schema, &insert("two")).unwrap();
2786
+ execute_transaction(&db, &schema, &insert("two"), None).unwrap();
2645
2787
  let result = execute_query(
2646
2788
  &db,
2647
2789
  &schema,
@@ -2658,6 +2800,7 @@ mod tests {
2658
2800
  aggregates: vec![],
2659
2801
  references: vec![],
2660
2802
  },
2803
+ None,
2661
2804
  )
2662
2805
  .unwrap();
2663
2806
  assert_eq!(result.records.len(), 1);
@@ -2697,7 +2840,7 @@ mod tests {
2697
2840
  },
2698
2841
  ],
2699
2842
  };
2700
- execute_transaction(&db, &schema, &request).unwrap();
2843
+ execute_transaction(&db, &schema, &request, None).unwrap();
2701
2844
  }
2702
2845
  let reopened = FeltDb::open(path).unwrap();
2703
2846
  assert_eq!(reopened.export_snapshot().unwrap().rows.len(), 2);
@@ -2730,7 +2873,7 @@ mod tests {
2730
2873
  })
2731
2874
  .collect(),
2732
2875
  };
2733
- execute_transaction(&db, &schema, &request).unwrap();
2876
+ execute_transaction(&db, &schema, &request, None).unwrap();
2734
2877
  let context = begin_read(&db, &schema, "app", auth()).unwrap();
2735
2878
  let result = execute_query(
2736
2879
  &db,
@@ -2751,6 +2894,7 @@ mod tests {
2751
2894
  aggregates: vec![],
2752
2895
  references: vec![],
2753
2896
  },
2897
+ None,
2754
2898
  )
2755
2899
  .unwrap();
2756
2900
  assert_eq!(result.plan.access_method, Some("index".to_string()));
@@ -2791,7 +2935,7 @@ mod tests {
2791
2935
  })
2792
2936
  .collect(),
2793
2937
  };
2794
- execute_transaction(&db, &schema, &request).unwrap();
2938
+ execute_transaction(&db, &schema, &request, None).unwrap();
2795
2939
  let context = begin_read(&db, &schema, "app", auth()).unwrap();
2796
2940
  let result = execute_query(
2797
2941
  &db,
@@ -2812,6 +2956,7 @@ mod tests {
2812
2956
  aggregates: vec![],
2813
2957
  references: vec![],
2814
2958
  },
2959
+ None,
2815
2960
  )
2816
2961
  .unwrap();
2817
2962
  assert_eq!(
@@ -2851,7 +2996,7 @@ mod tests {
2851
2996
  if_version: None,
2852
2997
  }],
2853
2998
  };
2854
- execute_transaction(&db, &schema, &request).unwrap();
2999
+ execute_transaction(&db, &schema, &request, None).unwrap();
2855
3000
  let context = begin_read(&db, &schema, "app", auth()).unwrap();
2856
3001
  let result = execute_query(
2857
3002
  &db,
@@ -2872,6 +3017,7 @@ mod tests {
2872
3017
  aggregates: vec![],
2873
3018
  references: vec![],
2874
3019
  },
3020
+ None,
2875
3021
  )
2876
3022
  .unwrap();
2877
3023
  assert!(result.plan.timing.is_some());
@@ -2909,7 +3055,7 @@ mod tests {
2909
3055
  })
2910
3056
  .collect(),
2911
3057
  };
2912
- execute_transaction(&db, &schema, &request).unwrap();
3058
+ execute_transaction(&db, &schema, &request, None).unwrap();
2913
3059
  let context = begin_read(&db, &schema, "app", auth()).unwrap();
2914
3060
  let result = execute_query(
2915
3061
  &db,
@@ -2930,6 +3076,7 @@ mod tests {
2930
3076
  aggregates: vec![],
2931
3077
  references: vec![],
2932
3078
  },
3079
+ None,
2933
3080
  )
2934
3081
  .unwrap();
2935
3082
  let metrics = result.plan.index_metrics.as_ref().unwrap();
@@ -3043,7 +3190,7 @@ mod tests {
3043
3190
  preconditions: vec![],
3044
3191
  operations,
3045
3192
  };
3046
- execute_transaction(&db, &schema, &request).unwrap();
3193
+ execute_transaction(&db, &schema, &request, None).unwrap();
3047
3194
  }
3048
3195
  let load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
3049
3196
  println!(" Loaded ~23,000 records in {:.2}ms", load_ms);
@@ -3074,6 +3221,7 @@ mod tests {
3074
3221
  aggregates: vec![],
3075
3222
  references: vec![],
3076
3223
  },
3224
+ None,
3077
3225
  )
3078
3226
  .unwrap();
3079
3227
  let q_ms = q_start.elapsed().as_secs_f64() * 1000.0;
@@ -3115,4 +3263,1110 @@ mod tests {
3115
3263
  total_query_time_ms
3116
3264
  );
3117
3265
  }
3266
+
3267
+ #[test]
3268
+ fn authenticated_policy_allows_query_with_capability() {
3269
+ let db = db("auth_read_allow");
3270
+ let schema = schema();
3271
+
3272
+ // Actor with "state:read" capability
3273
+ let mut auth_context = auth();
3274
+ auth_context.subject = "user:alice".into();
3275
+
3276
+ let context = begin_read(&db, &schema, "app", auth_context).unwrap();
3277
+ let query = CanonicalQuery {
3278
+ collection: "incidents".into(),
3279
+ filter: None,
3280
+ order_by: vec![],
3281
+ limit: Some(100),
3282
+ offset: 0,
3283
+ cursor: None,
3284
+ projection: vec![],
3285
+ group_by: vec![],
3286
+ aggregates: vec![],
3287
+ references: vec![],
3288
+ };
3289
+
3290
+ let result = execute_query(&db, &schema, &context, &query, None);
3291
+ assert!(result.is_ok(), "Query should succeed with state:read capability");
3292
+ }
3293
+
3294
+ #[test]
3295
+ fn authenticated_policy_denies_query_without_capability() {
3296
+ let db = db("auth_read_deny");
3297
+ let schema = schema();
3298
+
3299
+ // Actor without "state:read" capability (unauthenticated equivalent)
3300
+ let auth_context = AuthorizationContext {
3301
+ subject: "user:bob".into(),
3302
+ tenant_id: "tenant".into(),
3303
+ application_id: "app".into(),
3304
+ revision_id: "rev".into(),
3305
+ capabilities: BTreeSet::new(), // No capabilities
3306
+ readable_collections: Default::default(),
3307
+ writable_collections: Default::default(),
3308
+ field_projections: Default::default(),
3309
+ };
3310
+
3311
+ let context = begin_read(&db, &schema, "app", auth_context).unwrap();
3312
+ let query = CanonicalQuery {
3313
+ collection: "incidents".into(),
3314
+ filter: None,
3315
+ order_by: vec![],
3316
+ limit: Some(100),
3317
+ offset: 0,
3318
+ cursor: None,
3319
+ projection: vec![],
3320
+ group_by: vec![],
3321
+ aggregates: vec![],
3322
+ references: vec![],
3323
+ };
3324
+
3325
+ let result = execute_query(&db, &schema, &context, &query, None);
3326
+ assert!(
3327
+ result.is_err(),
3328
+ "Query should fail without state:read capability"
3329
+ );
3330
+ if let Err(e) = result {
3331
+ assert_eq!(e.code, "AUTHORIZATION_DENIED");
3332
+ }
3333
+ }
3334
+
3335
+ #[test]
3336
+ fn write_policy_requires_state_write_capability() {
3337
+ let db = db("auth_write_deny");
3338
+ let schema = schema();
3339
+
3340
+ // Transaction without state:write capability
3341
+ let auth_context = AuthorizationContext {
3342
+ subject: "user:charlie".into(),
3343
+ tenant_id: "tenant".into(),
3344
+ application_id: "app".into(),
3345
+ revision_id: "rev".into(),
3346
+ capabilities: BTreeSet::new(), // No capabilities
3347
+ readable_collections: Default::default(),
3348
+ writable_collections: Default::default(),
3349
+ field_projections: Default::default(),
3350
+ };
3351
+
3352
+ let request = TransactionRequest {
3353
+ transaction_id: None,
3354
+ tenant_id: "tenant".into(),
3355
+ application_id: "app".into(),
3356
+ revision_id: "rev".into(),
3357
+ schema_version: 1,
3358
+ state_namespace: None,
3359
+ causal_parent: None,
3360
+ authorization: auth_context,
3361
+ operations: vec![TransactionOperation {
3362
+ kind: TransactionOperationKind::Insert,
3363
+ collection: "incidents".into(),
3364
+ id: "proj-1".into(),
3365
+ value: serde_json::json!({"title": "Test Project"}),
3366
+ if_version: None,
3367
+ }],
3368
+ preconditions: vec![],
3369
+ };
3370
+
3371
+ let result = execute_transaction(&db, &schema, &request, None);
3372
+ assert!(
3373
+ result.is_err(),
3374
+ "Transaction should fail without state:write capability"
3375
+ );
3376
+ if let Err(e) = result {
3377
+ assert_eq!(e.code, "AUTHORIZATION_DENIED");
3378
+ }
3379
+ }
3380
+
3381
+ // PR #2.1 Verification Tests: Prove runtime enforcement works end-to-end
3382
+
3383
+ #[test]
3384
+ fn policy_enforcement_unauthenticated_read_denied() {
3385
+ // Test: Unauthenticated actor cannot read even with valid query
3386
+ let db = db("policy_unauth_read");
3387
+ let schema = schema();
3388
+
3389
+ // Pre-populate data
3390
+ let insert_request = TransactionRequest {
3391
+ transaction_id: None,
3392
+ tenant_id: "tenant".into(),
3393
+ application_id: "app".into(),
3394
+ revision_id: "rev".into(),
3395
+ schema_version: 1,
3396
+ state_namespace: None,
3397
+ causal_parent: None,
3398
+ authorization: auth(),
3399
+ operations: vec![TransactionOperation {
3400
+ kind: TransactionOperationKind::Insert,
3401
+ collection: "incidents".into(),
3402
+ id: "inc-1".into(),
3403
+ value: serde_json::json!({"title": "Test Incident"}),
3404
+ if_version: None,
3405
+ }],
3406
+ preconditions: vec![],
3407
+ };
3408
+ execute_transaction(&db, &schema, &insert_request, None).unwrap();
3409
+
3410
+ // Unauthenticated read attempt (no capabilities)
3411
+ let unauth_context = AuthorizationContext {
3412
+ subject: "anonymous".into(),
3413
+ tenant_id: "tenant".into(),
3414
+ application_id: "app".into(),
3415
+ revision_id: "rev".into(),
3416
+ capabilities: BTreeSet::new(), // No capabilities
3417
+ readable_collections: Default::default(),
3418
+ writable_collections: Default::default(),
3419
+ field_projections: Default::default(),
3420
+ };
3421
+
3422
+ let context = begin_read(&db, &schema, "app", unauth_context).unwrap();
3423
+ let query = CanonicalQuery {
3424
+ collection: "incidents".into(),
3425
+ filter: None,
3426
+ order_by: vec![],
3427
+ limit: Some(100),
3428
+ offset: 0,
3429
+ cursor: None,
3430
+ projection: vec![],
3431
+ group_by: vec![],
3432
+ aggregates: vec![],
3433
+ references: vec![],
3434
+ };
3435
+
3436
+ let result = execute_query(&db, &schema, &context, &query, None);
3437
+ assert!(result.is_err(), "Unauthenticated read must be denied");
3438
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3439
+ }
3440
+
3441
+ #[test]
3442
+ fn policy_enforcement_unauthenticated_write_denied() {
3443
+ // Test: Unauthenticated actor cannot write
3444
+ let db = db("policy_unauth_write");
3445
+ let schema = schema();
3446
+
3447
+ let unauth_context = AuthorizationContext {
3448
+ subject: "anonymous".into(),
3449
+ tenant_id: "tenant".into(),
3450
+ application_id: "app".into(),
3451
+ revision_id: "rev".into(),
3452
+ capabilities: BTreeSet::new(), // No capabilities
3453
+ readable_collections: Default::default(),
3454
+ writable_collections: Default::default(),
3455
+ field_projections: Default::default(),
3456
+ };
3457
+
3458
+ let request = TransactionRequest {
3459
+ transaction_id: None,
3460
+ tenant_id: "tenant".into(),
3461
+ application_id: "app".into(),
3462
+ revision_id: "rev".into(),
3463
+ schema_version: 1,
3464
+ state_namespace: None,
3465
+ causal_parent: None,
3466
+ authorization: unauth_context,
3467
+ operations: vec![TransactionOperation {
3468
+ kind: TransactionOperationKind::Insert,
3469
+ collection: "incidents".into(),
3470
+ id: "inc-1".into(),
3471
+ value: serde_json::json!({"title": "Incident"}),
3472
+ if_version: None,
3473
+ }],
3474
+ preconditions: vec![],
3475
+ };
3476
+
3477
+ let result = execute_transaction(&db, &schema, &request, None);
3478
+ assert!(result.is_err(), "Unauthenticated write must be denied");
3479
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3480
+ }
3481
+
3482
+ #[test]
3483
+ fn policy_enforcement_authenticated_read_allowed() {
3484
+ // Test: Authenticated actor can read with proper capability
3485
+ let db = db("policy_auth_read_ok");
3486
+ let schema = schema();
3487
+
3488
+ // Pre-populate data
3489
+ let insert_request = TransactionRequest {
3490
+ transaction_id: None,
3491
+ tenant_id: "tenant".into(),
3492
+ application_id: "app".into(),
3493
+ revision_id: "rev".into(),
3494
+ schema_version: 1,
3495
+ state_namespace: None,
3496
+ causal_parent: None,
3497
+ authorization: auth(),
3498
+ operations: vec![TransactionOperation {
3499
+ kind: TransactionOperationKind::Insert,
3500
+ collection: "incidents".into(),
3501
+ id: "inc-1".into(),
3502
+ value: serde_json::json!({"title": "Test Incident"}),
3503
+ if_version: None,
3504
+ }],
3505
+ preconditions: vec![],
3506
+ };
3507
+ execute_transaction(&db, &schema, &insert_request, None).unwrap();
3508
+
3509
+ // Authenticated read (with state:read capability)
3510
+ let auth_context = AuthorizationContext {
3511
+ subject: "user:alice".into(),
3512
+ tenant_id: "tenant".into(),
3513
+ application_id: "app".into(),
3514
+ revision_id: "rev".into(),
3515
+ capabilities: ["state:read".into()].into(),
3516
+ readable_collections: Default::default(),
3517
+ writable_collections: Default::default(),
3518
+ field_projections: Default::default(),
3519
+ };
3520
+
3521
+ let context = begin_read(&db, &schema, "app", auth_context).unwrap();
3522
+ let query = CanonicalQuery {
3523
+ collection: "incidents".into(),
3524
+ filter: None,
3525
+ order_by: vec![],
3526
+ limit: Some(100),
3527
+ offset: 0,
3528
+ cursor: None,
3529
+ projection: vec![],
3530
+ group_by: vec![],
3531
+ aggregates: vec![],
3532
+ references: vec![],
3533
+ };
3534
+
3535
+ let result = execute_query(&db, &schema, &context, &query, None);
3536
+ assert!(result.is_ok(), "Authenticated read must be allowed");
3537
+ let query_result = result.unwrap();
3538
+ assert!(query_result.records.len() >= 1, "Query should return the inserted record");
3539
+ }
3540
+
3541
+ #[test]
3542
+ fn policy_enforcement_authenticated_write_allowed() {
3543
+ // Test: Authenticated actor can write with proper capability
3544
+ let db = db("policy_auth_write_ok");
3545
+ let schema = schema();
3546
+
3547
+ let auth_context = AuthorizationContext {
3548
+ subject: "user:bob".into(),
3549
+ tenant_id: "tenant".into(),
3550
+ application_id: "app".into(),
3551
+ revision_id: "rev".into(),
3552
+ capabilities: ["state:write".into()].into(),
3553
+ readable_collections: Default::default(),
3554
+ writable_collections: Default::default(),
3555
+ field_projections: Default::default(),
3556
+ };
3557
+
3558
+ let request = TransactionRequest {
3559
+ transaction_id: None,
3560
+ tenant_id: "tenant".into(),
3561
+ application_id: "app".into(),
3562
+ revision_id: "rev".into(),
3563
+ schema_version: 1,
3564
+ state_namespace: None,
3565
+ causal_parent: None,
3566
+ authorization: auth_context,
3567
+ operations: vec![TransactionOperation {
3568
+ kind: TransactionOperationKind::Insert,
3569
+ collection: "incidents".into(),
3570
+ id: "inc-1".into(),
3571
+ value: serde_json::json!({"title": "New Incident"}),
3572
+ if_version: None,
3573
+ }],
3574
+ preconditions: vec![],
3575
+ };
3576
+
3577
+ let result = execute_transaction(&db, &schema, &request, None);
3578
+ assert!(result.is_ok(), "Authenticated write must be allowed");
3579
+ assert!(result.unwrap().state_after > 0, "Transaction should have succeeded");
3580
+ }
3581
+
3582
+ #[test]
3583
+ fn policy_enforcement_transaction_atomicity_with_auth() {
3584
+ // Test: Transaction authorization is checked before any operations commit
3585
+ let db = db("policy_tx_atomic");
3586
+ let schema = schema();
3587
+
3588
+ // Start with one record
3589
+ let insert_initial = TransactionRequest {
3590
+ transaction_id: None,
3591
+ tenant_id: "tenant".into(),
3592
+ application_id: "app".into(),
3593
+ revision_id: "rev".into(),
3594
+ schema_version: 1,
3595
+ state_namespace: None,
3596
+ causal_parent: None,
3597
+ authorization: auth(),
3598
+ operations: vec![TransactionOperation {
3599
+ kind: TransactionOperationKind::Insert,
3600
+ collection: "incidents".into(),
3601
+ id: "initial".into(),
3602
+ value: serde_json::json!({"title": "Initial"}),
3603
+ if_version: None,
3604
+ }],
3605
+ preconditions: vec![],
3606
+ };
3607
+ let initial_result = execute_transaction(&db, &schema, &insert_initial, None).unwrap();
3608
+ let state_before_denied_tx = initial_result.state_after;
3609
+
3610
+ // Attempt transaction without authorization
3611
+ let unauth_context = AuthorizationContext {
3612
+ subject: "anonymous".into(),
3613
+ tenant_id: "tenant".into(),
3614
+ application_id: "app".into(),
3615
+ revision_id: "rev".into(),
3616
+ capabilities: BTreeSet::new(), // No capabilities
3617
+ readable_collections: Default::default(),
3618
+ writable_collections: Default::default(),
3619
+ field_projections: Default::default(),
3620
+ };
3621
+
3622
+ let denied_request = TransactionRequest {
3623
+ transaction_id: None,
3624
+ tenant_id: "tenant".into(),
3625
+ application_id: "app".into(),
3626
+ revision_id: "rev".into(),
3627
+ schema_version: 1,
3628
+ state_namespace: None,
3629
+ causal_parent: None,
3630
+ authorization: unauth_context,
3631
+ operations: vec![TransactionOperation {
3632
+ kind: TransactionOperationKind::Insert,
3633
+ collection: "incidents".into(),
3634
+ id: "denied".into(),
3635
+ value: serde_json::json!({"title": "Should Not Commit"}),
3636
+ if_version: None,
3637
+ }],
3638
+ preconditions: vec![],
3639
+ };
3640
+
3641
+ let denied_result = execute_transaction(&db, &schema, &denied_request, None);
3642
+ assert!(denied_result.is_err(), "Transaction should be denied");
3643
+
3644
+ // Verify state hasn't changed
3645
+ let verify_auth = auth();
3646
+ let verify_context = begin_read(&db, &schema, "app", verify_auth).unwrap();
3647
+ let verify_query = CanonicalQuery {
3648
+ collection: "incidents".into(),
3649
+ filter: None,
3650
+ order_by: vec![],
3651
+ limit: Some(100),
3652
+ offset: 0,
3653
+ cursor: None,
3654
+ projection: vec![],
3655
+ group_by: vec![],
3656
+ aggregates: vec![],
3657
+ references: vec![],
3658
+ };
3659
+
3660
+ let verify_result = execute_query(&db, &schema, &verify_context, &verify_query, None).unwrap();
3661
+ assert_eq!(
3662
+ verify_result.records.len(), 1,
3663
+ "Only initial record should exist; denied transaction must not partially commit"
3664
+ );
3665
+ }
3666
+
3667
+ #[test]
3668
+ fn policy_enforcement_capability_must_match_operation() {
3669
+ // Test: Having state:read doesn't allow writes
3670
+ let db = db("policy_cap_mismatch");
3671
+ let schema = schema();
3672
+
3673
+ let read_only_context = AuthorizationContext {
3674
+ subject: "user:charlie".into(),
3675
+ tenant_id: "tenant".into(),
3676
+ application_id: "app".into(),
3677
+ revision_id: "rev".into(),
3678
+ capabilities: ["state:read".into()].into(), // Only read capability
3679
+ readable_collections: Default::default(),
3680
+ writable_collections: Default::default(),
3681
+ field_projections: Default::default(),
3682
+ };
3683
+
3684
+ let write_request = TransactionRequest {
3685
+ transaction_id: None,
3686
+ tenant_id: "tenant".into(),
3687
+ application_id: "app".into(),
3688
+ revision_id: "rev".into(),
3689
+ schema_version: 1,
3690
+ state_namespace: None,
3691
+ causal_parent: None,
3692
+ authorization: read_only_context,
3693
+ operations: vec![TransactionOperation {
3694
+ kind: TransactionOperationKind::Insert,
3695
+ collection: "incidents".into(),
3696
+ id: "inc-1".into(),
3697
+ value: serde_json::json!({"title": "Test"}),
3698
+ if_version: None,
3699
+ }],
3700
+ preconditions: vec![],
3701
+ };
3702
+
3703
+ let result = execute_transaction(&db, &schema, &write_request, None);
3704
+ assert!(
3705
+ result.is_err(),
3706
+ "Write must be denied even with read capability"
3707
+ );
3708
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3709
+ }
3710
+
3711
+ #[test]
3712
+ fn member_policy_read_allowed_when_member() {
3713
+ // Test: Member policy allows reads when actor is a member of the org
3714
+ let db = db("member_policy_read_allow");
3715
+ let schema = schema();
3716
+
3717
+ // Pre-populate a record with organization_id
3718
+ let insert_context = AuthorizationContext {
3719
+ subject: "user:admin".into(),
3720
+ tenant_id: "tenant".into(),
3721
+ application_id: "app".into(),
3722
+ revision_id: "rev".into(),
3723
+ capabilities: ["state:write".into()].into(),
3724
+ readable_collections: Default::default(),
3725
+ writable_collections: Default::default(),
3726
+ field_projections: Default::default(),
3727
+ };
3728
+
3729
+ let insert_request = TransactionRequest {
3730
+ transaction_id: None,
3731
+ tenant_id: "tenant".into(),
3732
+ application_id: "app".into(),
3733
+ revision_id: "rev".into(),
3734
+ schema_version: 1,
3735
+ state_namespace: None,
3736
+ causal_parent: None,
3737
+ authorization: insert_context,
3738
+ operations: vec![TransactionOperation {
3739
+ kind: TransactionOperationKind::Insert,
3740
+ collection: "incidents".into(),
3741
+ id: "proj-1".into(),
3742
+ value: serde_json::json!({"title": "Test Project"}),
3743
+ if_version: None,
3744
+ }],
3745
+ preconditions: vec![],
3746
+ };
3747
+
3748
+ let _ = execute_transaction(&db, &schema, &insert_request, None);
3749
+
3750
+ // Now test member policy read with capability
3751
+ let member_context = AuthorizationContext {
3752
+ subject: "user:alice".into(),
3753
+ tenant_id: "tenant".into(),
3754
+ application_id: "app".into(),
3755
+ revision_id: "rev".into(),
3756
+ capabilities: ["state:read".into()].into(),
3757
+ readable_collections: Default::default(),
3758
+ writable_collections: Default::default(),
3759
+ field_projections: Default::default(),
3760
+ };
3761
+
3762
+ let read_context = begin_read(&db, &schema, "app", member_context).unwrap();
3763
+ let query = CanonicalQuery {
3764
+ collection: "incidents".into(),
3765
+ filter: None,
3766
+ order_by: vec![],
3767
+ limit: Some(100),
3768
+ offset: 0,
3769
+ cursor: None,
3770
+ projection: vec![],
3771
+ group_by: vec![],
3772
+ aggregates: vec![],
3773
+ references: vec![],
3774
+ };
3775
+
3776
+ let result = execute_query(&db, &schema, &read_context, &query, None);
3777
+ assert!(result.is_ok(), "Member policy read should be allowed with state:read");
3778
+ }
3779
+
3780
+ #[test]
3781
+ fn member_policy_read_denied_without_capability() {
3782
+ // Test: Member policy denies reads without state:read capability
3783
+ let db = db("member_policy_read_deny");
3784
+ let schema = schema();
3785
+
3786
+ let empty_context = AuthorizationContext {
3787
+ subject: "user:bob".into(),
3788
+ tenant_id: "tenant".into(),
3789
+ application_id: "app".into(),
3790
+ revision_id: "rev".into(),
3791
+ capabilities: BTreeSet::new(), // No capabilities
3792
+ readable_collections: Default::default(),
3793
+ writable_collections: Default::default(),
3794
+ field_projections: Default::default(),
3795
+ };
3796
+
3797
+ let read_context = begin_read(&db, &schema, "app", empty_context).unwrap();
3798
+ let query = CanonicalQuery {
3799
+ collection: "incidents".into(),
3800
+ filter: None,
3801
+ order_by: vec![],
3802
+ limit: Some(100),
3803
+ offset: 0,
3804
+ cursor: None,
3805
+ projection: vec![],
3806
+ group_by: vec![],
3807
+ aggregates: vec![],
3808
+ references: vec![],
3809
+ };
3810
+
3811
+ let result = execute_query(&db, &schema, &read_context, &query, None);
3812
+ assert!(
3813
+ result.is_err(),
3814
+ "Member policy read must be denied without state:read"
3815
+ );
3816
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3817
+ }
3818
+
3819
+ #[test]
3820
+ fn member_policy_write_allowed_when_member() {
3821
+ // Test: Member policy allows writes when actor is a member of the org
3822
+ let db = db("member_policy_write_allow");
3823
+ let schema = schema();
3824
+
3825
+ let member_context = AuthorizationContext {
3826
+ subject: "user:alice".into(),
3827
+ tenant_id: "tenant".into(),
3828
+ application_id: "app".into(),
3829
+ revision_id: "rev".into(),
3830
+ capabilities: ["state:write".into()].into(),
3831
+ readable_collections: Default::default(),
3832
+ writable_collections: Default::default(),
3833
+ field_projections: Default::default(),
3834
+ };
3835
+
3836
+ let write_request = TransactionRequest {
3837
+ transaction_id: None,
3838
+ tenant_id: "tenant".into(),
3839
+ application_id: "app".into(),
3840
+ revision_id: "rev".into(),
3841
+ schema_version: 1,
3842
+ state_namespace: None,
3843
+ causal_parent: None,
3844
+ authorization: member_context,
3845
+ operations: vec![TransactionOperation {
3846
+ kind: TransactionOperationKind::Insert,
3847
+ collection: "incidents".into(),
3848
+ id: "inc-member-1".into(),
3849
+ value: serde_json::json!({"title": "Member Project"}),
3850
+ if_version: None,
3851
+ }],
3852
+ preconditions: vec![],
3853
+ };
3854
+
3855
+ let result = execute_transaction(&db, &schema, &write_request, None);
3856
+ assert!(
3857
+ result.is_ok(),
3858
+ "Member policy write should be allowed with state:write"
3859
+ );
3860
+ }
3861
+
3862
+ #[test]
3863
+ fn member_policy_write_denied_without_capability() {
3864
+ // Test: Member policy denies writes without state:write capability
3865
+ let db = db("member_policy_write_deny");
3866
+ let schema = schema();
3867
+
3868
+ let empty_context = AuthorizationContext {
3869
+ subject: "user:bob".into(),
3870
+ tenant_id: "tenant".into(),
3871
+ application_id: "app".into(),
3872
+ revision_id: "rev".into(),
3873
+ capabilities: BTreeSet::new(), // No capabilities
3874
+ readable_collections: Default::default(),
3875
+ writable_collections: Default::default(),
3876
+ field_projections: Default::default(),
3877
+ };
3878
+
3879
+ let write_request = TransactionRequest {
3880
+ transaction_id: None,
3881
+ tenant_id: "tenant".into(),
3882
+ application_id: "app".into(),
3883
+ revision_id: "rev".into(),
3884
+ schema_version: 1,
3885
+ state_namespace: None,
3886
+ causal_parent: None,
3887
+ authorization: empty_context,
3888
+ operations: vec![TransactionOperation {
3889
+ kind: TransactionOperationKind::Insert,
3890
+ collection: "incidents".into(),
3891
+ id: "inc-member-2".into(),
3892
+ value: serde_json::json!({"title": "Denied Project"}),
3893
+ if_version: None,
3894
+ }],
3895
+ preconditions: vec![],
3896
+ };
3897
+
3898
+ let result = execute_transaction(&db, &schema, &write_request, None);
3899
+ assert!(
3900
+ result.is_err(),
3901
+ "Member policy write must be denied without state:write"
3902
+ );
3903
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3904
+ }
3905
+
3906
+ #[test]
3907
+ fn member_policy_cross_org_mutation_prevented() {
3908
+ // Test: Cross-organization mutation attack is prevented by lack of state:write
3909
+ // Scenario: user:alice is member of org-1 but tries to create/modify resource in org-2
3910
+ // Expected: Denied because server wouldn't grant state:write capability for org-2
3911
+ let db = db("member_policy_cross_org");
3912
+ let schema = schema();
3913
+
3914
+ // Admin inserts record in org-2
3915
+ let admin_context = AuthorizationContext {
3916
+ subject: "user:admin".into(),
3917
+ tenant_id: "tenant".into(),
3918
+ application_id: "app".into(),
3919
+ revision_id: "rev".into(),
3920
+ capabilities: ["state:write".into()].into(),
3921
+ readable_collections: Default::default(),
3922
+ writable_collections: Default::default(),
3923
+ field_projections: Default::default(),
3924
+ };
3925
+
3926
+ let insert_in_org2 = TransactionRequest {
3927
+ transaction_id: None,
3928
+ tenant_id: "tenant".into(),
3929
+ application_id: "app".into(),
3930
+ revision_id: "rev".into(),
3931
+ schema_version: 1,
3932
+ state_namespace: None,
3933
+ causal_parent: None,
3934
+ authorization: admin_context,
3935
+ operations: vec![TransactionOperation {
3936
+ kind: TransactionOperationKind::Insert,
3937
+ collection: "incidents".into(),
3938
+ id: "inc-org2-1".into(),
3939
+ value: serde_json::json!({"title": "Org2 Resource"}),
3940
+ if_version: None,
3941
+ }],
3942
+ preconditions: vec![],
3943
+ };
3944
+
3945
+ let _ = execute_transaction(&db, &schema, &insert_in_org2, None);
3946
+
3947
+ // Now user:alice (member of org-1 only) tries to modify org-2 resource
3948
+ // Without state:write capability (which would only be granted for org-1),
3949
+ // the mutation is denied
3950
+ let alice_context = AuthorizationContext {
3951
+ subject: "user:alice".into(),
3952
+ tenant_id: "tenant".into(),
3953
+ application_id: "app".into(),
3954
+ revision_id: "rev".into(),
3955
+ capabilities: BTreeSet::new(), // No state:write for org-2
3956
+ readable_collections: Default::default(),
3957
+ writable_collections: Default::default(),
3958
+ field_projections: Default::default(),
3959
+ };
3960
+
3961
+ let cross_org_mutation = TransactionRequest {
3962
+ transaction_id: None,
3963
+ tenant_id: "tenant".into(),
3964
+ application_id: "app".into(),
3965
+ revision_id: "rev".into(),
3966
+ schema_version: 1,
3967
+ state_namespace: None,
3968
+ causal_parent: None,
3969
+ authorization: alice_context,
3970
+ operations: vec![TransactionOperation {
3971
+ kind: TransactionOperationKind::Update,
3972
+ collection: "incidents".into(),
3973
+ id: "inc-org2-1".into(),
3974
+ value: serde_json::json!({"title": "Hacked by alice"}),
3975
+ if_version: Some(0),
3976
+ }],
3977
+ preconditions: vec![],
3978
+ };
3979
+
3980
+ let result = execute_transaction(&db, &schema, &cross_org_mutation, None);
3981
+ assert!(
3982
+ result.is_err(),
3983
+ "Cross-org mutation must be denied (member of org-1 only)"
3984
+ );
3985
+ assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
3986
+ }
3987
+
3988
+ #[test]
3989
+ fn record_authorization_owner_policy_filters_owned_records() {
3990
+ let db = db("record_auth_owner_filter");
3991
+ let schema = schema_with_owner();
3992
+
3993
+ let alice_context = AuthorizationContext {
3994
+ subject: "user:alice".into(),
3995
+ tenant_id: "tenant".into(),
3996
+ application_id: "app".into(),
3997
+ revision_id: "rev".into(),
3998
+ capabilities: ["state:read".into()].into(),
3999
+ readable_collections: Default::default(),
4000
+ writable_collections: Default::default(),
4001
+ field_projections: Default::default(),
4002
+ };
4003
+
4004
+ let bob_context = AuthorizationContext {
4005
+ subject: "user:bob".into(),
4006
+ tenant_id: "tenant".into(),
4007
+ application_id: "app".into(),
4008
+ revision_id: "rev".into(),
4009
+ capabilities: ["state:read".into()].into(),
4010
+ readable_collections: Default::default(),
4011
+ writable_collections: Default::default(),
4012
+ field_projections: Default::default(),
4013
+ };
4014
+
4015
+ let admin_context = AuthorizationContext {
4016
+ subject: "user:admin".into(),
4017
+ tenant_id: "tenant".into(),
4018
+ application_id: "app".into(),
4019
+ revision_id: "rev".into(),
4020
+ capabilities: ["state:write".into(), "state:read".into()].into(),
4021
+ readable_collections: Default::default(),
4022
+ writable_collections: Default::default(),
4023
+ field_projections: Default::default(),
4024
+ };
4025
+
4026
+ let insert_records = TransactionRequest {
4027
+ transaction_id: None,
4028
+ tenant_id: "tenant".into(),
4029
+ application_id: "app".into(),
4030
+ revision_id: "rev".into(),
4031
+ schema_version: 1,
4032
+ state_namespace: None,
4033
+ causal_parent: None,
4034
+ authorization: admin_context.clone(),
4035
+ operations: vec![
4036
+ TransactionOperation {
4037
+ kind: TransactionOperationKind::Insert,
4038
+ collection: "incidents".into(),
4039
+ id: "inc-alice-1".into(),
4040
+ value: serde_json::json!({"title": "Alice 1", "owner_id": "alice"}),
4041
+ if_version: None,
4042
+ },
4043
+ TransactionOperation {
4044
+ kind: TransactionOperationKind::Insert,
4045
+ collection: "incidents".into(),
4046
+ id: "inc-bob-1".into(),
4047
+ value: serde_json::json!({"title": "Bob 1", "owner_id": "bob"}),
4048
+ if_version: None,
4049
+ },
4050
+ TransactionOperation {
4051
+ kind: TransactionOperationKind::Insert,
4052
+ collection: "incidents".into(),
4053
+ id: "inc-alice-2".into(),
4054
+ value: serde_json::json!({"title": "Alice 2", "owner_id": "alice"}),
4055
+ if_version: None,
4056
+ },
4057
+ ],
4058
+ preconditions: vec![],
4059
+ };
4060
+
4061
+ execute_transaction(&db, &schema, &insert_records, None).expect("insert should succeed");
4062
+
4063
+ let alice_read = begin_read(&db, &schema, "app", alice_context).unwrap();
4064
+ let query = CanonicalQuery {
4065
+ collection: "incidents".into(),
4066
+ filter: None,
4067
+ order_by: vec![],
4068
+ limit: None,
4069
+ offset: 0,
4070
+ cursor: None,
4071
+ projection: vec!["title".into(), "owner_id".into()],
4072
+ group_by: vec![],
4073
+ aggregates: vec![],
4074
+ references: vec![],
4075
+ };
4076
+
4077
+ let alice_result = execute_query(
4078
+ &db,
4079
+ &schema,
4080
+ &alice_read,
4081
+ &query,
4082
+ Some(PolicySubject::Owner),
4083
+ )
4084
+ .expect("query should succeed");
4085
+
4086
+ assert_eq!(
4087
+ alice_result.records.len(),
4088
+ 2,
4089
+ "Alice should see only 2 incidents she owns"
4090
+ );
4091
+ for record in &alice_result.records {
4092
+ assert_eq!(record.get("owner_id").unwrap(), "alice");
4093
+ }
4094
+
4095
+ let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
4096
+ let bob_result = execute_query(
4097
+ &db,
4098
+ &schema,
4099
+ &bob_read,
4100
+ &query,
4101
+ Some(PolicySubject::Owner),
4102
+ )
4103
+ .expect("query should succeed");
4104
+
4105
+ assert_eq!(
4106
+ bob_result.records.len(),
4107
+ 1,
4108
+ "Bob should see only 1 incident he owns"
4109
+ );
4110
+ assert_eq!(bob_result.records[0].get("owner_id").unwrap(), "bob");
4111
+ }
4112
+
4113
+ #[test]
4114
+ fn record_authorization_owner_policy_denies_direct_access() {
4115
+ let db = db("record_auth_owner_direct");
4116
+ let schema = schema_with_owner();
4117
+
4118
+ let admin_context = AuthorizationContext {
4119
+ subject: "user:admin".into(),
4120
+ tenant_id: "tenant".into(),
4121
+ application_id: "app".into(),
4122
+ revision_id: "rev".into(),
4123
+ capabilities: ["state:write".into(), "state:read".into()].into(),
4124
+ readable_collections: Default::default(),
4125
+ writable_collections: Default::default(),
4126
+ field_projections: Default::default(),
4127
+ };
4128
+
4129
+ let bob_context = AuthorizationContext {
4130
+ subject: "user:bob".into(),
4131
+ tenant_id: "tenant".into(),
4132
+ application_id: "app".into(),
4133
+ revision_id: "rev".into(),
4134
+ capabilities: ["state:read".into()].into(),
4135
+ readable_collections: Default::default(),
4136
+ writable_collections: Default::default(),
4137
+ field_projections: Default::default(),
4138
+ };
4139
+
4140
+ let insert_request = TransactionRequest {
4141
+ transaction_id: None,
4142
+ tenant_id: "tenant".into(),
4143
+ application_id: "app".into(),
4144
+ revision_id: "rev".into(),
4145
+ schema_version: 1,
4146
+ state_namespace: None,
4147
+ causal_parent: None,
4148
+ authorization: admin_context,
4149
+ operations: vec![TransactionOperation {
4150
+ kind: TransactionOperationKind::Insert,
4151
+ collection: "incidents".into(),
4152
+ id: "inc-alice-secret".into(),
4153
+ value: serde_json::json!({"title": "Secret", "owner_id": "alice"}),
4154
+ if_version: None,
4155
+ }],
4156
+ preconditions: vec![],
4157
+ };
4158
+
4159
+ execute_transaction(&db, &schema, &insert_request, None).expect("insert should succeed");
4160
+
4161
+ let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
4162
+ let direct_query = CanonicalQuery {
4163
+ collection: "incidents".into(),
4164
+ filter: Some(QueryFilter::Eq {
4165
+ field: "_id".into(),
4166
+ value: Value::String("inc-alice-secret".into()),
4167
+ }),
4168
+ order_by: vec![],
4169
+ limit: None,
4170
+ offset: 0,
4171
+ cursor: None,
4172
+ projection: vec!["title".into(), "owner_id".into()],
4173
+ group_by: vec![],
4174
+ aggregates: vec![],
4175
+ references: vec![],
4176
+ };
4177
+
4178
+ let result = execute_query(
4179
+ &db,
4180
+ &schema,
4181
+ &bob_read,
4182
+ &direct_query,
4183
+ Some(PolicySubject::Owner),
4184
+ )
4185
+ .expect("query should succeed");
4186
+
4187
+ assert_eq!(
4188
+ result.records.len(),
4189
+ 0,
4190
+ "Bob should not see Alice's record even with direct _id filter"
4191
+ );
4192
+ }
4193
+
4194
+ #[test]
4195
+ fn record_authorization_authenticated_policy_allows_all() {
4196
+ let db = db("record_auth_authenticated");
4197
+ let schema = schema();
4198
+
4199
+ let alice_context = AuthorizationContext {
4200
+ subject: "user:alice".into(),
4201
+ tenant_id: "tenant".into(),
4202
+ application_id: "app".into(),
4203
+ revision_id: "rev".into(),
4204
+ capabilities: ["state:write".into(), "state:read".into()].into(),
4205
+ readable_collections: Default::default(),
4206
+ writable_collections: Default::default(),
4207
+ field_projections: Default::default(),
4208
+ };
4209
+
4210
+ let bob_context = AuthorizationContext {
4211
+ subject: "user:bob".into(),
4212
+ tenant_id: "tenant".into(),
4213
+ application_id: "app".into(),
4214
+ revision_id: "rev".into(),
4215
+ capabilities: ["state:read".into()].into(),
4216
+ readable_collections: Default::default(),
4217
+ writable_collections: Default::default(),
4218
+ field_projections: Default::default(),
4219
+ };
4220
+
4221
+ let insert_records = TransactionRequest {
4222
+ transaction_id: None,
4223
+ tenant_id: "tenant".into(),
4224
+ application_id: "app".into(),
4225
+ revision_id: "rev".into(),
4226
+ schema_version: 1,
4227
+ state_namespace: None,
4228
+ causal_parent: None,
4229
+ authorization: alice_context,
4230
+ operations: vec![
4231
+ TransactionOperation {
4232
+ kind: TransactionOperationKind::Insert,
4233
+ collection: "incidents".into(),
4234
+ id: "inc-alice-auth".into(),
4235
+ value: serde_json::json!({"title": "Alice's incident"}),
4236
+ if_version: None,
4237
+ },
4238
+ TransactionOperation {
4239
+ kind: TransactionOperationKind::Insert,
4240
+ collection: "incidents".into(),
4241
+ id: "inc-bob-auth".into(),
4242
+ value: serde_json::json!({"title": "Bob's incident"}),
4243
+ if_version: None,
4244
+ },
4245
+ ],
4246
+ preconditions: vec![],
4247
+ };
4248
+
4249
+ execute_transaction(&db, &schema, &insert_records, None).expect("insert should succeed");
4250
+
4251
+ let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
4252
+ let query = CanonicalQuery {
4253
+ collection: "incidents".into(),
4254
+ filter: None,
4255
+ order_by: vec![],
4256
+ limit: None,
4257
+ offset: 0,
4258
+ cursor: None,
4259
+ projection: vec!["title".into()],
4260
+ group_by: vec![],
4261
+ aggregates: vec![],
4262
+ references: vec![],
4263
+ };
4264
+
4265
+ let result = execute_query(
4266
+ &db,
4267
+ &schema,
4268
+ &bob_read,
4269
+ &query,
4270
+ Some(PolicySubject::Authenticated),
4271
+ )
4272
+ .expect("query should succeed");
4273
+
4274
+ assert_eq!(
4275
+ result.records.len(),
4276
+ 2,
4277
+ "Authenticated policy should allow access to all records for authenticated actor"
4278
+ );
4279
+ }
4280
+
4281
+ #[test]
4282
+ fn record_authorization_pagination_respects_authorization() {
4283
+ let db = db("record_auth_pagination");
4284
+ let schema = schema_with_owner();
4285
+
4286
+ let admin_context = AuthorizationContext {
4287
+ subject: "user:admin".into(),
4288
+ tenant_id: "tenant".into(),
4289
+ application_id: "app".into(),
4290
+ revision_id: "rev".into(),
4291
+ capabilities: ["state:write".into(), "state:read".into()].into(),
4292
+ readable_collections: Default::default(),
4293
+ writable_collections: Default::default(),
4294
+ field_projections: Default::default(),
4295
+ };
4296
+
4297
+ let alice_context = AuthorizationContext {
4298
+ subject: "user:alice".into(),
4299
+ tenant_id: "tenant".into(),
4300
+ application_id: "app".into(),
4301
+ revision_id: "rev".into(),
4302
+ capabilities: ["state:read".into()].into(),
4303
+ readable_collections: Default::default(),
4304
+ writable_collections: Default::default(),
4305
+ field_projections: Default::default(),
4306
+ };
4307
+
4308
+ let mut ops = vec![];
4309
+ for i in 0..5 {
4310
+ ops.push(TransactionOperation {
4311
+ kind: TransactionOperationKind::Insert,
4312
+ collection: "incidents".into(),
4313
+ id: format!("incident-{}", i),
4314
+ value: serde_json::json!({
4315
+ "title": format!("Incident {}", i),
4316
+ "owner_id": if i % 2 == 0 { "alice" } else { "bob" }
4317
+ }),
4318
+ if_version: None,
4319
+ });
4320
+ }
4321
+
4322
+ let insert_request = TransactionRequest {
4323
+ transaction_id: None,
4324
+ tenant_id: "tenant".into(),
4325
+ application_id: "app".into(),
4326
+ revision_id: "rev".into(),
4327
+ schema_version: 1,
4328
+ state_namespace: None,
4329
+ causal_parent: None,
4330
+ authorization: admin_context,
4331
+ operations: ops,
4332
+ preconditions: vec![],
4333
+ };
4334
+
4335
+ execute_transaction(&db, &schema, &insert_request, None).expect("insert should succeed");
4336
+
4337
+ let alice_read = begin_read(&db, &schema, "app", alice_context).unwrap();
4338
+ let query_no_limit = CanonicalQuery {
4339
+ collection: "incidents".into(),
4340
+ filter: None,
4341
+ order_by: vec![QueryOrder {
4342
+ field: "_id".into(),
4343
+ direction: SortDirection::Asc,
4344
+ }],
4345
+ limit: None,
4346
+ offset: 0,
4347
+ cursor: None,
4348
+ projection: vec!["title".into(), "owner_id".into()],
4349
+ group_by: vec![],
4350
+ aggregates: vec![],
4351
+ references: vec![],
4352
+ };
4353
+
4354
+ let result = execute_query(
4355
+ &db,
4356
+ &schema,
4357
+ &alice_read,
4358
+ &query_no_limit,
4359
+ Some(PolicySubject::Owner),
4360
+ )
4361
+ .expect("query should succeed");
4362
+
4363
+ assert_eq!(
4364
+ result.records.len(),
4365
+ 3,
4366
+ "Alice should see 3 incidents she owns (0, 2, 4)"
4367
+ );
4368
+ for record in &result.records {
4369
+ assert_eq!(record.get("owner_id").unwrap(), "alice");
4370
+ }
4371
+ }
3118
4372
  }