@feltdb/core 0.4.12 → 0.4.14

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 (165) hide show
  1. package/dist/cli/commands.js +42 -25
  2. package/dist/cli/index.js +1 -1
  3. package/dist/create/cli.js +39 -4
  4. package/dist/create/create.js +30 -18
  5. package/dist/create/docker-compose-generator.js +68 -11
  6. package/dist/create/package-versions.js +1 -1
  7. package/dist/create/server-source/Cargo.lock +2238 -0
  8. package/dist/create/server-source/Cargo.toml +7 -0
  9. package/dist/create/server-source/crates/feltdb/Cargo.lock +175 -0
  10. package/dist/create/server-source/crates/feltdb/Cargo.toml +79 -0
  11. package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +370 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +589 -0
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +259 -0
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +446 -0
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +272 -0
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +378 -0
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +414 -0
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +486 -0
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +273 -0
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +587 -0
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +513 -0
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +395 -0
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +469 -0
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +409 -0
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +627 -0
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +383 -0
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +298 -0
  28. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +1698 -0
  29. package/dist/create/server-source/crates/feltdb/src/acquisition.rs +286 -0
  30. package/dist/create/server-source/crates/feltdb/src/admission.rs +192 -0
  31. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +477 -0
  32. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +566 -0
  33. package/dist/create/server-source/crates/feltdb/src/analytics.rs +475 -0
  34. package/dist/create/server-source/crates/feltdb/src/application.rs +2244 -0
  35. package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +1070 -0
  36. package/dist/create/server-source/crates/feltdb/src/authorization.rs +1030 -0
  37. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +266 -0
  38. package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +8 -0
  39. package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +418 -0
  40. package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +482 -0
  41. package/dist/create/server-source/crates/feltdb/src/capability.rs +903 -0
  42. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +261 -0
  43. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +78 -0
  44. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +2214 -0
  45. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +194 -0
  46. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +427 -0
  47. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +453 -0
  48. package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +465 -0
  49. package/dist/create/server-source/crates/feltdb/src/convergence.rs +618 -0
  50. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +418 -0
  51. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +380 -0
  52. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +362 -0
  53. package/dist/create/server-source/crates/feltdb/src/cron.rs +294 -0
  54. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +474 -0
  55. package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +1003 -0
  56. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +536 -0
  57. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +364 -0
  58. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +235 -0
  59. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +207 -0
  60. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +316 -0
  61. package/dist/create/server-source/crates/feltdb/src/execution.rs +426 -0
  62. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +219 -0
  63. package/dist/create/server-source/crates/feltdb/src/indexing.rs +779 -0
  64. package/dist/create/server-source/crates/feltdb/src/lib.rs +2838 -0
  65. package/dist/create/server-source/crates/feltdb/src/materialization.rs +184 -0
  66. package/dist/create/server-source/crates/feltdb/src/metrics.rs +267 -0
  67. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +311 -0
  68. package/dist/create/server-source/crates/feltdb/src/observability.rs +366 -0
  69. package/dist/create/server-source/crates/feltdb/src/operation.rs +251 -0
  70. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +438 -0
  71. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +344 -0
  72. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +477 -0
  73. package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +166 -0
  74. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +261 -0
  75. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +560 -0
  76. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +3226 -0
  77. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +201 -0
  78. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +263 -0
  79. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +484 -0
  80. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +216 -0
  81. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +281 -0
  82. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +323 -0
  83. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +573 -0
  84. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +404 -0
  85. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +418 -0
  86. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +381 -0
  87. package/dist/create/server-source/crates/feltdb/src/phase_1c_three_node.rs +523 -0
  88. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +334 -0
  89. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +306 -0
  90. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +355 -0
  91. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +395 -0
  92. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +346 -0
  93. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +430 -0
  94. package/dist/create/server-source/crates/feltdb/src/production_api.rs +400 -0
  95. package/dist/create/server-source/crates/feltdb/src/provenance.rs +207 -0
  96. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +217 -0
  97. package/dist/create/server-source/crates/feltdb/src/references.rs +404 -0
  98. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +401 -0
  99. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +160 -0
  100. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +132 -0
  101. package/dist/create/server-source/crates/feltdb/src/routing.rs +409 -0
  102. package/dist/create/server-source/crates/feltdb/src/sharding.rs +523 -0
  103. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +3118 -0
  104. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +291 -0
  105. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +376 -0
  106. package/dist/create/server-source/crates/feltdb/src/storage.rs +267 -0
  107. package/dist/create/server-source/crates/feltdb/src/submission.rs +477 -0
  108. package/dist/create/server-source/crates/feltdb/src/sync.rs +483 -0
  109. package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +822 -0
  110. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +366 -0
  111. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +473 -0
  112. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +949 -0
  113. package/dist/create/server-source/crates/feltdb/src/transactions.rs +646 -0
  114. package/dist/create/server-source/crates/feltdb/src/trigger.rs +390 -0
  115. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +928 -0
  116. package/dist/create/server-source/crates/feltdb/src/workflow.rs +713 -0
  117. package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +510 -0
  118. package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +354 -0
  119. package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +594 -0
  120. package/dist/create/server-source/crates/feltdb/src/workload.rs +1310 -0
  121. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +23 -0
  122. package/dist/create/server-source/crates/feltdb-server/README.md +79 -0
  123. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +73 -0
  124. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +425 -0
  125. package/dist/create/server-source/crates/feltdb-server/src/artifacts.rs +449 -0
  126. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +59 -0
  127. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +308 -0
  128. package/dist/create/server-source/crates/feltdb-server/src/authorization.rs +228 -0
  129. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +416 -0
  130. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +218 -0
  131. package/dist/create/server-source/crates/feltdb-server/src/certification.rs +223 -0
  132. package/dist/create/server-source/crates/feltdb-server/src/clock.rs +132 -0
  133. package/dist/create/server-source/crates/feltdb-server/src/cluster.rs +165 -0
  134. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +1044 -0
  135. package/dist/create/server-source/crates/feltdb-server/src/content.rs +111 -0
  136. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +250 -0
  137. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +78 -0
  138. package/dist/create/server-source/crates/feltdb-server/src/key_provider.rs +247 -0
  139. package/dist/create/server-source/crates/feltdb-server/src/leases.rs +123 -0
  140. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +25 -0
  141. package/dist/create/server-source/crates/feltdb-server/src/main.rs +8715 -0
  142. package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +97 -0
  143. package/dist/create/server-source/crates/feltdb-server/src/portable_bundle.rs +350 -0
  144. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +510 -0
  145. package/dist/create/server-source/crates/feltdb-server/src/providers.rs +269 -0
  146. package/dist/create/server-source/crates/feltdb-server/src/releases.rs +2563 -0
  147. package/dist/create/server-source/crates/feltdb-server/src/sessions.rs +156 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +1097 -0
  149. package/dist/create/server-source/crates/feltdb-server/src/versions.rs +264 -0
  150. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +31 -0
  151. package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +1086 -0
  152. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  153. package/dist/flowspec.d.ts +2 -0
  154. package/dist/flowspec.d.ts.map +1 -1
  155. package/dist/flowspec.js +28 -4
  156. package/dist/state-contract.d.ts +7 -1
  157. package/dist/state-contract.d.ts.map +1 -1
  158. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  159. package/dist/studio/components/ConflictExplorer.d.ts.map +1 -1
  160. package/dist/studio/components/index.js +1 -1
  161. package/dist/studio/{components-Q0Nx6gLE.js → components-9kDSWiGL.js} +163 -47
  162. package/dist/studio/index.js +31 -28
  163. package/dist/studio-app/assets/{index-BHOmLZF-.js → index-DVdvmf69.js} +9 -9
  164. package/dist/studio-app/index.html +1 -1
  165. package/package.json +1 -1
