@feltdb/core 0.8.4 → 0.8.5
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/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +507 -6
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +326 -6
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-j8IlhNqJ.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -31,7 +31,7 @@ use feltdb::{
|
|
|
31
31
|
sync_contract::SyncStore,
|
|
32
32
|
worker_mesh::WorkerMeshStore,
|
|
33
33
|
workload::WorkloadStore,
|
|
34
|
-
FeltDb,
|
|
34
|
+
FeltDb, StateTriggerStore,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
#[derive(Clone)]
|
|
@@ -72,6 +72,7 @@ pub struct AppState {
|
|
|
72
72
|
pub grants: Arc<std::sync::Mutex<GrantStore>>,
|
|
73
73
|
pub sync: Arc<std::sync::Mutex<SyncStore>>,
|
|
74
74
|
pub workloads: Arc<std::sync::Mutex<WorkloadStore>>,
|
|
75
|
+
pub state_triggers: Arc<std::sync::Mutex<StateTriggerStore>>,
|
|
75
76
|
pub mesh: Arc<std::sync::Mutex<WorkerMeshStore>>,
|
|
76
77
|
pub readiness_probe: Arc<PathBuf>,
|
|
77
78
|
pub bounded_query_cursors: Arc<std::sync::Mutex<HashMap<String, BoundedQueryCursor>>>,
|
|
@@ -40,6 +40,17 @@ pub struct ApiKeyRecord {
|
|
|
40
40
|
pub namespaces: Vec<String>,
|
|
41
41
|
#[serde(default)]
|
|
42
42
|
pub revoked: bool,
|
|
43
|
+
/// The worker this credential *is*, when it is a worker credential.
|
|
44
|
+
///
|
|
45
|
+
/// A key without one authenticates as a service, which is what every key
|
|
46
|
+
/// did before this field existed and what every key without it still does.
|
|
47
|
+
/// A key with one authenticates as `Subject::Worker(worker_id)` and is
|
|
48
|
+
/// thereby **narrower**, not wider: the worker lifecycle routes compare the
|
|
49
|
+
/// authenticated subject against the `worker_id` in the request body, so
|
|
50
|
+
/// such a credential can act as exactly one worker and no other, and cannot
|
|
51
|
+
/// satisfy a check that requires a human or service subject.
|
|
52
|
+
#[serde(default)]
|
|
53
|
+
pub worker_id: Option<String>,
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
#[derive(Debug, Clone, Serialize)]
|
|
@@ -104,6 +115,9 @@ impl KeyStore {
|
|
|
104
115
|
scopes: token.scopes,
|
|
105
116
|
namespaces: vec![token.namespace],
|
|
106
117
|
revoked: false,
|
|
118
|
+
// Legacy credentials predate worker identity and
|
|
119
|
+
// stay services, which is what they already were.
|
|
120
|
+
worker_id: None,
|
|
107
121
|
});
|
|
108
122
|
}
|
|
109
123
|
(records, true)
|
|
@@ -141,12 +155,25 @@ impl KeyStore {
|
|
|
141
155
|
.iter()
|
|
142
156
|
.any(|allowed| allowed == "*" || allowed == namespace);
|
|
143
157
|
if namespace_allowed && verify_api_key(token, &record.hash) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
// The credential decides the subject type. A worker
|
|
159
|
+
// credential can never present as a service and a service
|
|
160
|
+
// credential can never present as a worker, because neither
|
|
161
|
+
// the request nor any header participates in this choice.
|
|
162
|
+
Some(match &record.worker_id {
|
|
163
|
+
Some(worker) => Principal {
|
|
164
|
+
key_id: record.id.clone(),
|
|
165
|
+
scopes: record.scopes.clone(),
|
|
166
|
+
subject_type: "worker".into(),
|
|
167
|
+
identity_id: Some(worker.clone()),
|
|
168
|
+
session_id: None,
|
|
169
|
+
},
|
|
170
|
+
None => Principal {
|
|
171
|
+
key_id: record.id.clone(),
|
|
172
|
+
scopes: record.scopes.clone(),
|
|
173
|
+
subject_type: "service".into(),
|
|
174
|
+
identity_id: None,
|
|
175
|
+
session_id: None,
|
|
176
|
+
},
|
|
150
177
|
})
|
|
151
178
|
} else {
|
|
152
179
|
None
|
|
@@ -159,6 +186,21 @@ impl KeyStore {
|
|
|
159
186
|
name: String,
|
|
160
187
|
scopes: Vec<String>,
|
|
161
188
|
namespaces: Vec<String>,
|
|
189
|
+
) -> Result<CreatedApiKey, String> {
|
|
190
|
+
self.create_for_worker(name, scopes, namespaces, None)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Issue a credential that authenticates as one specific worker.
|
|
194
|
+
///
|
|
195
|
+
/// `worker_id` of `None` produces an ordinary service credential, so this
|
|
196
|
+
/// is the one construction path and [`KeyStore::create`] is the common case
|
|
197
|
+
/// spelled out.
|
|
198
|
+
pub fn create_for_worker(
|
|
199
|
+
&self,
|
|
200
|
+
name: String,
|
|
201
|
+
scopes: Vec<String>,
|
|
202
|
+
namespaces: Vec<String>,
|
|
203
|
+
worker_id: Option<String>,
|
|
162
204
|
) -> Result<CreatedApiKey, String> {
|
|
163
205
|
let secret: String = rand::thread_rng()
|
|
164
206
|
.sample_iter(&Alphanumeric)
|
|
@@ -185,6 +227,7 @@ impl KeyStore {
|
|
|
185
227
|
scopes,
|
|
186
228
|
namespaces,
|
|
187
229
|
revoked: false,
|
|
230
|
+
worker_id,
|
|
188
231
|
});
|
|
189
232
|
self.persist()?;
|
|
190
233
|
Ok(CreatedApiKey { id, secret: token })
|
|
@@ -266,13 +309,11 @@ fn verify_api_key(token: &str, stored_hash: &str) -> bool {
|
|
|
266
309
|
.is_ok();
|
|
267
310
|
}
|
|
268
311
|
|
|
269
|
-
PasswordHash::new(stored_hash)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
.is_ok()
|
|
275
|
-
})
|
|
312
|
+
PasswordHash::new(stored_hash).ok().is_some_and(|parsed| {
|
|
313
|
+
Argon2::default()
|
|
314
|
+
.verify_password(token.as_bytes(), &parsed)
|
|
315
|
+
.is_ok()
|
|
316
|
+
})
|
|
276
317
|
}
|
|
277
318
|
|
|
278
319
|
#[cfg(test)]
|
|
@@ -341,4 +382,114 @@ mod tests {
|
|
|
341
382
|
assert!(migrated.contains("managed-instance"));
|
|
342
383
|
let _ = fs::remove_file(path);
|
|
343
384
|
}
|
|
385
|
+
|
|
386
|
+
// ------------------------------------------------------------------
|
|
387
|
+
// Worker identity.
|
|
388
|
+
//
|
|
389
|
+
// The whole worker security boundary rests on one thing: which subject a
|
|
390
|
+
// credential authenticates as. That is decided here, by the stored
|
|
391
|
+
// credential alone — no request field, header or body participates — so
|
|
392
|
+
// these tests are the root of the boundary the workload lifecycle routes
|
|
393
|
+
// enforce further up.
|
|
394
|
+
// ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
fn key_store(name: &str) -> (PathBuf, KeyStore) {
|
|
397
|
+
let path = std::env::temp_dir().join(format!(
|
|
398
|
+
"feltdb-worker-identity-{name}-{}-{}.json",
|
|
399
|
+
std::process::id(),
|
|
400
|
+
rand::random::<u64>()
|
|
401
|
+
));
|
|
402
|
+
let _ = fs::remove_file(&path);
|
|
403
|
+
let store = KeyStore::load(&path).unwrap();
|
|
404
|
+
(path, store)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#[test]
|
|
408
|
+
fn a_worker_credential_authenticates_as_that_one_worker() {
|
|
409
|
+
let (path, store) = key_store("worker");
|
|
410
|
+
let key = store
|
|
411
|
+
.create_for_worker(
|
|
412
|
+
"extractor".into(),
|
|
413
|
+
vec!["state:read".into()],
|
|
414
|
+
vec!["default".into()],
|
|
415
|
+
Some("worker-x".into()),
|
|
416
|
+
)
|
|
417
|
+
.unwrap();
|
|
418
|
+
|
|
419
|
+
let principal = store
|
|
420
|
+
.authenticate(&key.secret, "default")
|
|
421
|
+
.expect("authenticates");
|
|
422
|
+
assert_eq!(principal.subject_type, "worker");
|
|
423
|
+
assert_eq!(
|
|
424
|
+
principal.identity_id.as_deref(),
|
|
425
|
+
Some("worker-x"),
|
|
426
|
+
"the worker it is, not the key it uses, is the identity"
|
|
427
|
+
);
|
|
428
|
+
let _ = fs::remove_file(path);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[test]
|
|
432
|
+
fn a_service_credential_cannot_present_as_a_worker() {
|
|
433
|
+
let (path, store) = key_store("service");
|
|
434
|
+
let key = store
|
|
435
|
+
.create(
|
|
436
|
+
"control-plane".into(),
|
|
437
|
+
vec!["state:read".into()],
|
|
438
|
+
vec!["default".into()],
|
|
439
|
+
)
|
|
440
|
+
.unwrap();
|
|
441
|
+
|
|
442
|
+
let principal = store
|
|
443
|
+
.authenticate(&key.secret, "default")
|
|
444
|
+
.expect("authenticates");
|
|
445
|
+
assert_eq!(
|
|
446
|
+
principal.subject_type, "service",
|
|
447
|
+
"a key issued without a worker is a service, exactly as before this field existed"
|
|
448
|
+
);
|
|
449
|
+
assert_eq!(principal.identity_id, None);
|
|
450
|
+
let _ = fs::remove_file(path);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#[test]
|
|
454
|
+
fn worker_identity_survives_a_restart_of_the_credential_store() {
|
|
455
|
+
let (path, store) = key_store("reload");
|
|
456
|
+
let key = store
|
|
457
|
+
.create_for_worker(
|
|
458
|
+
"extractor".into(),
|
|
459
|
+
vec!["state:read".into()],
|
|
460
|
+
vec!["default".into()],
|
|
461
|
+
Some("worker-x".into()),
|
|
462
|
+
)
|
|
463
|
+
.unwrap();
|
|
464
|
+
drop(store);
|
|
465
|
+
|
|
466
|
+
// A credential that forgot which worker it was on restart would silently
|
|
467
|
+
// downgrade to a service and start failing the identity check.
|
|
468
|
+
let reopened = KeyStore::load(&path).unwrap();
|
|
469
|
+
let principal = reopened
|
|
470
|
+
.authenticate(&key.secret, "default")
|
|
471
|
+
.expect("authenticates");
|
|
472
|
+
assert_eq!(principal.subject_type, "worker");
|
|
473
|
+
assert_eq!(principal.identity_id.as_deref(), Some("worker-x"));
|
|
474
|
+
let _ = fs::remove_file(path);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#[test]
|
|
478
|
+
fn a_revoked_worker_credential_authenticates_as_nothing() {
|
|
479
|
+
let (path, store) = key_store("revoked");
|
|
480
|
+
let key = store
|
|
481
|
+
.create_for_worker(
|
|
482
|
+
"extractor".into(),
|
|
483
|
+
vec!["state:read".into()],
|
|
484
|
+
vec!["default".into()],
|
|
485
|
+
Some("worker-x".into()),
|
|
486
|
+
)
|
|
487
|
+
.unwrap();
|
|
488
|
+
assert!(store.revoke(&key.id).unwrap());
|
|
489
|
+
assert!(
|
|
490
|
+
store.authenticate(&key.secret, "default").is_none(),
|
|
491
|
+
"revocation is not weakened by carrying a worker identity"
|
|
492
|
+
);
|
|
493
|
+
let _ = fs::remove_file(path);
|
|
494
|
+
}
|
|
344
495
|
}
|
|
@@ -62,7 +62,7 @@ use feltdb::{
|
|
|
62
62
|
},
|
|
63
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
64
64
|
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
-
PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StateTriggerStore, StoredRow,
|
|
66
66
|
};
|
|
67
67
|
use feltdb_server::{
|
|
68
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -1421,9 +1421,10 @@ struct WorkerReconcileRequest {
|
|
|
1421
1421
|
fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
1422
1422
|
let status = match error.code.as_str() {
|
|
1423
1423
|
"WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
|
1424
|
-
"WORKLOAD_STALE_FENCE"
|
|
1425
|
-
|
|
1426
|
-
|
|
1424
|
+
"WORKLOAD_STALE_FENCE"
|
|
1425
|
+
| "WORKLOAD_TRANSITION_DENIED"
|
|
1426
|
+
| "WORKLOAD_NOT_READY"
|
|
1427
|
+
| "WORKLOAD_NOT_YET_ELIGIBLE" => StatusCode::CONFLICT,
|
|
1427
1428
|
"WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
|
|
1428
1429
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
1429
1430
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
@@ -8289,6 +8290,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8289
8290
|
if manage_workloads()? {
|
|
8290
8291
|
return Ok(());
|
|
8291
8292
|
}
|
|
8293
|
+
if manage_state_triggers()? {
|
|
8294
|
+
return Ok(());
|
|
8295
|
+
}
|
|
8292
8296
|
if manage_backup()? {
|
|
8293
8297
|
return Ok(());
|
|
8294
8298
|
}
|
|
@@ -8336,6 +8340,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8336
8340
|
let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
|
|
8337
8341
|
let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
|
|
8338
8342
|
let workload_store = WorkloadStore::load(config.data.with_extension("workloads.json"))?;
|
|
8343
|
+
let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
|
|
8339
8344
|
let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
|
|
8340
8345
|
let content_store = ContentStore::new(config.data.with_extension("content"))?;
|
|
8341
8346
|
let artifact_store = ArtifactStore::load(
|
|
@@ -8403,6 +8408,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8403
8408
|
grants: Arc::new(std::sync::Mutex::new(grant_store)),
|
|
8404
8409
|
sync: Arc::new(std::sync::Mutex::new(sync_store)),
|
|
8405
8410
|
workloads: Arc::new(std::sync::Mutex::new(workload_store)),
|
|
8411
|
+
state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
|
|
8406
8412
|
mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
|
|
8407
8413
|
readiness_probe: Arc::new(readiness_probe),
|
|
8408
8414
|
bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
@@ -9566,10 +9572,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9566
9572
|
.map(str::to_string)
|
|
9567
9573
|
.collect()
|
|
9568
9574
|
};
|
|
9569
|
-
|
|
9575
|
+
// `--worker <id>` issues a credential that authenticates as that
|
|
9576
|
+
// one worker rather than as a service. It is a narrowing: the
|
|
9577
|
+
// worker lifecycle routes accept it only for its own worker id, and
|
|
9578
|
+
// it cannot satisfy a check that wants a human or a service.
|
|
9579
|
+
let key = store.create_for_worker(
|
|
9570
9580
|
name,
|
|
9571
9581
|
values("--scope", "state:read,state:write,events:read"),
|
|
9572
9582
|
values("--namespace", "default"),
|
|
9583
|
+
option("--worker"),
|
|
9573
9584
|
)?;
|
|
9574
9585
|
println!(
|
|
9575
9586
|
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
@@ -9584,7 +9595,7 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9584
9595
|
}
|
|
9585
9596
|
println!("API key revoked: {id}");
|
|
9586
9597
|
}
|
|
9587
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--id key-id]".into()),
|
|
9598
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
9588
9599
|
}
|
|
9589
9600
|
Ok(true)
|
|
9590
9601
|
}
|
|
@@ -9682,6 +9693,61 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9682
9693
|
Ok(true)
|
|
9683
9694
|
}
|
|
9684
9695
|
|
|
9696
|
+
fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
|
|
9697
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
9698
|
+
if arguments.get(1).map(String::as_str) != Some("trigger") {
|
|
9699
|
+
return Ok(false);
|
|
9700
|
+
}
|
|
9701
|
+
let command = arguments.get(2).map(String::as_str).unwrap_or("list");
|
|
9702
|
+
let option = |name: &str| {
|
|
9703
|
+
arguments
|
|
9704
|
+
.iter()
|
|
9705
|
+
.position(|value| value == name)
|
|
9706
|
+
.and_then(|index| arguments.get(index + 1))
|
|
9707
|
+
.cloned()
|
|
9708
|
+
};
|
|
9709
|
+
let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
9710
|
+
let trigger_path = PathBuf::from(option("--store").unwrap_or_else(|| {
|
|
9711
|
+
data.with_extension("triggers.json")
|
|
9712
|
+
.to_string_lossy()
|
|
9713
|
+
.into_owned()
|
|
9714
|
+
}));
|
|
9715
|
+
let mut triggers = StateTriggerStore::load(trigger_path)?;
|
|
9716
|
+
match command {
|
|
9717
|
+
"define" => {
|
|
9718
|
+
let input = option("--input").ok_or("trigger define requires --input trigger.json")?;
|
|
9719
|
+
let trigger: feltdb::StateTrigger = serde_json::from_slice(&std::fs::read(input)?)?;
|
|
9720
|
+
triggers.define(trigger)?;
|
|
9721
|
+
println!("{}", serde_json::to_string_pretty(&triggers.list())?);
|
|
9722
|
+
}
|
|
9723
|
+
"list" => println!("{}", serde_json::to_string_pretty(&triggers.list())?),
|
|
9724
|
+
"status" => println!(
|
|
9725
|
+
"{}",
|
|
9726
|
+
serde_json::to_string_pretty(&json!({
|
|
9727
|
+
"triggers": triggers.triggers.len(),
|
|
9728
|
+
"cursors": triggers.cursor_versions(),
|
|
9729
|
+
}))?
|
|
9730
|
+
),
|
|
9731
|
+
"evaluate" => {
|
|
9732
|
+
let db = FeltDb::open(&data)?;
|
|
9733
|
+
let workload_path = PathBuf::from(
|
|
9734
|
+
option("--workload-store")
|
|
9735
|
+
.unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
|
|
9736
|
+
);
|
|
9737
|
+
let mut workloads = WorkloadStore::load(workload_path)?;
|
|
9738
|
+
let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
|
|
9739
|
+
println!("{}", serde_json::to_string_pretty(&produced)?);
|
|
9740
|
+
}
|
|
9741
|
+
_ => {
|
|
9742
|
+
return Err(
|
|
9743
|
+
"usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--workload-store path] [--input trigger.json]"
|
|
9744
|
+
.into(),
|
|
9745
|
+
)
|
|
9746
|
+
}
|
|
9747
|
+
}
|
|
9748
|
+
Ok(true)
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9685
9751
|
fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
9686
9752
|
let arguments: Vec<String> = std::env::args().collect();
|
|
9687
9753
|
if arguments.get(1).map(String::as_str) != Some("backup") {
|
|
@@ -10103,6 +10169,197 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
10103
10169
|
}
|
|
10104
10170
|
execute_agent_run(state, &row.value, &worker).await?;
|
|
10105
10171
|
}
|
|
10172
|
+
|
|
10173
|
+
evaluate_state_triggers(state)?;
|
|
10174
|
+
execute_eligible_workloads(state, &worker)?;
|
|
10175
|
+
Ok(())
|
|
10176
|
+
}
|
|
10177
|
+
|
|
10178
|
+
/// How long an autonomous claim holds its lease.
|
|
10179
|
+
///
|
|
10180
|
+
/// Long enough that an ordinary capability program finishes inside it, short
|
|
10181
|
+
/// enough that a worker dying mid-execution returns the work quickly. The lease
|
|
10182
|
+
/// is the only thing that makes worker death recoverable, so this is a real
|
|
10183
|
+
/// bound rather than a formality.
|
|
10184
|
+
const WORKLOAD_LEASE_MS: u64 = 30_000;
|
|
10185
|
+
|
|
10186
|
+
fn state_trigger_store(
|
|
10187
|
+
state: &AppState,
|
|
10188
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::StateTriggerStore>, ApiError> {
|
|
10189
|
+
state.state_triggers.lock().map_err(|_| {
|
|
10190
|
+
ApiError(
|
|
10191
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10192
|
+
"state trigger store unavailable".into(),
|
|
10193
|
+
)
|
|
10194
|
+
})
|
|
10195
|
+
}
|
|
10196
|
+
|
|
10197
|
+
fn state_trigger_error(error: feltdb::StateTriggerError) -> ApiError {
|
|
10198
|
+
let status = match error.code.as_str() {
|
|
10199
|
+
"TRIGGER_INVALID" | "TRIGGER_UNAUTHORIZED" => StatusCode::BAD_REQUEST,
|
|
10200
|
+
"WORKLOAD_UNAUTHORIZED" => StatusCode::FORBIDDEN,
|
|
10201
|
+
"TRIGGER_STATE_REPLAY_FAILED" | "TRIGGER_STORAGE_FAILURE" | "WORKLOAD_STORAGE_FAILURE" => {
|
|
10202
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
10203
|
+
}
|
|
10204
|
+
_ => StatusCode::CONFLICT,
|
|
10205
|
+
};
|
|
10206
|
+
ApiError(
|
|
10207
|
+
status,
|
|
10208
|
+
serde_json::to_string(&error).unwrap_or(error.message),
|
|
10209
|
+
)
|
|
10210
|
+
}
|
|
10211
|
+
|
|
10212
|
+
fn evaluate_state_triggers(state: &AppState) -> Result<(), ApiError> {
|
|
10213
|
+
let mut triggers = state_trigger_store(state)?;
|
|
10214
|
+
if triggers.triggers.is_empty() {
|
|
10215
|
+
return Ok(());
|
|
10216
|
+
}
|
|
10217
|
+
let mut workloads = workload_store(state)?;
|
|
10218
|
+
triggers
|
|
10219
|
+
.evaluate(&state.db, &mut workloads, unix_seconds_i64())
|
|
10220
|
+
.map_err(state_trigger_error)?;
|
|
10221
|
+
Ok(())
|
|
10222
|
+
}
|
|
10223
|
+
|
|
10224
|
+
/// The durable workload store, or a service error if its lock is poisoned.
|
|
10225
|
+
fn workload_store(
|
|
10226
|
+
state: &AppState,
|
|
10227
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::workload::WorkloadStore>, ApiError> {
|
|
10228
|
+
state.workloads.lock().map_err(|_| {
|
|
10229
|
+
ApiError(
|
|
10230
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10231
|
+
"workload store unavailable".into(),
|
|
10232
|
+
)
|
|
10233
|
+
})
|
|
10234
|
+
}
|
|
10235
|
+
|
|
10236
|
+
/// The capabilities this node can actually run.
|
|
10237
|
+
///
|
|
10238
|
+
/// This is what the worker presents to [`WorkloadStore::claim`], which requires
|
|
10239
|
+
/// the workload's `capability_snapshot` to be a subset of it. That check is
|
|
10240
|
+
/// therefore load-bearing rather than ceremonial: a workload whose declared
|
|
10241
|
+
/// capabilities this node cannot execute is never claimed by it, and stays
|
|
10242
|
+
/// `Ready` for a node that can.
|
|
10243
|
+
fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
|
|
10244
|
+
let mut capabilities: BTreeSet<String> = ["search", "identity"]
|
|
10245
|
+
.into_iter()
|
|
10246
|
+
.map(str::to_string)
|
|
10247
|
+
.collect();
|
|
10248
|
+
for row in state.db.list_collection("_flow_capabilities")? {
|
|
10249
|
+
if let Some((_, name)) = row.key.split_once(':') {
|
|
10250
|
+
capabilities.insert(name.to_string());
|
|
10251
|
+
}
|
|
10252
|
+
}
|
|
10253
|
+
Ok(capabilities)
|
|
10254
|
+
}
|
|
10255
|
+
|
|
10256
|
+
/// Claim and execute the durable workloads this node is eligible to run.
|
|
10257
|
+
///
|
|
10258
|
+
/// This is the composition that makes `WorkloadStore` an execution primitive
|
|
10259
|
+
/// rather than a work data model. It adds no queue and no worker system: the
|
|
10260
|
+
/// scan is the pass that already runs, the claim is the existing lease and
|
|
10261
|
+
/// fencing, the execution is the existing capability interpreter, and the
|
|
10262
|
+
/// transitions are the existing state machine.
|
|
10263
|
+
///
|
|
10264
|
+
/// Temporal eligibility needs no special handling here — `Workload::is_eligible_at`
|
|
10265
|
+
/// filters candidates and `WorkloadStore::claim` refuses anything early, so
|
|
10266
|
+
/// `not_before` and retry backoff gate this path by construction.
|
|
10267
|
+
///
|
|
10268
|
+
/// The store lock is released before execution. A capability program reads
|
|
10269
|
+
/// application state through the same database, so holding the workload lock
|
|
10270
|
+
/// across it would make every other workload operation wait on unrelated work.
|
|
10271
|
+
fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
|
|
10272
|
+
let capabilities = worker_capabilities(state)?;
|
|
10273
|
+
let now = unix_seconds_i64();
|
|
10274
|
+
let candidates: Vec<(String, String, Value)> = {
|
|
10275
|
+
let store = workload_store(state)?;
|
|
10276
|
+
store
|
|
10277
|
+
.workloads
|
|
10278
|
+
.values()
|
|
10279
|
+
// Selection, not enforcement. `WorkloadStore::claim` independently
|
|
10280
|
+
// refuses work that is early or beyond this node's capabilities, and
|
|
10281
|
+
// that is where the guarantee lives; filtering here only avoids
|
|
10282
|
+
// attempting a claim, every pass, that is known to be refused.
|
|
10283
|
+
.filter(|workload| {
|
|
10284
|
+
workload.state == feltdb::workload::WorkloadState::Ready
|
|
10285
|
+
&& workload.is_eligible_at(now)
|
|
10286
|
+
&& workload.capability_snapshot.is_subset(&capabilities)
|
|
10287
|
+
})
|
|
10288
|
+
.map(|workload| {
|
|
10289
|
+
(
|
|
10290
|
+
workload.workload_id.clone(),
|
|
10291
|
+
workload.definition_id.clone(),
|
|
10292
|
+
workload.input.clone(),
|
|
10293
|
+
)
|
|
10294
|
+
})
|
|
10295
|
+
.collect()
|
|
10296
|
+
};
|
|
10297
|
+
|
|
10298
|
+
for (id, definition, input) in candidates {
|
|
10299
|
+
// Unlike the filter above, this one is enforcement: nothing downstream
|
|
10300
|
+
// knows whether `definition_id` names something runnable. Claiming a
|
|
10301
|
+
// workload only to dead-letter it would consume work another node could
|
|
10302
|
+
// have executed, so an unrunnable definition is left untouched.
|
|
10303
|
+
if !is_executable_capability(state, &definition)? {
|
|
10304
|
+
continue;
|
|
10305
|
+
}
|
|
10306
|
+
let claim = match workload_store(state)?.claim(
|
|
10307
|
+
&id,
|
|
10308
|
+
worker,
|
|
10309
|
+
&capabilities,
|
|
10310
|
+
unix_seconds_i64(),
|
|
10311
|
+
WORKLOAD_LEASE_MS,
|
|
10312
|
+
) {
|
|
10313
|
+
Ok(claim) => claim,
|
|
10314
|
+
// Another worker took it, or eligibility moved between the scan and
|
|
10315
|
+
// the claim. Both are ordinary outcomes of concurrent workers, not
|
|
10316
|
+
// failures of this pass.
|
|
10317
|
+
Err(_) => continue,
|
|
10318
|
+
};
|
|
10319
|
+
workload_store(state)?
|
|
10320
|
+
.start(
|
|
10321
|
+
&id,
|
|
10322
|
+
&claim.claim_id,
|
|
10323
|
+
worker,
|
|
10324
|
+
claim.fencing_token,
|
|
10325
|
+
unix_seconds_i64(),
|
|
10326
|
+
)
|
|
10327
|
+
.map_err(workload_error)?;
|
|
10328
|
+
|
|
10329
|
+
match execute_named_capability(state, &definition, &input) {
|
|
10330
|
+
Ok(output) => {
|
|
10331
|
+
workload_store(state)?
|
|
10332
|
+
.complete(
|
|
10333
|
+
&id,
|
|
10334
|
+
&claim.claim_id,
|
|
10335
|
+
worker,
|
|
10336
|
+
claim.fencing_token,
|
|
10337
|
+
&format!("result_{id}_{}", claim.fencing_token),
|
|
10338
|
+
output,
|
|
10339
|
+
vec![],
|
|
10340
|
+
1,
|
|
10341
|
+
unix_seconds_i64(),
|
|
10342
|
+
)
|
|
10343
|
+
.map_err(workload_error)?;
|
|
10344
|
+
}
|
|
10345
|
+
Err(error) => {
|
|
10346
|
+
// A capability program that fails does so deterministically, so
|
|
10347
|
+
// this failure kind is deliberately outside the default
|
|
10348
|
+
// `retry_on` set: retrying it would burn attempts to reach the
|
|
10349
|
+
// same dead letter. Only the message is carried, because the
|
|
10350
|
+
// store refuses failure text that looks like a secret.
|
|
10351
|
+
let _ = workload_store(state)?.fail(
|
|
10352
|
+
&id,
|
|
10353
|
+
&claim.claim_id,
|
|
10354
|
+
worker,
|
|
10355
|
+
claim.fencing_token,
|
|
10356
|
+
"execution_error",
|
|
10357
|
+
&format!("capability {definition} failed with status {}", error.0),
|
|
10358
|
+
unix_seconds_i64(),
|
|
10359
|
+
);
|
|
10360
|
+
}
|
|
10361
|
+
}
|
|
10362
|
+
}
|
|
10106
10363
|
Ok(())
|
|
10107
10364
|
}
|
|
10108
10365
|
|
|
@@ -13008,6 +13265,69 @@ async fn shutdown_signal() {
|
|
|
13008
13265
|
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
|
13009
13266
|
}
|
|
13010
13267
|
|
|
13268
|
+
#[cfg(test)]
|
|
13269
|
+
mod worker_identity_tests {
|
|
13270
|
+
use super::grant_subject;
|
|
13271
|
+
use feltdb::authorization::Subject as GrantSubject;
|
|
13272
|
+
use feltdb_server::auth::Principal;
|
|
13273
|
+
|
|
13274
|
+
fn principal(subject_type: &str, identity: Option<&str>) -> Principal {
|
|
13275
|
+
Principal {
|
|
13276
|
+
key_id: "key-1".into(),
|
|
13277
|
+
scopes: vec!["*".into()],
|
|
13278
|
+
subject_type: subject_type.into(),
|
|
13279
|
+
identity_id: identity.map(str::to_string),
|
|
13280
|
+
session_id: None,
|
|
13281
|
+
}
|
|
13282
|
+
}
|
|
13283
|
+
|
|
13284
|
+
/// The workload worker lifecycle compares the authenticated subject against
|
|
13285
|
+
/// the `worker_id` in the request, so which subject each principal maps to
|
|
13286
|
+
/// *is* the security boundary. Nothing but the authenticated principal
|
|
13287
|
+
/// participates in this mapping.
|
|
13288
|
+
#[test]
|
|
13289
|
+
fn only_a_worker_principal_maps_to_a_worker_subject() {
|
|
13290
|
+
assert_eq!(
|
|
13291
|
+
grant_subject(&principal("worker", Some("worker-x"))),
|
|
13292
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13293
|
+
);
|
|
13294
|
+
// Every other principal maps somewhere a worker check can never accept,
|
|
13295
|
+
// no matter what identity it carries or what scopes it holds.
|
|
13296
|
+
for (subject_type, expected) in [
|
|
13297
|
+
("human", GrantSubject::Human("worker-x".into())),
|
|
13298
|
+
("user", GrantSubject::Human("worker-x".into())),
|
|
13299
|
+
("service", GrantSubject::Service("worker-x".into())),
|
|
13300
|
+
("api_key", GrantSubject::Service("worker-x".into())),
|
|
13301
|
+
("agent", GrantSubject::Agent("worker-x".into())),
|
|
13302
|
+
] {
|
|
13303
|
+
let subject = grant_subject(&principal(subject_type, Some("worker-x")));
|
|
13304
|
+
assert_eq!(subject, expected, "{subject_type} mapped unexpectedly");
|
|
13305
|
+
assert_ne!(
|
|
13306
|
+
subject,
|
|
13307
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13308
|
+
"{subject_type} must never satisfy a worker check"
|
|
13309
|
+
);
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
|
|
13313
|
+
/// A worker principal is one worker, not any worker.
|
|
13314
|
+
#[test]
|
|
13315
|
+
fn a_worker_subject_is_bound_to_its_own_identity() {
|
|
13316
|
+
let subject = grant_subject(&principal("worker", Some("worker-x")));
|
|
13317
|
+
assert_ne!(subject, GrantSubject::Worker("worker-y".into()));
|
|
13318
|
+
}
|
|
13319
|
+
|
|
13320
|
+
/// Without an identity the principal falls back to its key id, so a worker
|
|
13321
|
+
/// credential missing its worker id cannot silently become a wildcard.
|
|
13322
|
+
#[test]
|
|
13323
|
+
fn a_worker_principal_without_an_identity_falls_back_to_its_key_id() {
|
|
13324
|
+
assert_eq!(
|
|
13325
|
+
grant_subject(&principal("worker", None)),
|
|
13326
|
+
GrantSubject::Worker("key-1".into()),
|
|
13327
|
+
);
|
|
13328
|
+
}
|
|
13329
|
+
}
|
|
13330
|
+
|
|
13011
13331
|
#[cfg(test)]
|
|
13012
13332
|
mod authority_gate_tests {
|
|
13013
13333
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|