@feltdb/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/authority-scope.d.ts +2 -0
  2. package/dist/authority-scope.d.ts.map +1 -1
  3. package/dist/authority-scope.js +7 -2
  4. package/dist/cli/commands.js +19 -21
  5. package/dist/cli/development-session.js +16 -0
  6. package/dist/cli/studio-artifact.js +33 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  9. package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
  10. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
  12. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
  13. package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
  14. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  15. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  16. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  17. package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
  18. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  19. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  20. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  21. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
  22. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  23. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  24. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  25. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
  26. package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  27. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
  28. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
  29. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/main.rs +443 -77
  32. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
  33. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  34. package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  35. package/dist/db.d.ts +14 -7
  36. package/dist/db.d.ts.map +1 -1
  37. package/dist/db.js +50 -26
  38. package/dist/deployment.d.ts +2 -0
  39. package/dist/deployment.d.ts.map +1 -1
  40. package/dist/deployment.js +4 -2
  41. package/dist/embedded-transaction.d.ts.map +1 -1
  42. package/dist/embedded-transaction.js +4 -0
  43. package/dist/error-codes.d.ts +12 -0
  44. package/dist/error-codes.d.ts.map +1 -1
  45. package/dist/error-codes.js +20 -2
  46. package/dist/feltdb.d.ts +4 -0
  47. package/dist/feltdb.d.ts.map +1 -1
  48. package/dist/file-db.d.ts +10 -0
  49. package/dist/file-db.d.ts.map +1 -1
  50. package/dist/file-db.js +18 -0
  51. package/dist/http-db.d.ts +7 -0
  52. package/dist/http-db.d.ts.map +1 -1
  53. package/dist/http-db.js +83 -15
  54. package/dist/index-core.d.ts +1 -0
  55. package/dist/index-core.d.ts.map +1 -1
  56. package/dist/index-core.js +1 -0
  57. package/dist/operation-admission.d.ts +2 -0
  58. package/dist/operation-admission.d.ts.map +1 -1
  59. package/dist/public-contract.d.ts +112 -0
  60. package/dist/public-contract.d.ts.map +1 -0
  61. package/dist/public-contract.js +125 -0
  62. package/dist/studio/app.d.ts +23 -0
  63. package/dist/studio/app.d.ts.map +1 -1
  64. package/dist/studio/components/ContractInspector.d.ts +5 -0
  65. package/dist/studio/components/ContractInspector.d.ts.map +1 -1
  66. package/dist/studio/components/SettingsPanel.d.ts +4 -1
  67. package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
  68. package/dist/studio/components/index.js +4 -4
  69. package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
  70. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  71. package/dist/studio/control-plane/Instance.d.ts +10 -0
  72. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  73. package/dist/studio/control-plane/Operations.d.ts +3 -0
  74. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  75. package/dist/studio/control-plane/State.d.ts.map +1 -1
  76. package/dist/studio/control-plane/index.d.ts +4 -2
  77. package/dist/studio/control-plane/index.d.ts.map +1 -1
  78. package/dist/studio/control-plane/index.js +2 -2
  79. package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
  80. package/dist/studio/index.js +96 -75
  81. package/dist/studio/utils/api.d.ts +1 -1
  82. package/dist/studio/utils/api.d.ts.map +1 -1
  83. package/dist/studio/utils/control-plane-api.d.ts +12 -1
  84. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  85. package/dist/studio/utils/control-plane-api.js +1 -1
  86. package/dist/studio/utils/index.js +16 -16
  87. package/dist/studio/utils/service-api.d.ts +8 -0
  88. package/dist/studio/utils/service-api.d.ts.map +1 -1
  89. package/dist/studio/utils/service-api.js +15 -12
  90. package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
  92. package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
  93. package/dist/studio-app/feltdb-studio.json +6 -0
  94. package/dist/studio-app/index.html +1 -1
  95. package/dist/transaction.d.ts +9 -1
  96. package/dist/transaction.d.ts.map +1 -1
  97. package/dist/transaction.js +6 -2
  98. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  99. package/dist/workspace/local-development-authority.d.ts +8 -0
  100. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  101. package/dist/workspace/local-development-authority.js +136 -1
  102. package/package.json +2 -2
  103. package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
  104. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  105. package/dist/studio-app/assets/index-CjELc-ul.js +0 -30