@@ -0,0 +1,1097 @@
1
+ use std::{
2
+ fs,
3
+ path::PathBuf,
4
+ sync::{Arc, RwLock},
5
+ time::{SystemTime, UNIX_EPOCH},
6
+ };
7
+
8
+ use argon2::{
9
+ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
10
+ Argon2,
11
+ };
12
+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
13
+ use rand::{distributions::Alphanumeric, Rng};
14
+ use serde::{Deserialize, Serialize};
15
+
16
+ use crate::authorization::{authorize, AuthorizationRequest, Membership, RoleGrant, Subject};
17
+
18
+ #[derive(Debug, Clone, Serialize, Deserialize)]
19
+ pub struct Tenant {
20
+ pub id: String,
21
+ pub name: String,
22
+ pub slug: String,
23
+ pub created_by: String,
24
+ pub created_at: u64,
25
+ pub updated_at: u64,
26
+ }
27
+ #[derive(Debug, Clone, Serialize, Deserialize)]
28
+ pub struct Application {
29
+ pub id: String,
30
+ pub tenant_id: String,
31
+ pub name: String,
32
+ pub slug: String,
33
+ pub namespace: String,
34
+ pub created_by: String,
35
+ pub created_at: u64,
36
+ pub updated_at: u64,
37
+ #[serde(default = "application_status")]
38
+ pub status: String,
39
+ #[serde(default)]
40
+ pub active_revision: Option<String>,
41
+ #[serde(default)]
42
+ pub environment_revisions: std::collections::BTreeMap<String, String>,
43
+ }
44
+ #[derive(Debug, Clone, Serialize, Deserialize)]
45
+ pub struct TenantMembership {
46
+ pub tenant_id: String,
47
+ pub user_id: String,
48
+ pub role: String,
49
+ pub status: MembershipStatus,
50
+ pub created_at: u64,
51
+ pub updated_at: u64,
52
+ }
53
+ #[derive(Debug, Clone, Serialize, Deserialize)]
54
+ pub struct ApplicationMembership {
55
+ pub application_id: String,
56
+ pub user_id: String,
57
+ pub role: String,
58
+ pub status: MembershipStatus,
59
+ pub created_at: u64,
60
+ pub updated_at: u64,
61
+ }
62
+ #[derive(Debug, Clone, Serialize, Deserialize)]
63
+ pub struct PlatformUser {
64
+ pub user_id: String,
65
+ pub identity: String,
66
+ pub status: MembershipStatus,
67
+ pub created_at: u64,
68
+ }
69
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70
+ #[serde(rename_all = "snake_case")]
71
+ pub enum MembershipStatus {
72
+ Active,
73
+ Revoked,
74
+ }
75
+ #[derive(Debug, Clone, Serialize, Deserialize)]
76
+ pub struct Invitation {
77
+ pub id: String,
78
+ pub tenant_id: String,
79
+ pub application_id: Option<String>,
80
+ pub email: String,
81
+ pub role: String,
82
+ pub invited_by: String,
83
+ pub token_hash: String,
84
+ pub expires_at: u64,
85
+ pub accepted_at: Option<u64>,
86
+ pub revoked_at: Option<u64>,
87
+ }
88
+ #[derive(Debug, Clone, Serialize, Deserialize)]
89
+ pub struct PlatformAuditEvent {
90
+ pub event: String,
91
+ pub actor: String,
92
+ pub tenant_id: String,
93
+ pub application_id: Option<String>,
94
+ pub target: String,
95
+ pub at: u64,
96
+ }
97
+
98
+ #[derive(Debug, Default, Serialize, Deserialize)]
99
+ struct PlatformRecords {
100
+ #[serde(default)]
101
+ users: Vec<PlatformUser>,
102
+ tenants: Vec<Tenant>,
103
+ applications: Vec<Application>,
104
+ tenant_memberships: Vec<TenantMembership>,
105
+ application_memberships: Vec<ApplicationMembership>,
106
+ invitations: Vec<Invitation>,
107
+ audit: Vec<PlatformAuditEvent>,
108
+ }
109
+
110
+ #[derive(Clone)]
111
+ pub struct TenancyStore {
112
+ path: PathBuf,
113
+ records: Arc<RwLock<PlatformRecords>>,
114
+ }
115
+
116
+ fn now() -> u64 {
117
+ SystemTime::now()
118
+ .duration_since(UNIX_EPOCH)
119
+ .unwrap_or_default()
120
+ .as_secs()
121
+ }
122
+ fn id(prefix: &str) -> String {
123
+ let value: String = rand::thread_rng()
124
+ .sample_iter(&Alphanumeric)
125
+ .take(18)
126
+ .map(char::from)
127
+ .collect();
128
+ format!("{prefix}_{value}")
129
+ }
130
+ fn slug(value: &str) -> String {
131
+ value
132
+ .to_lowercase()
133
+ .chars()
134
+ .map(|character| {
135
+ if character.is_ascii_alphanumeric() {
136
+ character
137
+ } else {
138
+ '-'
139
+ }
140
+ })
141
+ .collect::<String>()
142
+ .split('-')
143
+ .filter(|part| !part.is_empty())
144
+ .collect::<Vec<_>>()
145
+ .join("-")
146
+ }
147
+ fn platform_owner_user(user_id: &str) -> bool {
148
+ std::env::var("FELTDB_PLATFORM_OWNERS")
149
+ .unwrap_or_default()
150
+ .split(',')
151
+ .map(|owner| owner.trim().to_lowercase())
152
+ .filter(|owner| !owner.is_empty())
153
+ .map(|owner| format!("user_{}", URL_SAFE_NO_PAD.encode(owner).chars().take(18).collect::<String>()))
154
+ .any(|owner_id| owner_id == user_id)
155
+ }
156
+
157
+ pub fn tenant_role_grants() -> Vec<RoleGrant> {
158
+ vec![
159
+ RoleGrant {
160
+ role: "owner".into(),
161
+ capabilities: vec!["*".into()],
162
+ },
163
+ RoleGrant {
164
+ role: "admin".into(),
165
+ capabilities: vec![
166
+ "applications:read".into(),
167
+ "applications:write".into(),
168
+ "members:read".into(),
169
+ "members:write".into(),
170
+ "members:invite".into(),
171
+ "audit:read".into(),
172
+ ],
173
+ },
174
+ RoleGrant {
175
+ role: "member".into(),
176
+ capabilities: vec!["applications:read".into(), "members:read".into()],
177
+ },
178
+ RoleGrant {
179
+ role: "viewer".into(),
180
+ capabilities: vec!["applications:read".into()],
181
+ },
182
+ ]
183
+ }
184
+
185
+ pub fn application_role_grants() -> Vec<RoleGrant> {
186
+ vec![
187
+ RoleGrant {
188
+ role: "owner".into(),
189
+ capabilities: vec!["*".into()],
190
+ },
191
+ RoleGrant {
192
+ role: "promoter".into(),
193
+ capabilities: vec![
194
+ "application:read".into(),
195
+ "application:revision:read".into(),
196
+ "application:runtime:run".into(),
197
+ "application:runtime:stop".into(),
198
+ "application:revision:promote".into(),
199
+ "application:revision:rollback".into(),
200
+ "application:environment:read".into(),
201
+ "application:environment:write".into(),
202
+ ],
203
+ },
204
+ RoleGrant {
205
+ role: "editor".into(),
206
+ capabilities: vec![
207
+ "applications:read".into(),
208
+ "applications:write".into(),
209
+ "application:read".into(),
210
+ "application:write".into(),
211
+ "application:revision:create".into(),
212
+ "application:revision:read".into(),
213
+ "application:revision:preview".into(),
214
+ "application:environment:read".into(),
215
+ "application:runtime:run".into(),
216
+ "application:runtime:stop".into(),
217
+ "state:read".into(),
218
+ "state:write".into(),
219
+ "workflows:read".into(),
220
+ "workflows:write".into(),
221
+ "agents:read".into(),
222
+ "agents:write".into(),
223
+ "connections:read".into(),
224
+ "connections:write".into(),
225
+ ],
226
+ },
227
+ RoleGrant {
228
+ role: "operator".into(),
229
+ capabilities: vec![
230
+ "applications:read".into(),
231
+ "application:revision:read".into(),
232
+ "application:revision:rollback".into(),
233
+ "application:environment:read".into(),
234
+ "application:environment:write".into(),
235
+ "state:read".into(),
236
+ "workflows:read".into(),
237
+ "workflows:run".into(),
238
+ "workloads:read".into(),
239
+ "workloads:execute".into(),
240
+ "deployment:execute".into(),
241
+ ],
242
+ },
243
+ RoleGrant {
244
+ role: "viewer".into(),
245
+ capabilities: vec![
246
+ "applications:read".into(),
247
+ "application:read".into(),
248
+ "application:revision:read".into(),
249
+ "application:environment:read".into(),
250
+ "state:read".into(),
251
+ ],
252
+ },
253
+ ]
254
+ }
255
+ fn application_status() -> String {
256
+ "active".into()
257
+ }
258
+
259
+ impl TenancyStore {
260
+ pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
261
+ let path = path.into();
262
+ let records = if path.exists() {
263
+ serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?)
264
+ .map_err(|error| error.to_string())?
265
+ } else {
266
+ PlatformRecords::default()
267
+ };
268
+ Ok(Self {
269
+ path,
270
+ records: Arc::new(RwLock::new(records)),
271
+ })
272
+ }
273
+ fn persist(&self, records: &PlatformRecords) -> Result<(), String> {
274
+ if let Some(parent) = self.path.parent() {
275
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
276
+ }
277
+ let temporary = self.path.with_extension("tmp");
278
+ fs::write(
279
+ &temporary,
280
+ serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?,
281
+ )
282
+ .map_err(|error| error.to_string())?;
283
+ fs::rename(temporary, &self.path).map_err(|error| error.to_string())
284
+ }
285
+ fn audit(
286
+ records: &mut PlatformRecords,
287
+ event: &str,
288
+ actor: &str,
289
+ tenant_id: &str,
290
+ application_id: Option<&str>,
291
+ target: &str,
292
+ ) {
293
+ records.audit.push(PlatformAuditEvent {
294
+ event: event.into(),
295
+ actor: actor.into(),
296
+ tenant_id: tenant_id.into(),
297
+ application_id: application_id.map(str::to_string),
298
+ target: target.into(),
299
+ at: now(),
300
+ });
301
+ }
302
+
303
+ pub fn tenants_for(&self, user_id: &str) -> Vec<Tenant> {
304
+ self.records
305
+ .read()
306
+ .map(|records| {
307
+ records
308
+ .tenant_memberships
309
+ .iter()
310
+ .filter(|membership| {
311
+ membership.user_id == user_id
312
+ && membership.status == MembershipStatus::Active
313
+ })
314
+ .filter_map(|membership| {
315
+ records
316
+ .tenants
317
+ .iter()
318
+ .find(|tenant| tenant.id == membership.tenant_id)
319
+ .cloned()
320
+ })
321
+ .collect()
322
+ })
323
+ .unwrap_or_default()
324
+ }
325
+ pub fn platform_inventory(
326
+ &self,
327
+ ) -> Result<(
328
+ Vec<PlatformUser>,
329
+ Vec<Tenant>,
330
+ Vec<Application>,
331
+ Vec<TenantMembership>,
332
+ Vec<ApplicationMembership>,
333
+ ), String> {
334
+ let records = self
335
+ .records
336
+ .read()
337
+ .map_err(|_| "tenancy store lock poisoned")?;
338
+ Ok((
339
+ records.users.clone(),
340
+ records.tenants.clone(),
341
+ records.applications.clone(),
342
+ records.tenant_memberships.clone(),
343
+ records.application_memberships.clone(),
344
+ ))
345
+ }
346
+
347
+ pub fn register_platform_user(&self, user_id: &str, identity: &str) -> Result<(), String> {
348
+ let mut records = self
349
+ .records
350
+ .write()
351
+ .map_err(|_| "tenancy store lock poisoned")?;
352
+ if records.users.iter().any(|user| user.user_id == user_id) {
353
+ return Ok(());
354
+ }
355
+ records.users.push(PlatformUser {
356
+ user_id: user_id.into(),
357
+ identity: identity.trim().to_lowercase(),
358
+ status: MembershipStatus::Active,
359
+ created_at: now(),
360
+ });
361
+ self.persist(&records)
362
+ }
363
+
364
+ pub fn platform_update_user(
365
+ &self,
366
+ actor: &str,
367
+ user_id: &str,
368
+ status: MembershipStatus,
369
+ ) -> Result<PlatformUser, String> {
370
+ if actor == user_id {
371
+ return Err("platform owners cannot suspend their own active session".into());
372
+ }
373
+ let mut records = self
374
+ .records
375
+ .write()
376
+ .map_err(|_| "tenancy store lock poisoned")?;
377
+ let user = records
378
+ .users
379
+ .iter_mut()
380
+ .find(|user| user.user_id == user_id)
381
+ .ok_or("platform user not found")?;
382
+ user.status = status;
383
+ let updated = user.clone();
384
+ Self::audit(&mut records, "platform.user.updated", actor, "platform", None, user_id);
385
+ self.persist(&records)?;
386
+ Ok(updated)
387
+ }
388
+
389
+ pub fn platform_user_active(&self, user_id: &str) -> bool {
390
+ self.records.read().is_ok_and(|records| {
391
+ records
392
+ .users
393
+ .iter()
394
+ .find(|user| user.user_id == user_id)
395
+ .is_none_or(|user| user.status == MembershipStatus::Active)
396
+ })
397
+ }
398
+
399
+ pub fn platform_update_application_member(
400
+ &self,
401
+ actor: &str,
402
+ application_id: &str,
403
+ user_id: &str,
404
+ role: &str,
405
+ status: MembershipStatus,
406
+ ) -> Result<ApplicationMembership, String> {
407
+ let mut records = self
408
+ .records
409
+ .write()
410
+ .map_err(|_| "tenancy store lock poisoned")?;
411
+ let tenant_id = records
412
+ .applications
413
+ .iter()
414
+ .find(|application| application.id == application_id)
415
+ .map(|application| application.tenant_id.clone())
416
+ .ok_or("application not found")?;
417
+ let membership = records
418
+ .application_memberships
419
+ .iter_mut()
420
+ .find(|membership| {
421
+ membership.application_id == application_id && membership.user_id == user_id
422
+ })
423
+ .ok_or("application membership not found")?;
424
+ membership.role = role.into();
425
+ membership.status = status;
426
+ membership.updated_at = now();
427
+ let updated = membership.clone();
428
+ Self::audit(
429
+ &mut records,
430
+ "application.membership.updated_by_platform_owner",
431
+ actor,
432
+ &tenant_id,
433
+ Some(application_id),
434
+ user_id,
435
+ );
436
+ self.persist(&records)?;
437
+ Ok(updated)
438
+ }
439
+ pub fn tenant_for(&self, user_id: &str, tenant_id: &str) -> Option<Tenant> {
440
+ if !self.authorize_tenant(user_id, tenant_id, "applications:read") {
441
+ return None;
442
+ }
443
+ self.records
444
+ .read()
445
+ .ok()?
446
+ .tenants
447
+ .iter()
448
+ .find(|tenant| tenant.id == tenant_id)
449
+ .cloned()
450
+ }
451
+ pub fn applications_for(&self, user_id: &str, tenant_id: &str) -> Vec<Application> {
452
+ self.records
453
+ .read()
454
+ .map(|records| {
455
+ records
456
+ .application_memberships
457
+ .iter()
458
+ .filter(|membership| {
459
+ membership.user_id == user_id
460
+ && membership.status == MembershipStatus::Active
461
+ })
462
+ .filter_map(|membership| {
463
+ records
464
+ .applications
465
+ .iter()
466
+ .find(|app| {
467
+ app.id == membership.application_id && app.tenant_id == tenant_id
468
+ })
469
+ .cloned()
470
+ })
471
+ .collect()
472
+ })
473
+ .unwrap_or_default()
474
+ }
475
+ pub fn application_for(&self, user_id: &str, application_id: &str) -> Option<Application> {
476
+ let records = self.records.read().ok()?;
477
+ let app = records
478
+ .applications
479
+ .iter()
480
+ .find(|app| app.id == application_id)?
481
+ .clone();
482
+ drop(records);
483
+ self.authorize_application(user_id, &app.tenant_id, &app.id, "applications:read")
484
+ .then_some(app)
485
+ }
486
+ pub fn application_tenant(&self, application_id: &str) -> Option<String> {
487
+ self.records
488
+ .read()
489
+ .ok()?
490
+ .applications
491
+ .iter()
492
+ .find(|app| app.id == application_id)
493
+ .map(|app| app.tenant_id.clone())
494
+ }
495
+ pub fn application_members_for(
496
+ &self,
497
+ actor: &str,
498
+ tenant_id: &str,
499
+ application_id: &str,
500
+ ) -> Result<Vec<ApplicationMembership>, String> {
501
+ if !self.authorize_application(actor, tenant_id, application_id, "members:read") {
502
+ return Err("forbidden".into());
503
+ }
504
+ Ok(self
505
+ .records
506
+ .read()
507
+ .map_err(|_| "tenancy store lock poisoned")?
508
+ .application_memberships
509
+ .iter()
510
+ .filter(|membership| {
511
+ membership.application_id == application_id
512
+ && membership.status == MembershipStatus::Active
513
+ })
514
+ .cloned()
515
+ .collect())
516
+ }
517
+ pub fn audit_for_application(
518
+ &self,
519
+ actor: &str,
520
+ tenant_id: &str,
521
+ application_id: &str,
522
+ ) -> Result<Vec<PlatformAuditEvent>, String> {
523
+ if !self.authorize_application(actor, tenant_id, application_id, "audit:read") {
524
+ return Err("forbidden".into());
525
+ }
526
+ Ok(self
527
+ .records
528
+ .read()
529
+ .map_err(|_| "tenancy store lock poisoned")?
530
+ .audit
531
+ .iter()
532
+ .filter(|event| event.application_id.as_deref() == Some(application_id))
533
+ .cloned()
534
+ .collect())
535
+ }
536
+
537
+ pub fn create_tenant(&self, creator: &str, name: &str) -> Result<Tenant, String> {
538
+ let mut records = self
539
+ .records
540
+ .write()
541
+ .map_err(|_| "tenancy store lock poisoned")?;
542
+ let tenant_slug = slug(name);
543
+ if tenant_slug.is_empty()
544
+ || records
545
+ .tenants
546
+ .iter()
547
+ .any(|tenant| tenant.slug == tenant_slug)
548
+ {
549
+ return Err("tenant slug is unavailable".into());
550
+ }
551
+ let timestamp = now();
552
+ let tenant = Tenant {
553
+ id: id("tenant"),
554
+ name: name.into(),
555
+ slug: tenant_slug,
556
+ created_by: creator.into(),
557
+ created_at: timestamp,
558
+ updated_at: timestamp,
559
+ };
560
+ records.tenant_memberships.push(TenantMembership {
561
+ tenant_id: tenant.id.clone(),
562
+ user_id: creator.into(),
563
+ role: "owner".into(),
564
+ status: MembershipStatus::Active,
565
+ created_at: timestamp,
566
+ updated_at: timestamp,
567
+ });
568
+ records.tenants.push(tenant.clone());
569
+ Self::audit(
570
+ &mut records,
571
+ "tenant.created",
572
+ creator,
573
+ &tenant.id,
574
+ None,
575
+ &tenant.id,
576
+ );
577
+ self.persist(&records)?;
578
+ Ok(tenant)
579
+ }
580
+
581
+ pub fn create_application(
582
+ &self,
583
+ actor: &str,
584
+ tenant_id: &str,
585
+ name: &str,
586
+ ) -> Result<Application, String> {
587
+ if !self.authorize_tenant(actor, tenant_id, "applications:write") {
588
+ return Err("forbidden".into());
589
+ }
590
+ let mut records = self
591
+ .records
592
+ .write()
593
+ .map_err(|_| "tenancy store lock poisoned")?;
594
+ let app_slug = slug(name);
595
+ if records
596
+ .applications
597
+ .iter()
598
+ .any(|app| app.tenant_id == tenant_id && app.slug == app_slug)
599
+ {
600
+ return Err("application slug is unavailable".into());
601
+ }
602
+ let timestamp = now();
603
+ let application = Application {
604
+ id: id("app"),
605
+ tenant_id: tenant_id.into(),
606
+ name: name.into(),
607
+ slug: app_slug.clone(),
608
+ namespace: format!("{tenant_id}:{app_slug}"),
609
+ created_by: actor.into(),
610
+ created_at: timestamp,
611
+ updated_at: timestamp,
612
+ status: application_status(),
613
+ active_revision: None,
614
+ environment_revisions: Default::default(),
615
+ };
616
+ records.application_memberships.push(ApplicationMembership {
617
+ application_id: application.id.clone(),
618
+ user_id: actor.into(),
619
+ role: "owner".into(),
620
+ status: MembershipStatus::Active,
621
+ created_at: timestamp,
622
+ updated_at: timestamp,
623
+ });
624
+ records.applications.push(application.clone());
625
+ Self::audit(
626
+ &mut records,
627
+ "application.created",
628
+ actor,
629
+ tenant_id,
630
+ Some(&application.id),
631
+ &application.id,
632
+ );
633
+ self.persist(&records)?;
634
+ Ok(application)
635
+ }
636
+
637
+ pub fn delete_application(&self, actor: &str, application_id: &str) -> Result<(), String> {
638
+ let tenant_id = self
639
+ .application_tenant(application_id)
640
+ .ok_or_else(|| "application not found".to_string())?;
641
+ if !self.authorize_application(actor, &tenant_id, application_id, "applications:write") {
642
+ return Err("forbidden".into());
643
+ }
644
+ let mut records = self
645
+ .records
646
+ .write()
647
+ .map_err(|_| "tenancy store lock poisoned")?;
648
+ Self::audit(
649
+ &mut records,
650
+ "application.deleted",
651
+ actor,
652
+ &tenant_id,
653
+ Some(application_id),
654
+ application_id,
655
+ );
656
+ records
657
+ .applications
658
+ .retain(|application| application.id != application_id);
659
+ records
660
+ .application_memberships
661
+ .retain(|membership| membership.application_id != application_id);
662
+ self.persist(&records)
663
+ }
664
+
665
+ pub fn rename_application(
666
+ &self,
667
+ actor: &str,
668
+ application_id: &str,
669
+ name: &str,
670
+ ) -> Result<Application, String> {
671
+ let tenant_id = self
672
+ .application_tenant(application_id)
673
+ .ok_or_else(|| "application not found".to_string())?;
674
+ if !self.authorize_application(actor, &tenant_id, application_id, "applications:write") {
675
+ return Err("forbidden".into());
676
+ }
677
+ let next_name = name.trim();
678
+ if next_name.is_empty() {
679
+ return Err("application name is required".into());
680
+ }
681
+ let next_slug = slug(next_name);
682
+ let mut records = self
683
+ .records
684
+ .write()
685
+ .map_err(|_| "tenancy store lock poisoned")?;
686
+ if records.applications.iter().any(|app| {
687
+ app.id != application_id && app.tenant_id == tenant_id && app.slug == next_slug
688
+ }) {
689
+ return Err("application slug is unavailable".into());
690
+ }
691
+ let app = records
692
+ .applications
693
+ .iter_mut()
694
+ .find(|app| app.id == application_id)
695
+ .ok_or("application not found")?;
696
+ app.name = next_name.into();
697
+ app.slug = next_slug.clone();
698
+ app.namespace = format!("{tenant_id}:{next_slug}");
699
+ app.updated_at = now();
700
+ let updated = app.clone();
701
+ Self::audit(
702
+ &mut records,
703
+ "application.renamed",
704
+ actor,
705
+ &tenant_id,
706
+ Some(application_id),
707
+ application_id,
708
+ );
709
+ self.persist(&records)?;
710
+ Ok(updated)
711
+ }
712
+
713
+ pub fn set_revision_pointer(
714
+ &self,
715
+ actor: &str,
716
+ tenant_id: &str,
717
+ application_id: &str,
718
+ environment: &str,
719
+ revision_id: &str,
720
+ ) -> Result<Application, String> {
721
+ if !self.authorize_application(
722
+ actor,
723
+ tenant_id,
724
+ application_id,
725
+ "application:revision:promote",
726
+ ) && !self.authorize_application(
727
+ actor,
728
+ tenant_id,
729
+ application_id,
730
+ "application:revision:rollback",
731
+ ) {
732
+ return Err("forbidden".into());
733
+ }
734
+ let mut records = self
735
+ .records
736
+ .write()
737
+ .map_err(|_| "tenancy store lock poisoned")?;
738
+ let app = records
739
+ .applications
740
+ .iter_mut()
741
+ .find(|app| app.id == application_id && app.tenant_id == tenant_id)
742
+ .ok_or("application not found")?;
743
+ app.environment_revisions
744
+ .insert(environment.into(), revision_id.into());
745
+ if environment == "production" {
746
+ app.active_revision = Some(revision_id.into());
747
+ }
748
+ app.updated_at = now();
749
+ let result = app.clone();
750
+ Self::audit(
751
+ &mut records,
752
+ "revision.promoted",
753
+ actor,
754
+ tenant_id,
755
+ Some(application_id),
756
+ revision_id,
757
+ );
758
+ self.persist(&records)?;
759
+ Ok(result)
760
+ }
761
+
762
+ pub fn authorize_tenant(&self, user_id: &str, tenant_id: &str, capability: &str) -> bool {
763
+ if !self.platform_user_active(user_id) {
764
+ return false;
765
+ }
766
+ if platform_owner_user(user_id) {
767
+ return self
768
+ .records
769
+ .read()
770
+ .is_ok_and(|records| records.tenants.iter().any(|tenant| tenant.id == tenant_id));
771
+ }
772
+ let records = match self.records.read() {
773
+ Ok(records) => records,
774
+ Err(_) => return false,
775
+ };
776
+ let memberships: Vec<Membership> = records
777
+ .tenant_memberships
778
+ .iter()
779
+ .map(|membership| Membership {
780
+ subject_id: membership.user_id.clone(),
781
+ tenant_id: membership.tenant_id.clone(),
782
+ application_id: None,
783
+ roles: vec![membership.role.clone()],
784
+ revoked: membership.status == MembershipStatus::Revoked,
785
+ })
786
+ .collect();
787
+ authorize(&AuthorizationRequest {
788
+ subject: &Subject::User(user_id.into()),
789
+ tenant_id,
790
+ application_id: None,
791
+ capability,
792
+ direct_grants: &[],
793
+ memberships: &memberships,
794
+ roles: &tenant_role_grants(),
795
+ })
796
+ .allowed
797
+ }
798
+
799
+ pub fn authorize_application(
800
+ &self,
801
+ user_id: &str,
802
+ tenant_id: &str,
803
+ application_id: &str,
804
+ capability: &str,
805
+ ) -> bool {
806
+ if !self.platform_user_active(user_id) {
807
+ return false;
808
+ }
809
+ let records = match self.records.read() {
810
+ Ok(records) => records,
811
+ Err(_) => return false,
812
+ };
813
+ if !records
814
+ .applications
815
+ .iter()
816
+ .any(|app| app.id == application_id && app.tenant_id == tenant_id)
817
+ {
818
+ return false;
819
+ }
820
+ if platform_owner_user(user_id) {
821
+ return true;
822
+ }
823
+ let memberships: Vec<Membership> = records
824
+ .application_memberships
825
+ .iter()
826
+ .filter_map(|membership| {
827
+ let app = records
828
+ .applications
829
+ .iter()
830
+ .find(|app| app.id == membership.application_id)?;
831
+ Some(Membership {
832
+ subject_id: membership.user_id.clone(),
833
+ tenant_id: app.tenant_id.clone(),
834
+ application_id: Some(membership.application_id.clone()),
835
+ roles: vec![membership.role.clone()],
836
+ revoked: membership.status == MembershipStatus::Revoked,
837
+ })
838
+ })
839
+ .collect();
840
+ authorize(&AuthorizationRequest {
841
+ subject: &Subject::User(user_id.into()),
842
+ tenant_id,
843
+ application_id: Some(application_id),
844
+ capability,
845
+ direct_grants: &[],
846
+ memberships: &memberships,
847
+ roles: &application_role_grants(),
848
+ })
849
+ .allowed
850
+ }
851
+
852
+ pub fn invite(
853
+ &self,
854
+ actor: &str,
855
+ tenant_id: &str,
856
+ application_id: Option<&str>,
857
+ email: &str,
858
+ role: &str,
859
+ ttl_seconds: u64,
860
+ ) -> Result<(Invitation, String), String> {
861
+ let capability = "members:invite";
862
+ let allowed = application_id
863
+ .map(|app| self.authorize_application(actor, tenant_id, app, capability))
864
+ .unwrap_or_else(|| self.authorize_tenant(actor, tenant_id, capability));
865
+ if !allowed {
866
+ return Err("forbidden".into());
867
+ }
868
+ let token: String = rand::thread_rng()
869
+ .sample_iter(&Alphanumeric)
870
+ .take(48)
871
+ .map(char::from)
872
+ .collect();
873
+ let token_hash = Argon2::default()
874
+ .hash_password(token.as_bytes(), &SaltString::generate(&mut OsRng))
875
+ .map_err(|error| error.to_string())?
876
+ .to_string();
877
+ let invitation = Invitation {
878
+ id: id("invite"),
879
+ tenant_id: tenant_id.into(),
880
+ application_id: application_id.map(str::to_string),
881
+ email: email.trim().to_lowercase(),
882
+ role: role.into(),
883
+ invited_by: actor.into(),
884
+ token_hash,
885
+ expires_at: now() + ttl_seconds,
886
+ accepted_at: None,
887
+ revoked_at: None,
888
+ };
889
+ let mut records = self
890
+ .records
891
+ .write()
892
+ .map_err(|_| "tenancy store lock poisoned")?;
893
+ records.invitations.push(invitation.clone());
894
+ Self::audit(
895
+ &mut records,
896
+ if application_id.is_some() {
897
+ "application.member_invited"
898
+ } else {
899
+ "member.invited"
900
+ },
901
+ actor,
902
+ tenant_id,
903
+ application_id,
904
+ &invitation.email,
905
+ );
906
+ self.persist(&records)?;
907
+ Ok((invitation, token))
908
+ }
909
+
910
+ pub fn accept_invitation(&self, user_id: &str, email: &str, token: &str) -> Result<(), String> {
911
+ let mut records = self
912
+ .records
913
+ .write()
914
+ .map_err(|_| "tenancy store lock poisoned")?;
915
+ let timestamp = now();
916
+ let index = records
917
+ .invitations
918
+ .iter()
919
+ .position(|invitation| {
920
+ invitation.email == email.trim().to_lowercase()
921
+ && invitation.accepted_at.is_none()
922
+ && invitation.revoked_at.is_none()
923
+ && invitation.expires_at > timestamp
924
+ && PasswordHash::new(&invitation.token_hash)
925
+ .ok()
926
+ .map(|hash| {
927
+ Argon2::default()
928
+ .verify_password(token.as_bytes(), &hash)
929
+ .is_ok()
930
+ })
931
+ .unwrap_or(false)
932
+ })
933
+ .ok_or("invitation is invalid or expired")?;
934
+ let invitation = records.invitations[index].clone();
935
+ if let Some(application_id) = &invitation.application_id {
936
+ if !records.application_memberships.iter().any(|membership| {
937
+ membership.application_id == *application_id
938
+ && membership.user_id == user_id
939
+ && membership.status == MembershipStatus::Active
940
+ }) {
941
+ records.application_memberships.push(ApplicationMembership {
942
+ application_id: application_id.clone(),
943
+ user_id: user_id.into(),
944
+ role: invitation.role.clone(),
945
+ status: MembershipStatus::Active,
946
+ created_at: timestamp,
947
+ updated_at: timestamp,
948
+ });
949
+ }
950
+ } else if !records.tenant_memberships.iter().any(|membership| {
951
+ membership.tenant_id == invitation.tenant_id
952
+ && membership.user_id == user_id
953
+ && membership.status == MembershipStatus::Active
954
+ }) {
955
+ records.tenant_memberships.push(TenantMembership {
956
+ tenant_id: invitation.tenant_id.clone(),
957
+ user_id: user_id.into(),
958
+ role: invitation.role.clone(),
959
+ status: MembershipStatus::Active,
960
+ created_at: timestamp,
961
+ updated_at: timestamp,
962
+ });
963
+ }
964
+ records.invitations[index].accepted_at = Some(timestamp);
965
+ Self::audit(
966
+ &mut records,
967
+ "member.accepted",
968
+ user_id,
969
+ &invitation.tenant_id,
970
+ invitation.application_id.as_deref(),
971
+ user_id,
972
+ );
973
+ self.persist(&records)
974
+ }
975
+
976
+ pub fn revoke_application_member(
977
+ &self,
978
+ actor: &str,
979
+ tenant_id: &str,
980
+ application_id: &str,
981
+ user_id: &str,
982
+ ) -> Result<(), String> {
983
+ if !self.authorize_application(actor, tenant_id, application_id, "members:write") {
984
+ return Err("forbidden".into());
985
+ }
986
+ let mut records = self
987
+ .records
988
+ .write()
989
+ .map_err(|_| "tenancy store lock poisoned")?;
990
+ let membership = records
991
+ .application_memberships
992
+ .iter_mut()
993
+ .find(|membership| {
994
+ membership.application_id == application_id && membership.user_id == user_id
995
+ })
996
+ .ok_or("membership not found")?;
997
+ membership.status = MembershipStatus::Revoked;
998
+ membership.updated_at = now();
999
+ Self::audit(
1000
+ &mut records,
1001
+ "application.member_removed",
1002
+ actor,
1003
+ tenant_id,
1004
+ Some(application_id),
1005
+ user_id,
1006
+ );
1007
+ self.persist(&records)
1008
+ }
1009
+ }
1010
+
1011
+ #[cfg(test)]
1012
+ mod tests {
1013
+ use super::*;
1014
+ fn store(name: &str) -> TenancyStore {
1015
+ let path = std::env::temp_dir().join(format!("feltdb-{name}-{}.json", id("test")));
1016
+ TenancyStore::load(path).unwrap()
1017
+ }
1018
+
1019
+ #[test]
1020
+ fn tenant_and_application_are_isolated() {
1021
+ let store = store("isolation");
1022
+ let tenant_a = store.create_tenant("user_a", "Acme").unwrap();
1023
+ let tenant_b = store.create_tenant("user_b", "Beta").unwrap();
1024
+ let app_a = store
1025
+ .create_application("user_a", &tenant_a.id, "Operations")
1026
+ .unwrap();
1027
+ let app_b = store
1028
+ .create_application("user_b", &tenant_b.id, "Analytics")
1029
+ .unwrap();
1030
+ assert!(store.authorize_application("user_a", &tenant_a.id, &app_a.id, "state:write"));
1031
+ assert!(!store.authorize_application("user_a", &tenant_b.id, &app_b.id, "state:read"));
1032
+ assert!(!store.authorize_application("user_a", &tenant_a.id, &app_b.id, "state:read"));
1033
+ }
1034
+
1035
+ #[test]
1036
+ fn invitation_acceptance_and_revocation_change_authorization() {
1037
+ let store = store("invitation");
1038
+ let tenant = store.create_tenant("owner", "Acme").unwrap();
1039
+ let app = store
1040
+ .create_application("owner", &tenant.id, "Support")
1041
+ .unwrap();
1042
+ let (_, token) = store
1043
+ .invite(
1044
+ "owner",
1045
+ &tenant.id,
1046
+ Some(&app.id),
1047
+ "viewer@example.com",
1048
+ "viewer",
1049
+ 3600,
1050
+ )
1051
+ .unwrap();
1052
+ assert!(!store.authorize_application("viewer", &tenant.id, &app.id, "state:read"));
1053
+ store
1054
+ .accept_invitation("viewer", "viewer@example.com", &token)
1055
+ .unwrap();
1056
+ assert!(store.authorize_application("viewer", &tenant.id, &app.id, "state:read"));
1057
+ assert!(!store.authorize_application("viewer", &tenant.id, &app.id, "state:write"));
1058
+ store
1059
+ .revoke_application_member("owner", &tenant.id, &app.id, "viewer")
1060
+ .unwrap();
1061
+ assert!(!store.authorize_application("viewer", &tenant.id, &app.id, "state:read"));
1062
+ }
1063
+
1064
+ #[test]
1065
+ fn revision_promotion_is_stronger_than_draft_editing() {
1066
+ let store = store("revision-roles");
1067
+ let tenant = store.create_tenant("owner", "Acme").unwrap();
1068
+ let app = store
1069
+ .create_application("owner", &tenant.id, "Studio")
1070
+ .unwrap();
1071
+ for (user, role) in [("editor", "editor"), ("promoter", "promoter")] {
1072
+ let email = format!("{user}@example.com");
1073
+ let (_, token) = store
1074
+ .invite("owner", &tenant.id, Some(&app.id), &email, role, 3600)
1075
+ .unwrap();
1076
+ store.accept_invitation(user, &email, &token).unwrap();
1077
+ }
1078
+ assert!(store.authorize_application(
1079
+ "editor",
1080
+ &tenant.id,
1081
+ &app.id,
1082
+ "application:revision:create"
1083
+ ));
1084
+ assert!(!store.authorize_application(
1085
+ "editor",
1086
+ &tenant.id,
1087
+ &app.id,
1088
+ "application:revision:promote"
1089
+ ));
1090
+ assert!(store.authorize_application(
1091
+ "promoter",
1092
+ &tenant.id,
1093
+ &app.id,
1094
+ "application:revision:promote"
1095
+ ));
1096
+ }
1097
+ }