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