@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.
- package/dist/cli/index.js +1 -1
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +93 -0
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +787 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +139 -0
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +1669 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +1269 -15
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +406 -0
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +908 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +1028 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +332 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +342 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +313 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +90 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +571 -0
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +506 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +81 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +434 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +130 -23
- package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +2 -2
- package/dist/db.d.ts +10 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +3 -1
- package/dist/file-db.d.ts +69 -0
- package/dist/file-db.d.ts.map +1 -0
- package/dist/file-db.js +355 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/studio-app/assets/{feltdb_wasm-CJEJryDx.js → feltdb_wasm-CBGD0zRu.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C6ATF9mJ.wasm +0 -0
- package/dist/studio-app/assets/{index-D_p8T7nO.js → index-D74dfBgZ.js} +9 -9
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +7 -2
- 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
|
|
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
|
}
|