@@ -1,15 +1,10 @@
1
- use crate::{
2
- authorization::{authorize, AuthorizationRequest, Subject},
3
- tenancy::TenancyStore,
4
- };
5
- use rand::{distributions::Alphanumeric, Rng};
1
+ //! Principal domain records.
2
+ //!
3
+ //! Persistence intentionally does not live in this module. Canonical runtime
4
+ //! authority and the isolated one-way legacy reader are implemented by
5
+ //! `canonical_principals`; there is no JSON-backed `PrincipalStore`.
6
+
6
7
  use serde::{Deserialize, Serialize};
7
- use std::{
8
- fs,
9
- path::PathBuf,
10
- sync::{Arc, RwLock},
11
- time::{SystemTime, UNIX_EPOCH},
12
- };
13
8
 
14
9
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15
10
  #[serde(rename_all = "snake_case")]
@@ -20,6 +15,7 @@ pub enum PrincipalKind {
20
15
  Worker,
21
16
  Connection,
22
17
  }
18
+
23
19
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24
20
  #[serde(rename_all = "snake_case")]
25
21
  pub enum PrincipalStatus {
@@ -29,6 +25,7 @@ pub enum PrincipalStatus {
29
25
  Completed,
30
26
  Failed,
31
27
  }
28
+
32
29
  #[derive(Debug, Clone, Serialize, Deserialize)]
33
30
  pub struct PrincipalRecord {
34
31
  pub id: String,
@@ -45,6 +42,7 @@ pub struct PrincipalRecord {
45
42
  pub started_at: Option<u64>,
46
43
  pub completed_at: Option<u64>,
47
44
  }
45
+
48
46
  #[derive(Debug, Clone, Serialize, Deserialize)]
49
47
  pub struct PrincipalAuditEvent {
50
48
  pub event: String,
@@ -55,493 +53,3 @@ pub struct PrincipalAuditEvent {
55
53
  pub decision: Option<bool>,
56
54
  pub at: u64,
57
55
  }
58
- #[derive(Debug, Default, Serialize, Deserialize)]
59
- struct PrincipalRecords {
60
- principals: Vec<PrincipalRecord>,
61
- audit: Vec<PrincipalAuditEvent>,
62
- }
63
- #[derive(Clone)]
64
- pub struct PrincipalStore {
65
- path: PathBuf,
66
- records: Arc<RwLock<PrincipalRecords>>,
67
- }
68
-
69
- fn now() -> u64 {
70
- SystemTime::now()
71
- .duration_since(UNIX_EPOCH)
72
- .unwrap_or_default()
73
- .as_secs()
74
- }
75
- fn id(prefix: &str) -> String {
76
- let value: String = rand::thread_rng()
77
- .sample_iter(&Alphanumeric)
78
- .take(18)
79
- .map(char::from)
80
- .collect();
81
- format!("{prefix}_{value}")
82
- }
83
- fn kind_subject(record: &PrincipalRecord) -> Subject {
84
- match record.kind {
85
- PrincipalKind::Agent => Subject::Agent(record.id.clone()),
86
- PrincipalKind::Workload => Subject::Workload(record.id.clone()),
87
- PrincipalKind::Service => Subject::Service(record.id.clone()),
88
- PrincipalKind::Worker => Subject::Worker(record.id.clone()),
89
- PrincipalKind::Connection => Subject::Connection(record.id.clone()),
90
- }
91
- }
92
-
93
- impl PrincipalStore {
94
- pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
95
- let path = path.into();
96
- let records = if path.exists() {
97
- serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?)
98
- .map_err(|error| error.to_string())?
99
- } else {
100
- PrincipalRecords::default()
101
- };
102
- Ok(Self {
103
- path,
104
- records: Arc::new(RwLock::new(records)),
105
- })
106
- }
107
- fn persist(&self, records: &PrincipalRecords) -> Result<(), String> {
108
- if let Some(parent) = self.path.parent() {
109
- fs::create_dir_all(parent).map_err(|error| error.to_string())?;
110
- }
111
- let temporary = self.path.with_extension("tmp");
112
- fs::write(
113
- &temporary,
114
- serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?,
115
- )
116
- .map_err(|error| error.to_string())?;
117
- fs::rename(temporary, &self.path).map_err(|error| error.to_string())
118
- }
119
- fn event(
120
- records: &mut PrincipalRecords,
121
- event: &str,
122
- actor: &str,
123
- principal: &PrincipalRecord,
124
- capability: Option<&str>,
125
- decision: Option<bool>,
126
- ) {
127
- records.audit.push(PrincipalAuditEvent {
128
- event: event.into(),
129
- actor: actor.into(),
130
- principal_id: principal.id.clone(),
131
- application_id: principal.application_id.clone(),
132
- capability: capability.map(str::to_string),
133
- decision,
134
- at: now(),
135
- });
136
- }
137
- fn application_context(tenancy: &TenancyStore, application_id: &str) -> Result<String, String> {
138
- tenancy
139
- .application_tenant(application_id)
140
- .ok_or("application not found".into())
141
- }
142
-
143
- pub fn list(
144
- &self,
145
- actor: &str,
146
- tenancy: &TenancyStore,
147
- application_id: &str,
148
- ) -> Result<Vec<PrincipalRecord>, String> {
149
- let tenant_id = Self::application_context(tenancy, application_id)?;
150
- if !tenancy.authorize_application(actor, &tenant_id, application_id, "applications:read") {
151
- return Err("forbidden".into());
152
- }
153
- Ok(self
154
- .records
155
- .read()
156
- .map_err(|_| "principal store lock poisoned")?
157
- .principals
158
- .iter()
159
- .filter(|principal| principal.application_id == application_id)
160
- .cloned()
161
- .collect())
162
- }
163
- /// One principal's own audit trail: grants, creations, and the
164
- /// authorization decisions taken for it.
165
- ///
166
- /// The store has recorded these since it existed and nothing read them
167
- /// back. A human governing an agent needs exactly this — what the agent was
168
- /// allowed to do, when, and who allowed it — so it is readable under the
169
- /// same application authorization as listing principals.
170
- pub fn audit_for(
171
- &self,
172
- actor: &str,
173
- tenancy: &TenancyStore,
174
- application_id: &str,
175
- principal_id: Option<&str>,
176
- limit: usize,
177
- ) -> Result<Vec<PrincipalAuditEvent>, String> {
178
- let tenant_id = Self::application_context(tenancy, application_id)?;
179
- if !tenancy.authorize_application(actor, &tenant_id, application_id, "applications:read") {
180
- return Err("forbidden".into());
181
- }
182
- let records = self
183
- .records
184
- .read()
185
- .map_err(|_| "principal store lock poisoned")?;
186
- let mut events: Vec<PrincipalAuditEvent> = records
187
- .audit
188
- .iter()
189
- .filter(|event| event.application_id == application_id)
190
- .filter(|event| principal_id.is_none_or(|id| event.principal_id == id))
191
- .cloned()
192
- .collect();
193
- // Newest first: an operator reads the most recent decision, then
194
- // works backwards.
195
- events.reverse();
196
- events.truncate(limit);
197
- Ok(events)
198
- }
199
-
200
- pub fn create(
201
- &self,
202
- actor: &str,
203
- tenancy: &TenancyStore,
204
- application_id: &str,
205
- kind: PrincipalKind,
206
- name: &str,
207
- ) -> Result<PrincipalRecord, String> {
208
- let tenant_id = Self::application_context(tenancy, application_id)?;
209
- let required = match kind {
210
- PrincipalKind::Agent => "agents:write",
211
- PrincipalKind::Connection => "connections:write",
212
- _ => "applications:write",
213
- };
214
- if !tenancy.authorize_application(actor, &tenant_id, application_id, required) {
215
- return Err("forbidden".into());
216
- }
217
- let prefix = match kind {
218
- PrincipalKind::Agent => "agent",
219
- PrincipalKind::Workload => "workload",
220
- PrincipalKind::Service => "service",
221
- PrincipalKind::Worker => "worker",
222
- PrincipalKind::Connection => "connection",
223
- };
224
- let principal = PrincipalRecord {
225
- id: id(prefix),
226
- application_id: application_id.into(),
227
- kind,
228
- name: name.into(),
229
- version: 1,
230
- status: PrincipalStatus::Active,
231
- created_by: actor.into(),
232
- created_at: now(),
233
- capabilities: vec![],
234
- parent_id: None,
235
- worker_id: None,
236
- started_at: None,
237
- completed_at: None,
238
- };
239
- let mut records = self
240
- .records
241
- .write()
242
- .map_err(|_| "principal store lock poisoned")?;
243
- records.principals.push(principal.clone());
244
- Self::event(
245
- &mut records,
246
- "principal.created",
247
- actor,
248
- &principal,
249
- None,
250
- None,
251
- );
252
- self.persist(&records)?;
253
- Ok(principal)
254
- }
255
- pub fn grant(
256
- &self,
257
- actor: &str,
258
- tenancy: &TenancyStore,
259
- principal_id: &str,
260
- capability: &str,
261
- ) -> Result<PrincipalRecord, String> {
262
- if capability == "secrets:plaintext" {
263
- return Err("plaintext secret access cannot be granted".into());
264
- }
265
- let mut records = self
266
- .records
267
- .write()
268
- .map_err(|_| "principal store lock poisoned")?;
269
- let index = records
270
- .principals
271
- .iter()
272
- .position(|principal| principal.id == principal_id)
273
- .ok_or("principal not found")?;
274
- let application_id = records.principals[index].application_id.clone();
275
- let tenant_id = Self::application_context(tenancy, &application_id)?;
276
- if actor == principal_id
277
- || !tenancy.authorize_application(
278
- actor,
279
- &tenant_id,
280
- &application_id,
281
- "applications:write",
282
- )
283
- {
284
- return Err("forbidden".into());
285
- }
286
- if !records.principals[index]
287
- .capabilities
288
- .iter()
289
- .any(|existing| existing == capability)
290
- {
291
- records.principals[index]
292
- .capabilities
293
- .push(capability.into());
294
- records.principals[index].version += 1;
295
- }
296
- let principal = records.principals[index].clone();
297
- Self::event(
298
- &mut records,
299
- "principal.capability_granted",
300
- actor,
301
- &principal,
302
- Some(capability),
303
- None,
304
- );
305
- self.persist(&records)?;
306
- Ok(principal)
307
- }
308
- pub fn authorize(&self, principal_id: &str, application_id: &str, capability: &str) -> bool {
309
- let mut records = match self.records.write() {
310
- Ok(records) => records,
311
- Err(_) => return false,
312
- };
313
- let Some(principal) = records
314
- .principals
315
- .iter()
316
- .find(|principal| principal.id == principal_id)
317
- .cloned()
318
- else {
319
- return false;
320
- };
321
- let active = matches!(
322
- principal.status,
323
- PrincipalStatus::Active | PrincipalStatus::Running
324
- ) && principal.application_id == application_id;
325
- let allowed = active
326
- && capability != "secrets:plaintext"
327
- && authorize(&AuthorizationRequest {
328
- subject: &kind_subject(&principal),
329
- tenant_id: "delegated",
330
- application_id: Some(application_id),
331
- capability,
332
- direct_grants: &principal.capabilities,
333
- memberships: &[],
334
- roles: &[],
335
- })
336
- .allowed;
337
- Self::event(
338
- &mut records,
339
- "principal.authorized",
340
- principal_id,
341
- &principal,
342
- Some(capability),
343
- Some(allowed),
344
- );
345
- let _ = self.persist(&records);
346
- allowed
347
- }
348
- pub fn create_workload(
349
- &self,
350
- actor: &str,
351
- tenancy: &TenancyStore,
352
- agent_id: &str,
353
- ) -> Result<PrincipalRecord, String> {
354
- let mut records = self
355
- .records
356
- .write()
357
- .map_err(|_| "principal store lock poisoned")?;
358
- let agent = records
359
- .principals
360
- .iter()
361
- .find(|principal| principal.id == agent_id && principal.kind == PrincipalKind::Agent)
362
- .cloned()
363
- .ok_or("agent not found")?;
364
- let tenant_id = Self::application_context(tenancy, &agent.application_id)?;
365
- if !tenancy.authorize_application(
366
- actor,
367
- &tenant_id,
368
- &agent.application_id,
369
- "workloads:execute",
370
- ) && !tenancy.authorize_application(
371
- actor,
372
- &tenant_id,
373
- &agent.application_id,
374
- "applications:write",
375
- ) {
376
- return Err("forbidden".into());
377
- }
378
- let workload = PrincipalRecord {
379
- id: id("workload"),
380
- application_id: agent.application_id.clone(),
381
- kind: PrincipalKind::Workload,
382
- name: format!("{} execution", agent.name),
383
- version: agent.version,
384
- status: PrincipalStatus::Running,
385
- created_by: actor.into(),
386
- created_at: now(),
387
- capabilities: agent.capabilities.clone(),
388
- parent_id: Some(agent.id),
389
- worker_id: None,
390
- started_at: Some(now()),
391
- completed_at: None,
392
- };
393
- records.principals.push(workload.clone());
394
- Self::event(
395
- &mut records,
396
- "workload.created",
397
- actor,
398
- &workload,
399
- None,
400
- None,
401
- );
402
- self.persist(&records)?;
403
- Ok(workload)
404
- }
405
- pub fn assign_worker(
406
- &self,
407
- actor: &str,
408
- tenancy: &TenancyStore,
409
- workload_id: &str,
410
- worker_id: &str,
411
- ) -> Result<(), String> {
412
- let mut records = self
413
- .records
414
- .write()
415
- .map_err(|_| "principal store lock poisoned")?;
416
- let workload_index = records
417
- .principals
418
- .iter()
419
- .position(|principal| {
420
- principal.id == workload_id && principal.kind == PrincipalKind::Workload
421
- })
422
- .ok_or("workload not found")?;
423
- let worker = records
424
- .principals
425
- .iter()
426
- .find(|principal| principal.id == worker_id && principal.kind == PrincipalKind::Worker)
427
- .cloned()
428
- .ok_or("worker not found")?;
429
- let app_id = records.principals[workload_index].application_id.clone();
430
- if worker.application_id != app_id {
431
- return Err("cross-application worker assignment denied".into());
432
- }
433
- let tenant_id = Self::application_context(tenancy, &app_id)?;
434
- if !tenancy.authorize_application(actor, &tenant_id, &app_id, "applications:write") {
435
- return Err("forbidden".into());
436
- }
437
- records.principals[workload_index].worker_id = Some(worker_id.into());
438
- self.persist(&records)
439
- }
440
- pub fn worker_can_execute(&self, worker_id: &str, workload_id: &str) -> bool {
441
- self.records
442
- .read()
443
- .map(|records| {
444
- records.principals.iter().any(|principal| {
445
- principal.id == workload_id
446
- && principal.kind == PrincipalKind::Workload
447
- && principal.worker_id.as_deref() == Some(worker_id)
448
- && principal.status == PrincipalStatus::Running
449
- })
450
- })
451
- .unwrap_or(false)
452
- }
453
- pub fn execution_snapshot(
454
- &self,
455
- worker_id: &str,
456
- workload_id: &str,
457
- ) -> Option<(String, String, Vec<String>)> {
458
- self.records
459
- .read()
460
- .ok()?
461
- .principals
462
- .iter()
463
- .find(|principal| {
464
- principal.id == workload_id
465
- && principal.kind == PrincipalKind::Workload
466
- && principal.worker_id.as_deref() == Some(worker_id)
467
- && principal.status == PrincipalStatus::Running
468
- })
469
- .map(|workload| {
470
- (
471
- workload.application_id.clone(),
472
- workload.id.clone(),
473
- workload.capabilities.clone(),
474
- )
475
- })
476
- }
477
- }
478
-
479
- #[cfg(test)]
480
- mod tests {
481
- use super::*;
482
- fn stores() -> (TenancyStore, PrincipalStore, String, String) {
483
- let suffix = id("test");
484
- let tenancy =
485
- TenancyStore::load(std::env::temp_dir().join(format!("{suffix}-tenancy.json")))
486
- .unwrap();
487
- let tenant = tenancy.create_tenant("owner", "Acme Principals").unwrap();
488
- let app = tenancy
489
- .create_application("owner", &tenant.id, "Operations")
490
- .unwrap();
491
- let principals =
492
- PrincipalStore::load(std::env::temp_dir().join(format!("{suffix}-principals.json")))
493
- .unwrap();
494
- (tenancy, principals, tenant.id, app.id)
495
- }
496
- #[test]
497
- fn agent_and_workload_are_scoped_without_escalation() {
498
- let (tenancy, principals, _, app) = stores();
499
- let agent = principals
500
- .create("owner", &tenancy, &app, PrincipalKind::Agent, "Coordinator")
501
- .unwrap();
502
- principals
503
- .grant("owner", &tenancy, &agent.id, "state:read")
504
- .unwrap();
505
- assert!(principals.authorize(&agent.id, &app, "state:read"));
506
- assert!(!principals.authorize(&agent.id, &app, "state:write"));
507
- assert!(!principals.authorize(&agent.id, "app_other", "state:read"));
508
- assert!(principals
509
- .grant(&agent.id, &tenancy, &agent.id, "state:write")
510
- .is_err());
511
- let workload = principals
512
- .create_workload("owner", &tenancy, &agent.id)
513
- .unwrap();
514
- assert!(principals.authorize(&workload.id, &app, "state:read"));
515
- assert!(!principals.authorize(&workload.id, &app, "state:write"));
516
- }
517
- #[test]
518
- fn worker_only_executes_assigned_workload_and_secrets_remain_opaque() {
519
- let (tenancy, principals, _, app) = stores();
520
- let agent = principals
521
- .create("owner", &tenancy, &app, PrincipalKind::Agent, "Agent")
522
- .unwrap();
523
- principals
524
- .grant("owner", &tenancy, &agent.id, "connection:use")
525
- .unwrap();
526
- assert!(principals
527
- .grant("owner", &tenancy, &agent.id, "secrets:plaintext")
528
- .is_err());
529
- let workload = principals
530
- .create_workload("owner", &tenancy, &agent.id)
531
- .unwrap();
532
- let worker = principals
533
- .create(
534
- "owner",
535
- &tenancy,
536
- &app,
537
- PrincipalKind::Worker,
538
- "worker-east",
539
- )
540
- .unwrap();
541
- assert!(!principals.worker_can_execute(&worker.id, &workload.id));
542
- principals
543
- .assign_worker("owner", &tenancy, &workload.id, &worker.id)
544
- .unwrap();
545
- assert!(principals.worker_can_execute(&worker.id, &workload.id));
546
- }
547
- }
@@ -656,6 +656,28 @@ impl TenancyStore {
656
656
  .find(|app| app.id == application_id)
657
657
  .map(|app| app.tenant_id.clone())
658
658
  }
659
+ /// Return all tenant scopes asserted by canonical tenancy application
660
+ /// records. Migration code uses the complete set so duplicate/conflicting
661
+ /// ownership cannot be hidden by a first-match lookup.
662
+ pub fn application_tenant_candidates(&self, application_id: &str) -> Vec<String> {
663
+ self.records
664
+ .read()
665
+ .map(|records| {
666
+ records
667
+ .applications
668
+ .iter()
669
+ .filter(|application| application.id == application_id)
670
+ .map(|application| application.tenant_id.clone())
671
+ .collect()
672
+ })
673
+ .unwrap_or_default()
674
+ }
675
+ pub fn tenant_exists(&self, tenant_id: &str) -> bool {
676
+ self.records
677
+ .read()
678
+ .map(|records| records.tenants.iter().any(|tenant| tenant.id == tenant_id))
679
+ .unwrap_or(false)
680
+ }
659
681
  pub fn application_members_for(
660
682
  &self,
661
683
  actor: &str,