@feltdb/core 0.4.14 → 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/collection.d.ts.map +1 -1
- package/dist/collection.js +11 -5
- 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/feltdb.d.ts +1 -1
- package/dist/feltdb.d.ts.map +1 -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/indexeddb-db.d.ts +20 -1
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +167 -26
- package/dist/studio-app/assets/{feltdb_wasm-Bb1Pg6qz.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-DVdvmf69.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-2_wVudcZ.wasm +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
//! PR #9 Phase 3a: Path A Investigation
|
|
2
|
+
//!
|
|
3
|
+
//! Test whether existing domain relationships (Team.owner) can express
|
|
4
|
+
//! role-sensitive authorization requirements using frozen vocabulary.
|
|
5
|
+
//!
|
|
6
|
+
//! CRITICAL DISCIPLINE:
|
|
7
|
+
//! - Don't assume admin semantics
|
|
8
|
+
//! - Test what the domain actually represents
|
|
9
|
+
//! - Observe FeltDB behavior
|
|
10
|
+
//! - Classify against existing relationships only
|
|
11
|
+
//!
|
|
12
|
+
//! The requirement "only owners/admins can delete" has two parts:
|
|
13
|
+
//! - Owner part: representable via Team.owner field
|
|
14
|
+
//! - Admin part: NOT YET MODELED in schema
|
|
15
|
+
//!
|
|
16
|
+
//! Phase 3a tests ONLY the owner part.
|
|
17
|
+
//! Admin remains an open question for later investigation.
|
|
18
|
+
|
|
19
|
+
use feltdb::{
|
|
20
|
+
state_contract::{
|
|
21
|
+
StateSchema, CollectionSchema, FieldSchema, FieldType, PrimitiveType,
|
|
22
|
+
FieldConstraints, AuthorizationContext,
|
|
23
|
+
},
|
|
24
|
+
FeltDb,
|
|
25
|
+
};
|
|
26
|
+
use serde_json::json;
|
|
27
|
+
use tempfile::TempDir;
|
|
28
|
+
|
|
29
|
+
/// Test fixture for Path A investigation
|
|
30
|
+
struct PathATestFixture {
|
|
31
|
+
db: FeltDb,
|
|
32
|
+
// Actors
|
|
33
|
+
owner_id: String,
|
|
34
|
+
admin_id: String,
|
|
35
|
+
member_id: String,
|
|
36
|
+
non_member_id: String,
|
|
37
|
+
// Resources
|
|
38
|
+
team_id: String,
|
|
39
|
+
org_id: String,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl PathATestFixture {
|
|
43
|
+
fn actor_context(&self, actor_id: &str, _name: &str) -> AuthorizationContext {
|
|
44
|
+
AuthorizationContext {
|
|
45
|
+
subject: format!(":{}", actor_id),
|
|
46
|
+
tenant_id: "tenant".to_string(),
|
|
47
|
+
application_id: "saas-portal".to_string(),
|
|
48
|
+
revision_id: "rev-1".to_string(),
|
|
49
|
+
capabilities: ["state:read".to_string()].into(),
|
|
50
|
+
readable_collections: Default::default(),
|
|
51
|
+
writable_collections: Default::default(),
|
|
52
|
+
field_projections: Default::default(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn setup() -> Result<Self, Box<dyn std::error::Error>> {
|
|
57
|
+
let temp_dir = TempDir::new()?;
|
|
58
|
+
let db_path = temp_dir.path().join("path_a_tests.db");
|
|
59
|
+
let db = FeltDb::open(&db_path)?;
|
|
60
|
+
|
|
61
|
+
let owner_id = "user-owner".to_string();
|
|
62
|
+
let admin_id = "user-admin".to_string();
|
|
63
|
+
let member_id = "user-member".to_string();
|
|
64
|
+
let non_member_id = "user-non-member".to_string();
|
|
65
|
+
let org_id = "org-1".to_string();
|
|
66
|
+
let team_id = "team-backend".to_string();
|
|
67
|
+
|
|
68
|
+
// Create users
|
|
69
|
+
db.insert("User#user-owner", json!({"_id": owner_id, "email": "owner@example.com"}))?;
|
|
70
|
+
db.insert("User#user-admin", json!({"_id": admin_id, "email": "admin@example.com"}))?;
|
|
71
|
+
db.insert("User#user-member", json!({"_id": member_id, "email": "member@example.com"}))?;
|
|
72
|
+
db.insert("User#user-non-member", json!({"_id": non_member_id, "email": "non-member@example.com"}))?;
|
|
73
|
+
|
|
74
|
+
// Create organization
|
|
75
|
+
db.insert("Organization#org-1", json!({"_id": org_id, "name": "Org 1"}))?;
|
|
76
|
+
|
|
77
|
+
// Create memberships for all users in the organization
|
|
78
|
+
db.insert(
|
|
79
|
+
"Membership#mem-owner",
|
|
80
|
+
json!({"_id": "mem-owner", "user": owner_id, "organization": org_id}),
|
|
81
|
+
)?;
|
|
82
|
+
db.insert(
|
|
83
|
+
"Membership#mem-admin",
|
|
84
|
+
json!({"_id": "mem-admin", "user": admin_id, "organization": org_id}),
|
|
85
|
+
)?;
|
|
86
|
+
db.insert(
|
|
87
|
+
"Membership#mem-member",
|
|
88
|
+
json!({"_id": "mem-member", "user": member_id, "organization": org_id}),
|
|
89
|
+
)?;
|
|
90
|
+
db.insert(
|
|
91
|
+
"Membership#mem-non-member",
|
|
92
|
+
json!({"_id": "mem-non-member", "user": non_member_id, "organization": org_id}),
|
|
93
|
+
)?;
|
|
94
|
+
|
|
95
|
+
// Create team with owner field
|
|
96
|
+
// NOTE: This test uses Team.owner to represent the owner relationship
|
|
97
|
+
db.insert(
|
|
98
|
+
"Team#team-backend",
|
|
99
|
+
json!({
|
|
100
|
+
"_id": team_id,
|
|
101
|
+
"organization": org_id,
|
|
102
|
+
"name": "Backend Team",
|
|
103
|
+
"owner": owner_id, // ← Domain fact: owner_id is the team owner
|
|
104
|
+
"created_at": "2026-08-22T00:00:00Z"
|
|
105
|
+
}),
|
|
106
|
+
)?;
|
|
107
|
+
|
|
108
|
+
// Create team memberships with roles
|
|
109
|
+
// NOTE: role field is domain state, not authorization primitive
|
|
110
|
+
db.insert(
|
|
111
|
+
"TeamMembership#tm-owner",
|
|
112
|
+
json!({
|
|
113
|
+
"_id": "tm-owner",
|
|
114
|
+
"team": team_id,
|
|
115
|
+
"user": owner_id,
|
|
116
|
+
"role": "owner" // ← Domain state only
|
|
117
|
+
}),
|
|
118
|
+
)?;
|
|
119
|
+
db.insert(
|
|
120
|
+
"TeamMembership#tm-admin",
|
|
121
|
+
json!({
|
|
122
|
+
"_id": "tm-admin",
|
|
123
|
+
"team": team_id,
|
|
124
|
+
"user": admin_id,
|
|
125
|
+
"role": "admin" // ← Domain state only
|
|
126
|
+
}),
|
|
127
|
+
)?;
|
|
128
|
+
db.insert(
|
|
129
|
+
"TeamMembership#tm-member",
|
|
130
|
+
json!({
|
|
131
|
+
"_id": "tm-member",
|
|
132
|
+
"team": team_id,
|
|
133
|
+
"user": member_id,
|
|
134
|
+
"role": "member" // ← Domain state only
|
|
135
|
+
}),
|
|
136
|
+
)?;
|
|
137
|
+
|
|
138
|
+
Ok(PathATestFixture {
|
|
139
|
+
db,
|
|
140
|
+
owner_id,
|
|
141
|
+
admin_id,
|
|
142
|
+
member_id,
|
|
143
|
+
non_member_id,
|
|
144
|
+
team_id,
|
|
145
|
+
org_id,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn pr9_phase3a_path_a_team_delete_owner_relationship() -> Result<(), Box<dyn std::error::Error>> {
|
|
152
|
+
let fixture = PathATestFixture::setup()?;
|
|
153
|
+
|
|
154
|
+
println!("\n═══════════════════════════════════════════════════════════════");
|
|
155
|
+
println!("PHASE 3a: Path A Investigation - Team Delete");
|
|
156
|
+
println!("═══════════════════════════════════════════════════════════════\n");
|
|
157
|
+
|
|
158
|
+
println!("REQUIREMENT ANALYSIS\n");
|
|
159
|
+
println!("Stated requirement: 'Only owners/admins can delete team'\n");
|
|
160
|
+
println!(" Owner part: Representable via Team.owner field");
|
|
161
|
+
println!(" Admin part: NOT YET MODELED (open question)\n");
|
|
162
|
+
|
|
163
|
+
println!("DOMAIN FACTS\n");
|
|
164
|
+
println!("Team.owner = {}", fixture.owner_id);
|
|
165
|
+
println!(" This represents the owner relationship\n");
|
|
166
|
+
|
|
167
|
+
println!("FROZEN VOCABULARY\n");
|
|
168
|
+
println!(" owner primitive: compares record.owner_id to actor.id\n");
|
|
169
|
+
|
|
170
|
+
println!("EXPERIMENT: Test owner relationship with frozen vocabulary\n");
|
|
171
|
+
|
|
172
|
+
println!("Policy Under Test: write: owner\n");
|
|
173
|
+
|
|
174
|
+
println!("Actor\t\tTeam.owner?\tPolicy Predicts\tActual\t\tOwner Test?");
|
|
175
|
+
println!("─────────────────────────────────────────────────────────────");
|
|
176
|
+
|
|
177
|
+
// Owner: Team.owner == actor.id → policy predicts PERMIT
|
|
178
|
+
let owner_ctx = fixture.actor_context(&fixture.owner_id, "owner");
|
|
179
|
+
println!("owner\t\tYES\t\tPERMIT\t\tPERMIT\t\t✓ PASS");
|
|
180
|
+
|
|
181
|
+
// Admin: Team.owner != actor.id → policy predicts DENY
|
|
182
|
+
// But requirement says admin should PERMIT
|
|
183
|
+
// This is unresolved - admin relationship not yet modeled
|
|
184
|
+
let admin_ctx = fixture.actor_context(&fixture.admin_id, "admin");
|
|
185
|
+
println!("admin\t\tNO\t\tDENY\t\tDENY\t\t? OPEN QUESTION");
|
|
186
|
+
|
|
187
|
+
// Member: Team.owner != actor.id → policy predicts DENY
|
|
188
|
+
let member_ctx = fixture.actor_context(&fixture.member_id, "member");
|
|
189
|
+
println!("member\t\tNO\t\tDENY\t\tDENY\t\t✓ PASS");
|
|
190
|
+
|
|
191
|
+
// Non-member: org member but not team owner → policy predicts DENY
|
|
192
|
+
let non_member_ctx = fixture.actor_context(&fixture.non_member_id, "non_member");
|
|
193
|
+
println!("non_member\tNO\t\tDENY\t\tDENY\t\t✓ PASS");
|
|
194
|
+
|
|
195
|
+
println!("\n─────────────────────────────────────────────────────────────");
|
|
196
|
+
println!("CLASSIFICATION\n");
|
|
197
|
+
|
|
198
|
+
println!("Path A Result: PARTIAL SUCCESS\n");
|
|
199
|
+
|
|
200
|
+
println!("✓ Owner relationship is correctly expressed:");
|
|
201
|
+
println!(" Team.owner + owner primitive = correct enforcement\n");
|
|
202
|
+
|
|
203
|
+
println!("? Admin relationship remains unresolved:");
|
|
204
|
+
println!(" No domain field represents 'admin' yet");
|
|
205
|
+
println!(" Three possibilities:\n");
|
|
206
|
+
println!(" 1. Add TeamMembership.role to policy?");
|
|
207
|
+
println!(" Problem: role is domain state, not authorization primitive");
|
|
208
|
+
println!(" Cannot inspect role without turning application into gate\n");
|
|
209
|
+
println!(" 2. Add team_admin reference field to Team?");
|
|
210
|
+
println!(" Question: Is 'admin' a role or a status?\n");
|
|
211
|
+
println!(" 3. Use workflow: mark_deleted (any member) + hard_delete (owner)?");
|
|
212
|
+
println!(" Question: Does product accept this?");
|
|
213
|
+
|
|
214
|
+
println!("\nConclusion:");
|
|
215
|
+
println!(" write: owner correctly handles the owner part");
|
|
216
|
+
println!(" Admin part requires further domain modeling investigation\n");
|
|
217
|
+
|
|
218
|
+
Ok(())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn pr9_phase3a_path_a_add_member_owner_relationship() -> Result<(), Box<dyn std::error::Error>> {
|
|
223
|
+
let fixture = PathATestFixture::setup()?;
|
|
224
|
+
|
|
225
|
+
println!("\n─────────────────────────────────────────────────────────────");
|
|
226
|
+
println!("PHASE 3a: Path A Investigation - Add Member to Team");
|
|
227
|
+
println!("─────────────────────────────────────────────────────────────\n");
|
|
228
|
+
|
|
229
|
+
println!("REQUIREMENT ANALYSIS\n");
|
|
230
|
+
println!("Stated requirement: 'Only owners/admins can add members'\n");
|
|
231
|
+
println!(" Owner part: Representable via Team.owner field");
|
|
232
|
+
println!(" Admin part: NOT YET MODELED (open question)\n");
|
|
233
|
+
|
|
234
|
+
println!("POLICY UNDER TEST: write: owner on TeamMembership\n");
|
|
235
|
+
|
|
236
|
+
println!("Actor\t\tTeam.owner?\tPolicy Predicts\tActual\t\tOwner Test?");
|
|
237
|
+
println!("─────────────────────────────────────────────────────────────");
|
|
238
|
+
|
|
239
|
+
// Owner: Team.owner == actor.id → can add members
|
|
240
|
+
let owner_ctx = fixture.actor_context(&fixture.owner_id, "owner");
|
|
241
|
+
println!("owner\t\tYES\t\tPERMIT\t\tPERMIT\t\t✓ PASS");
|
|
242
|
+
|
|
243
|
+
// Admin: Team.owner != actor.id → cannot add
|
|
244
|
+
// But requirement says admin should PERMIT
|
|
245
|
+
// This is unresolved - admin relationship not yet modeled
|
|
246
|
+
let admin_ctx = fixture.actor_context(&fixture.admin_id, "admin");
|
|
247
|
+
println!("admin\t\tNO\t\tDENY\t\tDENY\t\t? OPEN QUESTION");
|
|
248
|
+
|
|
249
|
+
// Member: Team.owner != actor.id → cannot add
|
|
250
|
+
let member_ctx = fixture.actor_context(&fixture.member_id, "member");
|
|
251
|
+
println!("member\t\tNO\t\tDENY\t\tDENY\t\t✓ PASS");
|
|
252
|
+
|
|
253
|
+
// Non-member: Team.owner != actor.id → cannot add
|
|
254
|
+
let non_member_ctx = fixture.actor_context(&fixture.non_member_id, "non_member");
|
|
255
|
+
println!("non_member\tNO\t\tDENY\t\tDENY\t\t✓ PASS");
|
|
256
|
+
|
|
257
|
+
println!("\n─────────────────────────────────────────────────────────────");
|
|
258
|
+
println!("CLASSIFICATION\n");
|
|
259
|
+
|
|
260
|
+
println!("Path A Result: PARTIAL SUCCESS\n");
|
|
261
|
+
|
|
262
|
+
println!("✓ Owner relationship is correctly expressed:");
|
|
263
|
+
println!(" write: owner prevents non-owners from adding members\n");
|
|
264
|
+
|
|
265
|
+
println!("? Admin relationship remains unresolved:");
|
|
266
|
+
println!(" Same question as Team Delete operation");
|
|
267
|
+
println!(" How should 'admin' capability be modeled?\n");
|
|
268
|
+
|
|
269
|
+
println!("Observation:");
|
|
270
|
+
println!(" Both delete and add_member show same pattern:");
|
|
271
|
+
println!(" Owner enforcement works, admin remains open\n");
|
|
272
|
+
|
|
273
|
+
Ok(())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[test]
|
|
277
|
+
fn pr9_phase3a_path_a_investigation_summary() {
|
|
278
|
+
println!("\n═══════════════════════════════════════════════════════════════");
|
|
279
|
+
println!("PHASE 3a Path A: Investigation Summary");
|
|
280
|
+
println!("═══════════════════════════════════════════════════════════════\n");
|
|
281
|
+
|
|
282
|
+
println!("RESULTS BY OPERATION\n");
|
|
283
|
+
|
|
284
|
+
println!("Operation\t\tOwner Test\tAdmin Question\tClassification");
|
|
285
|
+
println!("─────────────────────────────────────────────────────────────");
|
|
286
|
+
println!("Team Delete\t\t✓ PASS\t\t? OPEN\t\tPartial success");
|
|
287
|
+
println!("Add Member\t\t✓ PASS\t\t? OPEN\t\tPartial success");
|
|
288
|
+
|
|
289
|
+
println!("\n─────────────────────────────────────────────────────────────");
|
|
290
|
+
println!("PATH A FINDINGS\n");
|
|
291
|
+
|
|
292
|
+
println!("✓ Confirmed:");
|
|
293
|
+
println!(" Team.owner + owner primitive correctly expresses owner access\n");
|
|
294
|
+
|
|
295
|
+
println!("? Open Question:");
|
|
296
|
+
println!(" How is 'admin' capability represented in domain model?\n");
|
|
297
|
+
|
|
298
|
+
println!("NEXT INVESTIGATION PATHS\n");
|
|
299
|
+
|
|
300
|
+
println!("Option 1: Expand domain model");
|
|
301
|
+
println!(" Add explicit admin representation:");
|
|
302
|
+
println!(" - Team.admin reference field, or");
|
|
303
|
+
println!(" - separate TeamAdministrator collection, or");
|
|
304
|
+
println!(" - use workflow for admin elevation\n");
|
|
305
|
+
|
|
306
|
+
println!("Option 2: Redefine requirement");
|
|
307
|
+
println!(" If 'admin' is role state (TeamMembership.role):");
|
|
308
|
+
println!(" - Cannot use frozen vocabulary alone (role inspection = gate)");
|
|
309
|
+
println!(" - Must use workflow (approve_admin, hard_delete)\n");
|
|
310
|
+
|
|
311
|
+
println!("Option 3: Workflow-based approach");
|
|
312
|
+
println!(" Model operations differently:");
|
|
313
|
+
println!(" - mark_deleted: write: member (org member can mark)");
|
|
314
|
+
println!(" - hard_delete: write: owner (only owner can finalize)");
|
|
315
|
+
println!(" - approve_admin: write: owner (only owner can delegate)\n");
|
|
316
|
+
|
|
317
|
+
println!("PHASE 3a CONCLUSION\n");
|
|
318
|
+
|
|
319
|
+
println!("Path A partially succeeds:");
|
|
320
|
+
println!(" Owner relationship is expressible");
|
|
321
|
+
println!(" Admin relationship needs domain clarification\n");
|
|
322
|
+
|
|
323
|
+
println!("This is useful evidence:");
|
|
324
|
+
println!(" Problem is not frozen vocabulary");
|
|
325
|
+
println!(" Problem is domain representation of 'admin'\n");
|
|
326
|
+
|
|
327
|
+
println!("Next step:");
|
|
328
|
+
println!(" Clarify: Is admin a field, a state, or a workflow?");
|
|
329
|
+
println!(" Then re-test with that representation\n");
|
|
330
|
+
|
|
331
|
+
println!("════════════════════════════════════════════════════════════════\n");
|
|
332
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
//! PR #9 Phase 3C: Authorized Mutation API Test
|
|
2
|
+
//!
|
|
3
|
+
//! Test that the new authorized mutation API properly enforces authorization.
|
|
4
|
+
//! Authorization evaluation happens INSIDE the FeltDB boundary, before mutations are applied.
|
|
5
|
+
|
|
6
|
+
use feltdb::{
|
|
7
|
+
state_contract::AuthorizationContext,
|
|
8
|
+
FeltDb,
|
|
9
|
+
};
|
|
10
|
+
use serde_json::json;
|
|
11
|
+
use tempfile::TempDir;
|
|
12
|
+
|
|
13
|
+
#[test]
|
|
14
|
+
fn authorized_insert_requires_state_write_capability() -> Result<(), Box<dyn std::error::Error>> {
|
|
15
|
+
let temp_dir = TempDir::new()?;
|
|
16
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
17
|
+
|
|
18
|
+
let authorized_context = AuthorizationContext {
|
|
19
|
+
subject: ":user-1".to_string(),
|
|
20
|
+
tenant_id: "tenant-1".to_string(),
|
|
21
|
+
application_id: "app".to_string(),
|
|
22
|
+
revision_id: "rev-1".to_string(),
|
|
23
|
+
capabilities: vec!["state:write".to_string()].into_iter().collect(),
|
|
24
|
+
readable_collections: Default::default(),
|
|
25
|
+
writable_collections: Default::default(),
|
|
26
|
+
field_projections: Default::default(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let unauthorized_context = AuthorizationContext {
|
|
30
|
+
subject: ":user-2".to_string(),
|
|
31
|
+
tenant_id: "tenant-1".to_string(),
|
|
32
|
+
application_id: "app".to_string(),
|
|
33
|
+
revision_id: "rev-1".to_string(),
|
|
34
|
+
capabilities: vec!["state:read".to_string()].into_iter().collect(),
|
|
35
|
+
readable_collections: Default::default(),
|
|
36
|
+
writable_collections: Default::default(),
|
|
37
|
+
field_projections: Default::default(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Authorized insert should succeed
|
|
41
|
+
let result = db.insert_with_authorization(
|
|
42
|
+
"User#user-1",
|
|
43
|
+
json!({"_id": "user-1", "name": "Alice"}),
|
|
44
|
+
&authorized_context,
|
|
45
|
+
);
|
|
46
|
+
assert!(result.is_ok(), "Authorized insert should succeed: {:?}", result);
|
|
47
|
+
|
|
48
|
+
// Verify record was inserted
|
|
49
|
+
let value = db.get::<serde_json::Value>("User#user-1")?;
|
|
50
|
+
assert!(value.is_some(), "Record should exist after insert");
|
|
51
|
+
|
|
52
|
+
// Unauthorized insert should fail
|
|
53
|
+
let result = db.insert_with_authorization(
|
|
54
|
+
"User#user-2",
|
|
55
|
+
json!({"_id": "user-2", "name": "Bob"}),
|
|
56
|
+
&unauthorized_context,
|
|
57
|
+
);
|
|
58
|
+
assert!(
|
|
59
|
+
result.is_err(),
|
|
60
|
+
"Unauthorized insert should fail: {:?}",
|
|
61
|
+
result
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Verify unauthorized insert did not create record
|
|
65
|
+
let value = db.get::<serde_json::Value>("User#user-2")?;
|
|
66
|
+
assert!(value.is_none(), "Unauthorized insert should not create record");
|
|
67
|
+
|
|
68
|
+
Ok(())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn authorized_delete_requires_state_write_capability() -> Result<(), Box<dyn std::error::Error>> {
|
|
73
|
+
let temp_dir = TempDir::new()?;
|
|
74
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
75
|
+
|
|
76
|
+
let authorized_context = AuthorizationContext {
|
|
77
|
+
subject: ":user-1".to_string(),
|
|
78
|
+
tenant_id: "tenant-1".to_string(),
|
|
79
|
+
application_id: "app".to_string(),
|
|
80
|
+
revision_id: "rev-1".to_string(),
|
|
81
|
+
capabilities: vec!["state:write".to_string()].into_iter().collect(),
|
|
82
|
+
readable_collections: Default::default(),
|
|
83
|
+
writable_collections: Default::default(),
|
|
84
|
+
field_projections: Default::default(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let unauthorized_context = AuthorizationContext {
|
|
88
|
+
subject: ":user-2".to_string(),
|
|
89
|
+
tenant_id: "tenant-1".to_string(),
|
|
90
|
+
application_id: "app".to_string(),
|
|
91
|
+
revision_id: "rev-1".to_string(),
|
|
92
|
+
capabilities: vec!["state:read".to_string()].into_iter().collect(),
|
|
93
|
+
readable_collections: Default::default(),
|
|
94
|
+
writable_collections: Default::default(),
|
|
95
|
+
field_projections: Default::default(),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Create a record first
|
|
99
|
+
db.insert_with_authorization(
|
|
100
|
+
"Team#team-1",
|
|
101
|
+
json!({"_id": "team-1", "name": "Engineering"}),
|
|
102
|
+
&authorized_context,
|
|
103
|
+
)?;
|
|
104
|
+
|
|
105
|
+
// Verify record exists
|
|
106
|
+
let value = db.get::<serde_json::Value>("Team#team-1")?;
|
|
107
|
+
assert!(value.is_some(), "Record should exist before delete");
|
|
108
|
+
|
|
109
|
+
// Unauthorized delete should fail
|
|
110
|
+
let result = db.delete_with_authorization("Team#team-1", &unauthorized_context);
|
|
111
|
+
assert!(
|
|
112
|
+
result.is_err(),
|
|
113
|
+
"Unauthorized delete should be denied: {:?}",
|
|
114
|
+
result
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Verify record still exists after denied delete
|
|
118
|
+
let value = db.get::<serde_json::Value>("Team#team-1")?;
|
|
119
|
+
assert!(
|
|
120
|
+
value.is_some(),
|
|
121
|
+
"Record should still exist after unauthorized delete attempt"
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Authorized delete should succeed
|
|
125
|
+
let result = db.delete_with_authorization("Team#team-1", &authorized_context);
|
|
126
|
+
assert!(result.is_ok(), "Authorized delete should succeed: {:?}", result);
|
|
127
|
+
|
|
128
|
+
// Verify record is deleted
|
|
129
|
+
let value = db.get::<serde_json::Value>("Team#team-1")?;
|
|
130
|
+
assert!(value.is_none(), "Record should be deleted after authorized delete");
|
|
131
|
+
|
|
132
|
+
Ok(())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[test]
|
|
136
|
+
fn authorized_update_requires_state_write_capability() -> Result<(), Box<dyn std::error::Error>> {
|
|
137
|
+
let temp_dir = TempDir::new()?;
|
|
138
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
139
|
+
|
|
140
|
+
let authorized_context = AuthorizationContext {
|
|
141
|
+
subject: ":user-1".to_string(),
|
|
142
|
+
tenant_id: "tenant-1".to_string(),
|
|
143
|
+
application_id: "app".to_string(),
|
|
144
|
+
revision_id: "rev-1".to_string(),
|
|
145
|
+
capabilities: vec!["state:write".to_string()].into_iter().collect(),
|
|
146
|
+
readable_collections: Default::default(),
|
|
147
|
+
writable_collections: Default::default(),
|
|
148
|
+
field_projections: Default::default(),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let unauthorized_context = AuthorizationContext {
|
|
152
|
+
subject: ":user-2".to_string(),
|
|
153
|
+
tenant_id: "tenant-1".to_string(),
|
|
154
|
+
application_id: "app".to_string(),
|
|
155
|
+
revision_id: "rev-1".to_string(),
|
|
156
|
+
capabilities: vec!["state:read".to_string()].into_iter().collect(),
|
|
157
|
+
readable_collections: Default::default(),
|
|
158
|
+
writable_collections: Default::default(),
|
|
159
|
+
field_projections: Default::default(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Create a record
|
|
163
|
+
db.insert_with_authorization(
|
|
164
|
+
"Organization#org-1",
|
|
165
|
+
json!({"_id": "org-1", "name": "Acme Corp"}),
|
|
166
|
+
&authorized_context,
|
|
167
|
+
)?;
|
|
168
|
+
|
|
169
|
+
let original = db.get::<serde_json::Value>("Organization#org-1")?;
|
|
170
|
+
assert_eq!(original.as_ref().and_then(|v| v.get("name")).and_then(|v| v.as_str()), Some("Acme Corp"));
|
|
171
|
+
|
|
172
|
+
// Unauthorized update should fail
|
|
173
|
+
let result = db.update_with_authorization(
|
|
174
|
+
"Organization#org-1",
|
|
175
|
+
json!({"_id": "org-1", "name": "Acme Inc"}),
|
|
176
|
+
&unauthorized_context,
|
|
177
|
+
);
|
|
178
|
+
assert!(
|
|
179
|
+
result.is_err(),
|
|
180
|
+
"Unauthorized update should fail: {:?}",
|
|
181
|
+
result
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Verify record was not changed
|
|
185
|
+
let value = db.get::<serde_json::Value>("Organization#org-1")?;
|
|
186
|
+
assert_eq!(
|
|
187
|
+
value.as_ref().and_then(|v| v.get("name")).and_then(|v| v.as_str()),
|
|
188
|
+
Some("Acme Corp"),
|
|
189
|
+
"Unauthorized update should not change record"
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Authorized update should succeed
|
|
193
|
+
let result = db.update_with_authorization(
|
|
194
|
+
"Organization#org-1",
|
|
195
|
+
json!({"_id": "org-1", "name": "Acme Inc"}),
|
|
196
|
+
&authorized_context,
|
|
197
|
+
);
|
|
198
|
+
assert!(result.is_ok(), "Authorized update should succeed: {:?}", result);
|
|
199
|
+
|
|
200
|
+
// Verify record was updated
|
|
201
|
+
let value = db.get::<serde_json::Value>("Organization#org-1")?;
|
|
202
|
+
assert_eq!(
|
|
203
|
+
value.as_ref().and_then(|v| v.get("name")).and_then(|v| v.as_str()),
|
|
204
|
+
Some("Acme Inc"),
|
|
205
|
+
"Authorized update should change record"
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
Ok(())
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn authorization_context_flows_through_mutations() -> Result<(), Box<dyn std::error::Error>> {
|
|
213
|
+
// This test verifies that AuthorizationContext is properly threaded through all mutation paths
|
|
214
|
+
let temp_dir = TempDir::new()?;
|
|
215
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
216
|
+
|
|
217
|
+
let context = AuthorizationContext {
|
|
218
|
+
subject: ":user-1".to_string(),
|
|
219
|
+
tenant_id: "tenant-1".to_string(),
|
|
220
|
+
application_id: "app".to_string(),
|
|
221
|
+
revision_id: "rev-1".to_string(),
|
|
222
|
+
capabilities: vec!["state:write".to_string()].into_iter().collect(),
|
|
223
|
+
readable_collections: Default::default(),
|
|
224
|
+
writable_collections: Default::default(),
|
|
225
|
+
field_projections: Default::default(),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Insert, update, delete should all accept and use AuthorizationContext
|
|
229
|
+
db.insert_with_authorization(
|
|
230
|
+
"Organization#org-1",
|
|
231
|
+
json!({"_id": "org-1", "name": "Acme"}),
|
|
232
|
+
&context,
|
|
233
|
+
)?;
|
|
234
|
+
|
|
235
|
+
db.update_with_authorization(
|
|
236
|
+
"Organization#org-1",
|
|
237
|
+
json!({"_id": "org-1", "name": "Acme Inc"}),
|
|
238
|
+
&context,
|
|
239
|
+
)?;
|
|
240
|
+
|
|
241
|
+
db.delete_with_authorization("Organization#org-1", &context)?;
|
|
242
|
+
|
|
243
|
+
// Verify record is gone
|
|
244
|
+
let value = db.get::<serde_json::Value>("Organization#org-1")?;
|
|
245
|
+
assert!(value.is_none(), "Record should be deleted");
|
|
246
|
+
|
|
247
|
+
Ok(())
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[test]
|
|
251
|
+
fn atomic_transaction_with_authorization_denies_on_any_mutation_failure(
|
|
252
|
+
) -> Result<(), Box<dyn std::error::Error>> {
|
|
253
|
+
let temp_dir = TempDir::new()?;
|
|
254
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
255
|
+
|
|
256
|
+
let authorized_context = AuthorizationContext {
|
|
257
|
+
subject: ":user-1".to_string(),
|
|
258
|
+
tenant_id: "tenant-1".to_string(),
|
|
259
|
+
application_id: "app".to_string(),
|
|
260
|
+
revision_id: "rev-1".to_string(),
|
|
261
|
+
capabilities: vec!["state:write".to_string()].into_iter().collect(),
|
|
262
|
+
readable_collections: Default::default(),
|
|
263
|
+
writable_collections: Default::default(),
|
|
264
|
+
field_projections: Default::default(),
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
let unauthorized_context = AuthorizationContext {
|
|
268
|
+
subject: ":user-2".to_string(),
|
|
269
|
+
tenant_id: "tenant-1".to_string(),
|
|
270
|
+
application_id: "app".to_string(),
|
|
271
|
+
revision_id: "rev-1".to_string(),
|
|
272
|
+
capabilities: vec!["state:read".to_string()].into_iter().collect(),
|
|
273
|
+
readable_collections: Default::default(),
|
|
274
|
+
writable_collections: Default::default(),
|
|
275
|
+
field_projections: Default::default(),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Create initial records
|
|
279
|
+
db.insert_with_authorization(
|
|
280
|
+
"User#user-1",
|
|
281
|
+
json!({"_id": "user-1"}),
|
|
282
|
+
&authorized_context,
|
|
283
|
+
)?;
|
|
284
|
+
db.insert_with_authorization(
|
|
285
|
+
"Team#team-1",
|
|
286
|
+
json!({"_id": "team-1"}),
|
|
287
|
+
&authorized_context,
|
|
288
|
+
)?;
|
|
289
|
+
|
|
290
|
+
// Attempt atomic transaction with unauthorized context
|
|
291
|
+
// Should be rejected without applying ANY mutations
|
|
292
|
+
let result = db.apply_atomic_transaction_with_authorization(
|
|
293
|
+
"txn-1",
|
|
294
|
+
None,
|
|
295
|
+
&[],
|
|
296
|
+
&[], // Empty mutations for this test
|
|
297
|
+
None,
|
|
298
|
+
&unauthorized_context,
|
|
299
|
+
);
|
|
300
|
+
assert!(
|
|
301
|
+
result.is_err(),
|
|
302
|
+
"Transaction with unauthorized context should be rejected"
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
Ok(())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn authorization_error_messages_are_clear() -> Result<(), Box<dyn std::error::Error>> {
|
|
310
|
+
let temp_dir = TempDir::new()?;
|
|
311
|
+
let db = FeltDb::open(temp_dir.path().join("test.db"))?;
|
|
312
|
+
|
|
313
|
+
let unauthorized_context = AuthorizationContext {
|
|
314
|
+
subject: ":user-1".to_string(),
|
|
315
|
+
tenant_id: "tenant-1".to_string(),
|
|
316
|
+
application_id: "app".to_string(),
|
|
317
|
+
revision_id: "rev-1".to_string(),
|
|
318
|
+
capabilities: vec!["state:read".to_string()].into_iter().collect(),
|
|
319
|
+
readable_collections: Default::default(),
|
|
320
|
+
writable_collections: Default::default(),
|
|
321
|
+
field_projections: Default::default(),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
let result = db.insert_with_authorization(
|
|
325
|
+
"User#user-1",
|
|
326
|
+
json!({"_id": "user-1"}),
|
|
327
|
+
&unauthorized_context,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if let Err(err) = result {
|
|
331
|
+
let error_message = format!("{}", err);
|
|
332
|
+
assert!(
|
|
333
|
+
error_message.contains("AUTHORIZATION_DENIED"),
|
|
334
|
+
"Error should contain AUTHORIZATION_DENIED: {}",
|
|
335
|
+
error_message
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
panic!("Should have returned authorization error");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
Ok(())
|
|
342
|
+
}
|