@feltdb/core 0.13.0 → 0.15.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.
- package/dist/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +27 -6
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/db.d.ts +64 -9
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +153 -27
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +27 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +252 -54
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
|
@@ -88,9 +88,11 @@ use feltdb_server::{
|
|
|
88
88
|
cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
|
|
89
89
|
connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
|
|
90
90
|
content::ContentStore,
|
|
91
|
+
control_authorization::{self, PrincipalClass},
|
|
92
|
+
console_accounts::{self, ConsoleAccountStore},
|
|
91
93
|
control_plane,
|
|
92
94
|
identity::IdentityStore,
|
|
93
|
-
key_management::{create_key, list_keys, revoke_key},
|
|
95
|
+
key_management::{create_key, list_keys, revoke_key, rotate_key},
|
|
94
96
|
key_provider::provider_from_environment,
|
|
95
97
|
leases::LeaseStore,
|
|
96
98
|
metrics::Metrics,
|
|
@@ -200,11 +202,13 @@ struct HealthResponse<'a> {
|
|
|
200
202
|
public_contract: &'a str,
|
|
201
203
|
capability_fingerprint: String,
|
|
202
204
|
runtime: &'a str,
|
|
205
|
+
/// Replay outcome of the durable log at open (`clean` or
|
|
206
|
+
/// `recovered-incomplete-write`). Liveness only: it is not a probe of the
|
|
207
|
+
/// volume now and not a durability claim — see `/ready` and `/v1/status`.
|
|
203
208
|
storage: &'a str,
|
|
204
|
-
|
|
209
|
+
/// Configuration, not a probe: whether this process runs the autonomous
|
|
210
|
+
/// worker.
|
|
205
211
|
execution: &'a str,
|
|
206
|
-
workflows: &'a str,
|
|
207
|
-
agents: &'a str,
|
|
208
212
|
membership: &'a str,
|
|
209
213
|
}
|
|
210
214
|
|
|
@@ -1466,6 +1470,10 @@ struct WorkloadFailRequest {
|
|
|
1466
1470
|
fence: WorkloadFenceRequest,
|
|
1467
1471
|
kind: String,
|
|
1468
1472
|
error: String,
|
|
1473
|
+
/// `none` (failed before any external effect) or `unknown` (an effect may
|
|
1474
|
+
/// have happened). Omitted: the policy decides by `kind`.
|
|
1475
|
+
#[serde(default)]
|
|
1476
|
+
effect: Option<String>,
|
|
1469
1477
|
}
|
|
1470
1478
|
#[derive(Deserialize)]
|
|
1471
1479
|
struct WorkloadContextRequest {
|
|
@@ -1496,6 +1504,45 @@ struct WorkerReconcileRequest {
|
|
|
1496
1504
|
reconciliation: WorkerReconciliation,
|
|
1497
1505
|
grant: feltdb::authorization::SignedGrant,
|
|
1498
1506
|
}
|
|
1507
|
+
/// Whether this node commits Durable Work.
|
|
1508
|
+
///
|
|
1509
|
+
/// Durable Work's guarantees rest on one serialization point for each
|
|
1510
|
+
/// workload: the guarded commit of one store. Peers replicate by causal
|
|
1511
|
+
/// order with last-writer-wins for concurrent writes, so two nodes that both
|
|
1512
|
+
/// committed work would each pass their own guard and silently diverge. A node
|
|
1513
|
+
/// without peers is therefore always the authority (the single-node default);
|
|
1514
|
+
/// in a peered cluster exactly one node, started with `--work-authority`,
|
|
1515
|
+
/// commits Durable Work, and every other node serves it read-only from its
|
|
1516
|
+
/// replicated store. A node that learns a peer is also the authority stops
|
|
1517
|
+
/// being one.
|
|
1518
|
+
fn is_work_authority(state: &AppState) -> bool {
|
|
1519
|
+
if state
|
|
1520
|
+
.work_authority_conflict
|
|
1521
|
+
.load(std::sync::atomic::Ordering::SeqCst)
|
|
1522
|
+
{
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
state.work_authority || state.cluster.get().peers.is_empty()
|
|
1526
|
+
}
|
|
1527
|
+
/// Refuse a Durable Work write on a node that is not the authority.
|
|
1528
|
+
fn require_work_authority(state: &AppState) -> Result<(), ApiError> {
|
|
1529
|
+
if is_work_authority(state) {
|
|
1530
|
+
return Ok(());
|
|
1531
|
+
}
|
|
1532
|
+
let conflict = state
|
|
1533
|
+
.work_authority_conflict
|
|
1534
|
+
.load(std::sync::atomic::Ordering::SeqCst);
|
|
1535
|
+
Err(ApiError::structured(
|
|
1536
|
+
StatusCode::MISDIRECTED_REQUEST,
|
|
1537
|
+
json!({"code": "WORKLOAD_NOT_AUTHORITY",
|
|
1538
|
+
"message": if conflict {
|
|
1539
|
+
"more than one node is configured as the Durable Work authority; Durable Work writes are refused until that is corrected"
|
|
1540
|
+
} else {
|
|
1541
|
+
"this node replicates Durable Work read-only; send the request to the node started with --work-authority"
|
|
1542
|
+
},
|
|
1543
|
+
"details": {"reason": if conflict { "authority_conflict" } else { "replica" }, "retryable": false}}),
|
|
1544
|
+
))
|
|
1545
|
+
}
|
|
1499
1546
|
fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
1500
1547
|
let status = match error.code.as_str() {
|
|
1501
1548
|
"WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
|
@@ -1505,6 +1552,8 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
|
1505
1552
|
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1506
1553
|
| "WORKLOAD_ATTEMPTS_EXHAUSTED"
|
|
1507
1554
|
| "WORKLOAD_RETRY_EXHAUSTED"
|
|
1555
|
+
| "WORKLOAD_RESULT_CONFLICT"
|
|
1556
|
+
| "WORKLOAD_TIMED_OUT"
|
|
1508
1557
|
| "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
|
|
1509
1558
|
"WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
|
|
1510
1559
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
@@ -1597,6 +1646,7 @@ async fn create_workload(
|
|
|
1597
1646
|
Extension(principal): Extension<Principal>,
|
|
1598
1647
|
Json(mut input): Json<CreateWorkload>,
|
|
1599
1648
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
|
|
1649
|
+
require_work_authority(&state)?;
|
|
1600
1650
|
let subject = grant_subject(&principal);
|
|
1601
1651
|
input.requested_by = subject.clone();
|
|
1602
1652
|
input.session_id = principal.session_id.clone();
|
|
@@ -1624,10 +1674,18 @@ async fn create_workload(
|
|
|
1624
1674
|
if error.code == "IDEMPOTENCY_KEY_REUSED" {
|
|
1625
1675
|
// AppPort's contract for a reused key (§13.1): CONFLICT, with
|
|
1626
1676
|
// the reason, not retryable.
|
|
1677
|
+
// `fields` names the execution-affecting request fields that
|
|
1678
|
+
// differ from the admitted workload.
|
|
1679
|
+
let fields = error
|
|
1680
|
+
.details
|
|
1681
|
+
.as_ref()
|
|
1682
|
+
.and_then(|details| details.get("fields"))
|
|
1683
|
+
.cloned()
|
|
1684
|
+
.unwrap_or_else(|| json!([]));
|
|
1627
1685
|
ApiError::structured(
|
|
1628
1686
|
StatusCode::CONFLICT,
|
|
1629
1687
|
json!({"code": "CONFLICT", "message": error.message,
|
|
1630
|
-
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
|
|
1688
|
+
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
|
|
1631
1689
|
)
|
|
1632
1690
|
} else {
|
|
1633
1691
|
workload_error(error)
|
|
@@ -1668,7 +1726,8 @@ async fn list_workloads(
|
|
|
1668
1726
|
"workload store unavailable".into(),
|
|
1669
1727
|
)
|
|
1670
1728
|
})?
|
|
1671
|
-
.
|
|
1729
|
+
.fresh_list(&tenant, &target.application_id)
|
|
1730
|
+
.map_err(workload_error)?;
|
|
1672
1731
|
Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
|
|
1673
1732
|
}
|
|
1674
1733
|
async fn get_workload(
|
|
@@ -1685,7 +1744,7 @@ async fn get_workload(
|
|
|
1685
1744
|
"workload store unavailable".into(),
|
|
1686
1745
|
)
|
|
1687
1746
|
})?
|
|
1688
|
-
.
|
|
1747
|
+
.fresh(&id)
|
|
1689
1748
|
.map_err(workload_error)?
|
|
1690
1749
|
.clone();
|
|
1691
1750
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -1715,11 +1774,12 @@ async fn claim_workload(
|
|
|
1715
1774
|
Path(id): Path<String>,
|
|
1716
1775
|
Json(input): Json<WorkloadClaimRequest>,
|
|
1717
1776
|
) -> Result<Json<Value>, ApiError> {
|
|
1777
|
+
require_work_authority(&state)?;
|
|
1718
1778
|
let workload = state
|
|
1719
1779
|
.workloads
|
|
1720
1780
|
.lock()
|
|
1721
1781
|
.unwrap()
|
|
1722
|
-
.
|
|
1782
|
+
.fresh(&id)
|
|
1723
1783
|
.map_err(workload_error)?
|
|
1724
1784
|
.clone();
|
|
1725
1785
|
let subject = grant_subject(&principal);
|
|
@@ -1773,7 +1833,7 @@ fn authorize_fence(
|
|
|
1773
1833
|
.workloads
|
|
1774
1834
|
.lock()
|
|
1775
1835
|
.unwrap()
|
|
1776
|
-
.
|
|
1836
|
+
.fresh(id)
|
|
1777
1837
|
.map_err(workload_error)?
|
|
1778
1838
|
.clone();
|
|
1779
1839
|
let subject = grant_subject(principal);
|
|
@@ -1800,6 +1860,7 @@ async fn start_workload(
|
|
|
1800
1860
|
Path(id): Path<String>,
|
|
1801
1861
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1802
1862
|
) -> Result<StatusCode, ApiError> {
|
|
1863
|
+
require_work_authority(&state)?;
|
|
1803
1864
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1804
1865
|
state
|
|
1805
1866
|
.workloads
|
|
@@ -1821,6 +1882,7 @@ async fn heartbeat_workload(
|
|
|
1821
1882
|
Path(id): Path<String>,
|
|
1822
1883
|
Json(input): Json<WorkloadHeartbeatRequest>,
|
|
1823
1884
|
) -> Result<Json<Value>, ApiError> {
|
|
1885
|
+
require_work_authority(&state)?;
|
|
1824
1886
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1825
1887
|
let claim = state
|
|
1826
1888
|
.workloads
|
|
@@ -1843,6 +1905,7 @@ async fn checkpoint_workload(
|
|
|
1843
1905
|
Path(id): Path<String>,
|
|
1844
1906
|
Json(input): Json<WorkloadCheckpointRequest>,
|
|
1845
1907
|
) -> Result<Json<Value>, ApiError> {
|
|
1908
|
+
require_work_authority(&state)?;
|
|
1846
1909
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1847
1910
|
let checkpoint = state
|
|
1848
1911
|
.workloads
|
|
@@ -1865,6 +1928,7 @@ async fn complete_workload(
|
|
|
1865
1928
|
Path(id): Path<String>,
|
|
1866
1929
|
Json(input): Json<WorkloadCompleteRequest>,
|
|
1867
1930
|
) -> Result<Json<Value>, ApiError> {
|
|
1931
|
+
require_work_authority(&state)?;
|
|
1868
1932
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1869
1933
|
let result = state
|
|
1870
1934
|
.workloads
|
|
@@ -1891,18 +1955,20 @@ async fn fail_workload(
|
|
|
1891
1955
|
Path(id): Path<String>,
|
|
1892
1956
|
Json(input): Json<WorkloadFailRequest>,
|
|
1893
1957
|
) -> Result<Json<Value>, ApiError> {
|
|
1958
|
+
require_work_authority(&state)?;
|
|
1894
1959
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1895
1960
|
let status = state
|
|
1896
1961
|
.workloads
|
|
1897
1962
|
.lock()
|
|
1898
1963
|
.unwrap()
|
|
1899
|
-
.
|
|
1964
|
+
.fail_with_effect(
|
|
1900
1965
|
&id,
|
|
1901
1966
|
&input.fence.claim_id,
|
|
1902
1967
|
&input.fence.worker_id,
|
|
1903
1968
|
input.fence.fencing_token,
|
|
1904
1969
|
&input.kind,
|
|
1905
1970
|
&input.error,
|
|
1971
|
+
input.effect.as_deref(),
|
|
1906
1972
|
unix_seconds_i64(),
|
|
1907
1973
|
)
|
|
1908
1974
|
.map_err(workload_error)?;
|
|
@@ -1915,6 +1981,7 @@ async fn confirm_workload_cancelled(
|
|
|
1915
1981
|
Path(id): Path<String>,
|
|
1916
1982
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1917
1983
|
) -> Result<StatusCode, ApiError> {
|
|
1984
|
+
require_work_authority(&state)?;
|
|
1918
1985
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1919
1986
|
state
|
|
1920
1987
|
.workloads
|
|
@@ -1936,6 +2003,7 @@ async fn workload_execution_context(
|
|
|
1936
2003
|
Path(id): Path<String>,
|
|
1937
2004
|
Json(input): Json<WorkloadContextRequest>,
|
|
1938
2005
|
) -> Result<Json<Value>, ApiError> {
|
|
2006
|
+
require_work_authority(&state)?;
|
|
1939
2007
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1940
2008
|
let context = state
|
|
1941
2009
|
.workloads
|
|
@@ -1958,11 +2026,12 @@ async fn cancel_workload(
|
|
|
1958
2026
|
Path(id): Path<String>,
|
|
1959
2027
|
Json(input): Json<WorkloadControlRequest>,
|
|
1960
2028
|
) -> Result<Json<Value>, ApiError> {
|
|
2029
|
+
require_work_authority(&state)?;
|
|
1961
2030
|
let workload = state
|
|
1962
2031
|
.workloads
|
|
1963
2032
|
.lock()
|
|
1964
2033
|
.unwrap()
|
|
1965
|
-
.
|
|
2034
|
+
.fresh(&id)
|
|
1966
2035
|
.map_err(workload_error)?
|
|
1967
2036
|
.clone();
|
|
1968
2037
|
workload_decision(
|
|
@@ -1988,11 +2057,12 @@ async fn retry_workload(
|
|
|
1988
2057
|
Path(id): Path<String>,
|
|
1989
2058
|
Json(input): Json<WorkloadControlRequest>,
|
|
1990
2059
|
) -> Result<StatusCode, ApiError> {
|
|
2060
|
+
require_work_authority(&state)?;
|
|
1991
2061
|
let workload = state
|
|
1992
2062
|
.workloads
|
|
1993
2063
|
.lock()
|
|
1994
2064
|
.unwrap()
|
|
1995
|
-
.
|
|
2065
|
+
.fresh(&id)
|
|
1996
2066
|
.map_err(workload_error)?
|
|
1997
2067
|
.clone();
|
|
1998
2068
|
workload_decision(
|
|
@@ -2021,7 +2091,7 @@ async fn workload_history(
|
|
|
2021
2091
|
.workloads
|
|
2022
2092
|
.lock()
|
|
2023
2093
|
.unwrap()
|
|
2024
|
-
.
|
|
2094
|
+
.fresh(&id)
|
|
2025
2095
|
.map_err(workload_error)?
|
|
2026
2096
|
.clone();
|
|
2027
2097
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -2038,7 +2108,7 @@ async fn workload_result(
|
|
|
2038
2108
|
.workloads
|
|
2039
2109
|
.lock()
|
|
2040
2110
|
.unwrap()
|
|
2041
|
-
.
|
|
2111
|
+
.fresh(&id)
|
|
2042
2112
|
.map_err(workload_error)?
|
|
2043
2113
|
.clone();
|
|
2044
2114
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -2223,7 +2293,8 @@ async fn reconcile_worker(
|
|
|
2223
2293
|
) -> Result<Json<Value>, ApiError> {
|
|
2224
2294
|
authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
|
|
2225
2295
|
input.reconciliation.worker_id = id;
|
|
2226
|
-
let workloads = state.workloads.lock().unwrap();
|
|
2296
|
+
let mut workloads = state.workloads.lock().unwrap();
|
|
2297
|
+
workloads.refresh().map_err(workload_error)?;
|
|
2227
2298
|
let result = state
|
|
2228
2299
|
.mesh
|
|
2229
2300
|
.lock()
|
|
@@ -2314,7 +2385,7 @@ async fn eligible_workers(
|
|
|
2314
2385
|
.workloads
|
|
2315
2386
|
.lock()
|
|
2316
2387
|
.unwrap()
|
|
2317
|
-
.
|
|
2388
|
+
.fresh(&id)
|
|
2318
2389
|
.map_err(workload_error)?
|
|
2319
2390
|
.clone();
|
|
2320
2391
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -6957,6 +7028,139 @@ async fn auth_sign_up(
|
|
|
6957
7028
|
.map_err(control_error)?;
|
|
6958
7029
|
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
6959
7030
|
}
|
|
7031
|
+
#[derive(Deserialize)]
|
|
7032
|
+
struct ConsolePasswordRequest {
|
|
7033
|
+
email: String,
|
|
7034
|
+
password: String,
|
|
7035
|
+
}
|
|
7036
|
+
#[derive(Deserialize)]
|
|
7037
|
+
struct ConsoleGithubRequest {
|
|
7038
|
+
github_id: Value,
|
|
7039
|
+
login: String,
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
fn console_account_error(error: console_accounts::AccountError) -> ApiError {
|
|
7043
|
+
ApiError::structured(
|
|
7044
|
+
StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
|
7045
|
+
json!({ "code": error.code, "message": error.message }),
|
|
7046
|
+
)
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
/// Authenticate a website call to `/v1/console/accounts/*` and parse its body.
|
|
7050
|
+
fn console_request<T: serde::de::DeserializeOwned>(
|
|
7051
|
+
state: &AppState,
|
|
7052
|
+
method: &Method,
|
|
7053
|
+
uri: &axum::http::Uri,
|
|
7054
|
+
headers: &HeaderMap,
|
|
7055
|
+
body: &[u8],
|
|
7056
|
+
) -> Result<T, ApiError> {
|
|
7057
|
+
let Some(secret) = state.console_accounts.secret() else {
|
|
7058
|
+
return Err(ApiError::structured(
|
|
7059
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
7060
|
+
json!({
|
|
7061
|
+
"code": "CONSOLE_ACCOUNTS_UNCONFIGURED",
|
|
7062
|
+
"message": "console accounts require FELTDB_SESSION_SECRET (at least 32 bytes) on the control plane",
|
|
7063
|
+
}),
|
|
7064
|
+
));
|
|
7065
|
+
};
|
|
7066
|
+
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
|
|
7067
|
+
let now = std::time::SystemTime::now()
|
|
7068
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
7069
|
+
.unwrap_or_default()
|
|
7070
|
+
.as_secs();
|
|
7071
|
+
if let Err(error) = console_accounts::verify_console_request(
|
|
7072
|
+
secret,
|
|
7073
|
+
method.as_str(),
|
|
7074
|
+
uri.path(),
|
|
7075
|
+
header(console_accounts::TIMESTAMP_HEADER),
|
|
7076
|
+
header(console_accounts::SIGNATURE_HEADER),
|
|
7077
|
+
body,
|
|
7078
|
+
now,
|
|
7079
|
+
) {
|
|
7080
|
+
state.metrics.authentication_failure();
|
|
7081
|
+
audit(state, "anonymous", "console.accounts", uri.path(), "denied", 401);
|
|
7082
|
+
return Err(console_account_error(error));
|
|
7083
|
+
}
|
|
7084
|
+
serde_json::from_slice(body)
|
|
7085
|
+
.map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid request body: {error}")))
|
|
7086
|
+
}
|
|
7087
|
+
|
|
7088
|
+
/// Argon2 work and the account file write run off the request worker.
|
|
7089
|
+
async fn console_blocking<F>(work: F) -> Result<Json<Value>, ApiError>
|
|
7090
|
+
where
|
|
7091
|
+
F: FnOnce() -> Result<console_accounts::ResolvedAccount, console_accounts::AccountError>
|
|
7092
|
+
+ Send
|
|
7093
|
+
+ 'static,
|
|
7094
|
+
{
|
|
7095
|
+
match tokio::task::spawn_blocking(work).await {
|
|
7096
|
+
Ok(Ok(account)) => Ok(Json(json!({
|
|
7097
|
+
"user_id": account.user_id,
|
|
7098
|
+
"identity": account.identity,
|
|
7099
|
+
"display_identity": account.display_identity,
|
|
7100
|
+
}))),
|
|
7101
|
+
Ok(Err(error)) => Err(console_account_error(error)),
|
|
7102
|
+
Err(_) => Err(ApiError(
|
|
7103
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
7104
|
+
"console account operation failed".into(),
|
|
7105
|
+
)),
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
7108
|
+
|
|
7109
|
+
async fn console_password_signup(
|
|
7110
|
+
State(state): State<AppState>,
|
|
7111
|
+
method: Method,
|
|
7112
|
+
uri: axum::http::Uri,
|
|
7113
|
+
headers: HeaderMap,
|
|
7114
|
+
body: axum::body::Bytes,
|
|
7115
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
7116
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7117
|
+
let created = console_blocking(move || {
|
|
7118
|
+
state
|
|
7119
|
+
.console_accounts
|
|
7120
|
+
.password_signup(&state.tenancy, &input.email, &input.password)
|
|
7121
|
+
})
|
|
7122
|
+
.await?;
|
|
7123
|
+
Ok((StatusCode::CREATED, created))
|
|
7124
|
+
}
|
|
7125
|
+
|
|
7126
|
+
async fn console_password_signin(
|
|
7127
|
+
State(state): State<AppState>,
|
|
7128
|
+
method: Method,
|
|
7129
|
+
uri: axum::http::Uri,
|
|
7130
|
+
headers: HeaderMap,
|
|
7131
|
+
body: axum::body::Bytes,
|
|
7132
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7133
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7134
|
+
console_blocking(move || {
|
|
7135
|
+
state
|
|
7136
|
+
.console_accounts
|
|
7137
|
+
.password_signin(&input.email, &input.password)
|
|
7138
|
+
})
|
|
7139
|
+
.await
|
|
7140
|
+
}
|
|
7141
|
+
|
|
7142
|
+
async fn console_github_resolve(
|
|
7143
|
+
State(state): State<AppState>,
|
|
7144
|
+
method: Method,
|
|
7145
|
+
uri: axum::http::Uri,
|
|
7146
|
+
headers: HeaderMap,
|
|
7147
|
+
body: axum::body::Bytes,
|
|
7148
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7149
|
+
let input: ConsoleGithubRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7150
|
+
// GitHub user ids are numbers; accept the number or its decimal string.
|
|
7151
|
+
let github_id = match &input.github_id {
|
|
7152
|
+
Value::Number(number) if number.is_u64() => number.to_string(),
|
|
7153
|
+
Value::String(text) => text.clone(),
|
|
7154
|
+
_ => String::new(),
|
|
7155
|
+
};
|
|
7156
|
+
console_blocking(move || {
|
|
7157
|
+
state
|
|
7158
|
+
.console_accounts
|
|
7159
|
+
.github_resolve(&state.tenancy, &github_id, &input.login)
|
|
7160
|
+
})
|
|
7161
|
+
.await
|
|
7162
|
+
}
|
|
7163
|
+
|
|
6960
7164
|
async fn auth_sign_in(
|
|
6961
7165
|
State(state): State<AppState>,
|
|
6962
7166
|
Json(input): Json<SignInRequest>,
|
|
@@ -7595,7 +7799,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
|
|
|
7595
7799
|
"PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
|
|
7596
7800
|
"UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
|
|
7597
7801
|
"SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
|
|
7598
|
-
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" =>
|
|
7802
|
+
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
|
|
7803
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
7804
|
+
}
|
|
7599
7805
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
7600
7806
|
};
|
|
7601
7807
|
ApiError(
|
|
@@ -8232,6 +8438,10 @@ struct SyncPullResponse {
|
|
|
8232
8438
|
operations: Vec<Operation>,
|
|
8233
8439
|
versions: HashMap<String, u64>,
|
|
8234
8440
|
catchup: CatchupStatus,
|
|
8441
|
+
/// Whether the answering node is configured as the Durable Work authority
|
|
8442
|
+
/// (`--work-authority`). Absent from older peers.
|
|
8443
|
+
#[serde(default)]
|
|
8444
|
+
durable_work_authority: bool,
|
|
8235
8445
|
}
|
|
8236
8446
|
|
|
8237
8447
|
#[derive(Serialize, Deserialize)]
|
|
@@ -8480,6 +8690,7 @@ struct Config {
|
|
|
8480
8690
|
audit: PathBuf,
|
|
8481
8691
|
allowed_origins: Vec<axum::http::HeaderValue>,
|
|
8482
8692
|
worker_enabled: bool,
|
|
8693
|
+
work_authority: bool,
|
|
8483
8694
|
advertise_url: Option<String>,
|
|
8484
8695
|
maximum_clock_drift_ms: u64,
|
|
8485
8696
|
}
|
|
@@ -8528,6 +8739,7 @@ impl Config {
|
|
|
8528
8739
|
.transpose()?
|
|
8529
8740
|
.unwrap_or_default();
|
|
8530
8741
|
let mut worker_enabled = std::env::var("FELTDB_WORKER_ENABLED").as_deref() == Ok("true");
|
|
8742
|
+
let mut work_authority = std::env::var("FELTDB_WORK_AUTHORITY").as_deref() == Ok("true");
|
|
8531
8743
|
let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
|
|
8532
8744
|
.ok()
|
|
8533
8745
|
.map(|value| value.trim_end_matches('/').to_string());
|
|
@@ -8580,6 +8792,7 @@ impl Config {
|
|
|
8580
8792
|
.map_err(|_| "invalid --allow-origin".to_string())?,
|
|
8581
8793
|
),
|
|
8582
8794
|
"--worker" => worker_enabled = true,
|
|
8795
|
+
"--work-authority" => work_authority = true,
|
|
8583
8796
|
"--advertise-url" => {
|
|
8584
8797
|
advertise_url = Some(value()?.trim_end_matches('/').to_string())
|
|
8585
8798
|
}
|
|
@@ -8594,7 +8807,7 @@ impl Config {
|
|
|
8594
8807
|
}
|
|
8595
8808
|
}
|
|
8596
8809
|
"--help" | "-h" => {
|
|
8597
|
-
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker]");
|
|
8810
|
+
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker] [--work-authority]");
|
|
8598
8811
|
std::process::exit(0);
|
|
8599
8812
|
}
|
|
8600
8813
|
_ => return Err(format!("unknown argument: {argument}")),
|
|
@@ -8617,6 +8830,7 @@ impl Config {
|
|
|
8617
8830
|
audit,
|
|
8618
8831
|
allowed_origins,
|
|
8619
8832
|
worker_enabled,
|
|
8833
|
+
work_authority,
|
|
8620
8834
|
advertise_url,
|
|
8621
8835
|
maximum_clock_drift_ms,
|
|
8622
8836
|
})
|
|
@@ -8687,10 +8901,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
|
|
|
8687
8901
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
8688
8902
|
tracing_subscriber::fmt()
|
|
8689
8903
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
8904
|
+
// Every log line passes through credential redaction, whatever wrote it.
|
|
8905
|
+
.with_writer(feltdb_server::redaction::RedactingStdout)
|
|
8690
8906
|
.init();
|
|
8907
|
+
if matches!(
|
|
8908
|
+
std::env::args().nth(1).as_deref(),
|
|
8909
|
+
Some("version" | "--version" | "-V")
|
|
8910
|
+
) {
|
|
8911
|
+
// One line an operator script can print instead of a hard-coded banner.
|
|
8912
|
+
println!(
|
|
8913
|
+
"feltdb-server {} (release {}, commit {})",
|
|
8914
|
+
env!("CARGO_PKG_VERSION"),
|
|
8915
|
+
option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
8916
|
+
option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown")
|
|
8917
|
+
);
|
|
8918
|
+
return Ok(());
|
|
8919
|
+
}
|
|
8691
8920
|
if manage_keys()? {
|
|
8692
8921
|
return Ok(());
|
|
8693
8922
|
}
|
|
8923
|
+
if manage_console_accounts()? {
|
|
8924
|
+
return Ok(());
|
|
8925
|
+
}
|
|
8694
8926
|
if manage_authorization()? {
|
|
8695
8927
|
return Ok(());
|
|
8696
8928
|
}
|
|
@@ -8827,6 +9059,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8827
9059
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
8828
9060
|
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8829
9061
|
worker_enabled: config.worker_enabled,
|
|
9062
|
+
work_authority: config.work_authority,
|
|
9063
|
+
work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
8830
9064
|
leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
|
|
8831
9065
|
content: content_store,
|
|
8832
9066
|
artifacts: artifact_store,
|
|
@@ -8848,6 +9082,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8848
9082
|
.into_bytes(),
|
|
8849
9083
|
config.data.with_extension("revoked-sessions.json"),
|
|
8850
9084
|
)?,
|
|
9085
|
+
console_accounts: ConsoleAccountStore::load(
|
|
9086
|
+
config.data.with_extension("console-accounts.json"),
|
|
9087
|
+
console_accounts::configured_secret(std::env::var("FELTDB_SESSION_SECRET").ok()),
|
|
9088
|
+
)?,
|
|
8851
9089
|
principals: principal_authority,
|
|
8852
9090
|
connections: ConnectionStore::load_with_provider(
|
|
8853
9091
|
config.data.with_extension("connections.json"),
|
|
@@ -8936,6 +9174,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8936
9174
|
.route("/api/tenants/{tenant_id}", get(get_tenant))
|
|
8937
9175
|
.route("/api/keys", get(list_keys).post(create_key))
|
|
8938
9176
|
.route("/api/keys/{id}", axum::routing::delete(revoke_key))
|
|
9177
|
+
.route("/api/keys/{id}/rotate", axum::routing::post(rotate_key))
|
|
9178
|
+
// Operator observability: authenticated, `operator:read`.
|
|
9179
|
+
.route("/metrics", get(network_metrics))
|
|
9180
|
+
.route("/v1/status", get(operator_status))
|
|
8939
9181
|
.route(
|
|
8940
9182
|
"/api/tenants/{tenant_id}/applications",
|
|
8941
9183
|
get(list_applications).post(create_application_control),
|
|
@@ -9596,23 +9838,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9596
9838
|
.route("/v1/openapi.json", get(application_openapi))
|
|
9597
9839
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
9598
9840
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
9599
|
-
|
|
9841
|
+
// Console accounts: callable only by the website, which signs each
|
|
9842
|
+
// request with FELTDB_SESSION_SECRET (see console_accounts.rs).
|
|
9600
9843
|
.route(
|
|
9601
|
-
"/
|
|
9602
|
-
axum::routing::post(
|
|
9844
|
+
"/v1/console/accounts/password/signup",
|
|
9845
|
+
axum::routing::post(console_password_signup),
|
|
9603
9846
|
)
|
|
9604
|
-
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
9605
9847
|
.route(
|
|
9606
|
-
"/
|
|
9607
|
-
axum::routing::post(
|
|
9848
|
+
"/v1/console/accounts/password/signin",
|
|
9849
|
+
axum::routing::post(console_password_signin),
|
|
9608
9850
|
)
|
|
9609
|
-
.route("/ready", get(readiness))
|
|
9610
|
-
.route("/runtime", get(runtime))
|
|
9611
|
-
.route("/metrics", get(network_metrics))
|
|
9612
9851
|
.route(
|
|
9613
|
-
"/
|
|
9614
|
-
axum::routing::
|
|
9852
|
+
"/v1/console/accounts/github/resolve",
|
|
9853
|
+
axum::routing::post(console_github_resolve),
|
|
9615
9854
|
)
|
|
9855
|
+
.route("/ready", get(readiness))
|
|
9856
|
+
.route("/runtime", get(runtime))
|
|
9857
|
+
.merge(service_secret_routes())
|
|
9616
9858
|
.merge(protected)
|
|
9617
9859
|
.merge(query_execution_diagnostics_router())
|
|
9618
9860
|
.layer(middleware::from_fn_with_state(state.clone(), admit_request))
|
|
@@ -9620,7 +9862,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9620
9862
|
.layer(middleware::from_fn(attribute_request))
|
|
9621
9863
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
9622
9864
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
9623
|
-
.layer(
|
|
9865
|
+
.layer(middleware::from_fn(redact_error_responses))
|
|
9866
|
+
// Spans carry the path, never the query string, where clients put tokens.
|
|
9867
|
+
.layer(TraceLayer::new_for_http().make_span_with(
|
|
9868
|
+
|request: &Request<axum::body::Body>| {
|
|
9869
|
+
tracing::debug_span!(
|
|
9870
|
+
"request",
|
|
9871
|
+
method = %request.method(),
|
|
9872
|
+
path = %request.uri().path(),
|
|
9873
|
+
)
|
|
9874
|
+
},
|
|
9875
|
+
))
|
|
9624
9876
|
.with_state(state);
|
|
9625
9877
|
if !config.allowed_origins.is_empty() {
|
|
9626
9878
|
app = app.layer(
|
|
@@ -9664,6 +9916,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9664
9916
|
Ok(())
|
|
9665
9917
|
}
|
|
9666
9918
|
|
|
9919
|
+
/// Error bodies can quote client input (axum's JSON rejections echo the
|
|
9920
|
+
/// offending value); a credential sent by mistake must not come back out, or
|
|
9921
|
+
/// land in whatever logs the response. Success bodies are untouched: a key's
|
|
9922
|
+
/// secret is returned exactly once, at creation or rotation.
|
|
9923
|
+
async fn redact_error_responses(request: Request<axum::body::Body>, next: Next) -> Response {
|
|
9924
|
+
let response = next.run(request).await;
|
|
9925
|
+
if response.status().as_u16() < 400 {
|
|
9926
|
+
return response;
|
|
9927
|
+
}
|
|
9928
|
+
let (parts, body) = response.into_parts();
|
|
9929
|
+
let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
|
|
9930
|
+
Ok(bytes) => bytes,
|
|
9931
|
+
Err(_) => return Response::from_parts(parts, axum::body::Body::empty()),
|
|
9932
|
+
};
|
|
9933
|
+
let body = match std::str::from_utf8(&bytes) {
|
|
9934
|
+
Ok(text) => match feltdb_server::redaction::redact(text) {
|
|
9935
|
+
std::borrow::Cow::Owned(redacted) => axum::body::Body::from(redacted),
|
|
9936
|
+
std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
|
|
9937
|
+
},
|
|
9938
|
+
Err(_) => axum::body::Body::from(bytes),
|
|
9939
|
+
};
|
|
9940
|
+
let mut parts = parts;
|
|
9941
|
+
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
|
|
9942
|
+
Response::from_parts(parts, body)
|
|
9943
|
+
}
|
|
9944
|
+
|
|
9667
9945
|
async fn count_request(
|
|
9668
9946
|
State(state): State<AppState>,
|
|
9669
9947
|
request: Request<axum::body::Body>,
|
|
@@ -9696,7 +9974,7 @@ async fn authenticate(
|
|
|
9696
9974
|
// an answer to PR36's question — which part of the request does is.
|
|
9697
9975
|
let authorization =
|
|
9698
9976
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
|
|
9699
|
-
let principal = if state.auth_enabled {
|
|
9977
|
+
let (principal, class) = if state.auth_enabled {
|
|
9700
9978
|
let token = request
|
|
9701
9979
|
.headers()
|
|
9702
9980
|
.get(AUTHORIZATION)
|
|
@@ -9759,8 +10037,8 @@ async fn authenticate(
|
|
|
9759
10037
|
})
|
|
9760
10038
|
.and_then(|token| state.sessions.verify(token));
|
|
9761
10039
|
match (machine, actor, human) {
|
|
9762
|
-
(Some(principal), _, _) => principal,
|
|
9763
|
-
(None, Some(session), _) => Principal {
|
|
10040
|
+
(Some(principal), _, _) => (principal, PrincipalClass::Machine),
|
|
10041
|
+
(None, Some(session), _) => (Principal {
|
|
9764
10042
|
key_id: session.subject_id,
|
|
9765
10043
|
scopes: vec![
|
|
9766
10044
|
"application:read".into(),
|
|
@@ -9775,24 +10053,55 @@ async fn authenticate(
|
|
|
9775
10053
|
subject_type: "human".into(),
|
|
9776
10054
|
identity_id: Some(session.identity_id),
|
|
9777
10055
|
session_id: Some(session.session_id),
|
|
9778
|
-
},
|
|
10056
|
+
}, PrincipalClass::EndUser),
|
|
9779
10057
|
(None, None, Some(session)) => {
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
10058
|
+
// A signed cookie is not enough: its user id must belong to a
|
|
10059
|
+
// console account (created through the verified account
|
|
10060
|
+
// routes, or bound/claimed for a pre-0.15.0 user), and its
|
|
10061
|
+
// identity must be that account's. Sessions the website
|
|
10062
|
+
// minted before accounts existed are refused here.
|
|
10063
|
+
let account = match state.console_accounts.account_for_user(&session.user_id) {
|
|
10064
|
+
Some(account) if account.display_identity == session.identity.trim().to_lowercase() => account,
|
|
10065
|
+
_ => {
|
|
10066
|
+
state.metrics.authentication_failure();
|
|
10067
|
+
audit(
|
|
10068
|
+
&state,
|
|
10069
|
+
&session.user_id,
|
|
10070
|
+
"authenticate",
|
|
10071
|
+
request.uri().path(),
|
|
10072
|
+
"denied",
|
|
10073
|
+
401,
|
|
10074
|
+
);
|
|
10075
|
+
return ApiError::structured(
|
|
10076
|
+
StatusCode::UNAUTHORIZED,
|
|
10077
|
+
json!({
|
|
10078
|
+
"code": "CONSOLE_ACCOUNT_REQUIRED",
|
|
10079
|
+
"message": "this console session is not bound to a console account; sign in again",
|
|
10080
|
+
}),
|
|
10081
|
+
)
|
|
10082
|
+
.into_response();
|
|
10083
|
+
}
|
|
10084
|
+
};
|
|
10085
|
+
if (!state.tenancy.platform_user_exists(&account.user_id)
|
|
10086
|
+
&& state
|
|
10087
|
+
.tenancy
|
|
10088
|
+
.register_platform_user(&account.user_id, &account.display_identity)
|
|
10089
|
+
.is_err())
|
|
10090
|
+
|| !state.tenancy.platform_user_active(&account.user_id)
|
|
9785
10091
|
{
|
|
9786
10092
|
return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
|
|
9787
10093
|
.into_response();
|
|
9788
10094
|
}
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
10095
|
+
(
|
|
10096
|
+
Principal {
|
|
10097
|
+
key_id: account.user_id,
|
|
10098
|
+
scopes: vec![],
|
|
10099
|
+
subject_type: "human".into(),
|
|
10100
|
+
identity_id: Some(account.display_identity),
|
|
10101
|
+
session_id: Some(session.id),
|
|
10102
|
+
},
|
|
10103
|
+
PrincipalClass::ConsoleUser,
|
|
10104
|
+
)
|
|
9796
10105
|
}
|
|
9797
10106
|
(None, None, None) => {
|
|
9798
10107
|
state.metrics.authentication_failure();
|
|
@@ -9812,17 +10121,43 @@ async fn authenticate(
|
|
|
9812
10121
|
}
|
|
9813
10122
|
}
|
|
9814
10123
|
} else {
|
|
9815
|
-
|
|
9816
|
-
|
|
9817
|
-
|
|
9818
|
-
|
|
9819
|
-
|
|
9820
|
-
|
|
9821
|
-
|
|
10124
|
+
(
|
|
10125
|
+
Principal {
|
|
10126
|
+
key_id: "development".to_string(),
|
|
10127
|
+
scopes: vec!["*".to_string()],
|
|
10128
|
+
subject_type: "service".into(),
|
|
10129
|
+
identity_id: None,
|
|
10130
|
+
session_id: None,
|
|
10131
|
+
},
|
|
10132
|
+
PrincipalClass::Development,
|
|
10133
|
+
)
|
|
9822
10134
|
};
|
|
9823
10135
|
|
|
9824
10136
|
let path = request.uri().path();
|
|
9825
|
-
|
|
10137
|
+
// The control plane (`/api/*`) is decided per route, before any handler:
|
|
10138
|
+
// machine keys need the route's capability, console users are held to
|
|
10139
|
+
// tenant/application membership by the handler, and application end-user
|
|
10140
|
+
// sessions are refused. There is no path family without a decision.
|
|
10141
|
+
let control = control_authorization::control_plane_requirement(request.method(), path);
|
|
10142
|
+
if let Some(requirement) = control {
|
|
10143
|
+
if let Err(denial) = control_authorization::decide(class, &principal.scopes, requirement) {
|
|
10144
|
+
state.metrics.authentication_failure();
|
|
10145
|
+
let action = match requirement {
|
|
10146
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10147
|
+
_ => "control-plane",
|
|
10148
|
+
};
|
|
10149
|
+
audit(&state, &principal.key_id, action, path, "denied", 403);
|
|
10150
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10151
|
+
}
|
|
10152
|
+
}
|
|
10153
|
+
let required = if let Some(requirement) = control {
|
|
10154
|
+
match requirement {
|
|
10155
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10156
|
+
_ => "session:self",
|
|
10157
|
+
}
|
|
10158
|
+
} else if path == "/metrics" || path == "/v1/status" {
|
|
10159
|
+
control_authorization::OPERATOR_READ
|
|
10160
|
+
} else if path == "/events" {
|
|
9826
10161
|
"events:read"
|
|
9827
10162
|
} else if path == "/v1/state/diff"
|
|
9828
10163
|
|| path == "/v1/state/conflicts"
|
|
@@ -9882,6 +10217,24 @@ async fn authenticate(
|
|
|
9882
10217
|
} else {
|
|
9883
10218
|
"state:write"
|
|
9884
10219
|
};
|
|
10220
|
+
// An end-user session belongs to one application (self-serve sign-up
|
|
10221
|
+
// registers it there). Its state scopes are meaningful only on the
|
|
10222
|
+
// application API, which checks that membership per request. The legacy
|
|
10223
|
+
// routes (`/collections`, `/transactions`, `/cas`, `/events`, ...) are
|
|
10224
|
+
// scoped to the whole server namespace, so a session there would read and
|
|
10225
|
+
// write every application's records.
|
|
10226
|
+
if class == PrincipalClass::EndUser && !path.starts_with("/v1/") {
|
|
10227
|
+
state.metrics.authentication_failure();
|
|
10228
|
+
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
10229
|
+
let denial = control_authorization::Denial::new(
|
|
10230
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10231
|
+
format!(
|
|
10232
|
+
"end-user sessions can only use the application API (/v1/...) of their own application; {path} is a namespace-wide route — use an application-scoped service key on the server side"
|
|
10233
|
+
),
|
|
10234
|
+
)
|
|
10235
|
+
.with_scope(required);
|
|
10236
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10237
|
+
}
|
|
9885
10238
|
let subject = if principal.subject_type == "human" {
|
|
9886
10239
|
Subject::User(principal.key_id.clone())
|
|
9887
10240
|
} else {
|
|
@@ -9899,16 +10252,26 @@ async fn authenticate(
|
|
|
9899
10252
|
let human_contract = principal.subject_type == "human"
|
|
9900
10253
|
&& path.starts_with("/v1/applications/")
|
|
9901
10254
|
&& path.split('/').any(|segment| segment == "contract");
|
|
10255
|
+
// Two non-control routes are decided by who is asking rather than by a
|
|
10256
|
+
// scope, and are listed here rather than exempted: a user's own contract
|
|
10257
|
+
// drafts (keyed by that user) and the authenticated diagnostics summary.
|
|
9902
10258
|
let authenticated_diagnostics = path == "/v1/diagnostics";
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
&& !authenticated_diagnostics
|
|
9906
|
-
&& !decision.allowed
|
|
9907
|
-
{
|
|
10259
|
+
let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
|
|
10260
|
+
if !decided_by_route && !decision.allowed {
|
|
9908
10261
|
state.metrics.authentication_failure();
|
|
9909
10262
|
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
9910
|
-
|
|
9911
|
-
.
|
|
10263
|
+
let denial = if class == PrincipalClass::EndUser
|
|
10264
|
+
&& (path == "/metrics" || path == "/v1/status" || path.starts_with("/admin/"))
|
|
10265
|
+
{
|
|
10266
|
+
control_authorization::Denial::new(
|
|
10267
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10268
|
+
format!("missing scope {required} — end-user sessions cannot use operator routes; use an operator key"),
|
|
10269
|
+
)
|
|
10270
|
+
.with_scope(required)
|
|
10271
|
+
} else {
|
|
10272
|
+
control_authorization::Denial::missing_scope(required)
|
|
10273
|
+
};
|
|
10274
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
9912
10275
|
}
|
|
9913
10276
|
if path == "/events" {
|
|
9914
10277
|
state.metrics.event_connection();
|
|
@@ -9927,6 +10290,7 @@ async fn authenticate(
|
|
|
9927
10290
|
|
|
9928
10291
|
request.extensions_mut().insert(principal.clone());
|
|
9929
10292
|
request.extensions_mut().insert(authenticated.clone());
|
|
10293
|
+
request.extensions_mut().insert(class);
|
|
9930
10294
|
|
|
9931
10295
|
let principal_id = request
|
|
9932
10296
|
.extensions()
|
|
@@ -10272,14 +10636,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
10272
10636
|
capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
|
|
10273
10637
|
runtime: "self-hosted",
|
|
10274
10638
|
storage: database.storage.label(),
|
|
10275
|
-
fabric
|
|
10639
|
+
// `fabric`, `workflows` and `agents` were constant "healthy" /
|
|
10640
|
+
// "available" here whatever their state (GAP-10). Nothing checks them,
|
|
10641
|
+
// so nothing reports them.
|
|
10276
10642
|
execution: if state.worker_enabled {
|
|
10277
10643
|
"autonomous"
|
|
10278
10644
|
} else {
|
|
10279
10645
|
"state-coordinated"
|
|
10280
10646
|
},
|
|
10281
|
-
workflows: "available",
|
|
10282
|
-
agents: "available",
|
|
10283
10647
|
membership: if clock {
|
|
10284
10648
|
membership
|
|
10285
10649
|
} else {
|
|
@@ -10417,10 +10781,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10417
10781
|
values("--namespace", "default"),
|
|
10418
10782
|
option("--worker"),
|
|
10419
10783
|
)?;
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
)
|
|
10784
|
+
// `--secret-file <path>` writes the secret to an owner-only file
|
|
10785
|
+
// and prints only the id, so a bootstrap script never has to put a
|
|
10786
|
+
// credential on stdout (and from there into platform logs).
|
|
10787
|
+
match option("--secret-file") {
|
|
10788
|
+
Some(file) => {
|
|
10789
|
+
write_secret_file(std::path::Path::new(&file), &key.secret)?;
|
|
10790
|
+
println!("API key created (id: {}). Secret written to {file} (mode 0600).", key.id);
|
|
10791
|
+
}
|
|
10792
|
+
None => println!(
|
|
10793
|
+
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
10794
|
+
key.id, key.secret
|
|
10795
|
+
),
|
|
10796
|
+
}
|
|
10424
10797
|
}
|
|
10425
10798
|
"list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
|
|
10426
10799
|
"revoke" => {
|
|
@@ -10430,11 +10803,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10430
10803
|
}
|
|
10431
10804
|
println!("API key revoked: {id}");
|
|
10432
10805
|
}
|
|
10433
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
10806
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--secret-file path] [--id key-id]".into()),
|
|
10807
|
+
}
|
|
10808
|
+
Ok(true)
|
|
10809
|
+
}
|
|
10810
|
+
|
|
10811
|
+
/// `feltdb-server console-accounts <claim|list> --data <state.log> ...`
|
|
10812
|
+
///
|
|
10813
|
+
/// `claim --email <email> --password-file <file>` binds a console password
|
|
10814
|
+
/// account to the existing pre-0.15.0 platform user registered under that
|
|
10815
|
+
/// e-mail, so its owner keeps their tenants. Before 0.15.0 the website did not
|
|
10816
|
+
/// verify passwords, so such a user cannot be claimed by self-service sign-up;
|
|
10817
|
+
/// the operator verifies the person out of band and runs this. The password
|
|
10818
|
+
/// is read from a file (first line) so it never appears in the process list.
|
|
10819
|
+
fn manage_console_accounts() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10820
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
10821
|
+
if arguments.get(1).map(String::as_str) != Some("console-accounts") {
|
|
10822
|
+
return Ok(false);
|
|
10823
|
+
}
|
|
10824
|
+
let usage = "usage: feltdb-server console-accounts <claim|list> --data <state.log path> [--email email --password-file path]";
|
|
10825
|
+
let option = |name: &str| {
|
|
10826
|
+
arguments
|
|
10827
|
+
.iter()
|
|
10828
|
+
.position(|argument| argument == name)
|
|
10829
|
+
.and_then(|index| arguments.get(index + 1))
|
|
10830
|
+
.cloned()
|
|
10831
|
+
};
|
|
10832
|
+
let data = PathBuf::from(
|
|
10833
|
+
option("--data").unwrap_or_else(|| "./data/feltdb.log".to_string()),
|
|
10834
|
+
);
|
|
10835
|
+
let accounts = ConsoleAccountStore::load(data.with_extension("console-accounts.json"), None)?;
|
|
10836
|
+
let tenancy = TenancyStore::load(data.with_extension("platform.json"))?;
|
|
10837
|
+
match arguments.get(2).map(String::as_str).unwrap_or("") {
|
|
10838
|
+
"claim" => {
|
|
10839
|
+
let email = option("--email").ok_or("console-accounts claim requires --email")?;
|
|
10840
|
+
let file = option("--password-file")
|
|
10841
|
+
.ok_or("console-accounts claim requires --password-file")?;
|
|
10842
|
+
let password = std::fs::read_to_string(&file)?
|
|
10843
|
+
.lines()
|
|
10844
|
+
.next()
|
|
10845
|
+
.unwrap_or_default()
|
|
10846
|
+
.to_string();
|
|
10847
|
+
let claimed = accounts
|
|
10848
|
+
.claim_password(&tenancy, &email, &password)
|
|
10849
|
+
.map_err(|error| format!("{}: {}", error.code, error.message))?;
|
|
10850
|
+
println!(
|
|
10851
|
+
"Console account claimed: {} is bound to existing platform user {}.",
|
|
10852
|
+
claimed.identity, claimed.user_id
|
|
10853
|
+
);
|
|
10854
|
+
}
|
|
10855
|
+
"list" => {
|
|
10856
|
+
let listed: Vec<Value> = accounts
|
|
10857
|
+
.list()
|
|
10858
|
+
.into_iter()
|
|
10859
|
+
.map(|account| {
|
|
10860
|
+
json!({
|
|
10861
|
+
"identity": account.identity,
|
|
10862
|
+
"user_id": account.user_id,
|
|
10863
|
+
"provider": account.provider,
|
|
10864
|
+
"display_identity": account.display_identity,
|
|
10865
|
+
"origin": account.origin,
|
|
10866
|
+
"created_at": account.created_at,
|
|
10867
|
+
})
|
|
10868
|
+
})
|
|
10869
|
+
.collect();
|
|
10870
|
+
println!("{}", serde_json::to_string_pretty(&listed)?);
|
|
10871
|
+
}
|
|
10872
|
+
_ => return Err(usage.into()),
|
|
10434
10873
|
}
|
|
10435
10874
|
Ok(true)
|
|
10436
10875
|
}
|
|
10437
10876
|
|
|
10877
|
+
/// Create `path` owner-only (0600) and write `secret` to it; refuses to
|
|
10878
|
+
/// overwrite, so a second bootstrap never silently replaces a delivered secret.
|
|
10879
|
+
fn write_secret_file(path: &std::path::Path, secret: &str) -> std::io::Result<()> {
|
|
10880
|
+
let mut options = OpenOptions::new();
|
|
10881
|
+
options.write(true).create_new(true);
|
|
10882
|
+
#[cfg(unix)]
|
|
10883
|
+
{
|
|
10884
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
10885
|
+
options.mode(0o600);
|
|
10886
|
+
}
|
|
10887
|
+
let mut file = options.open(path)?;
|
|
10888
|
+
file.write_all(secret.as_bytes())?;
|
|
10889
|
+
file.write_all(b"\n")?;
|
|
10890
|
+
file.sync_all()
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10438
10893
|
fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10439
10894
|
let arguments: Vec<String> = std::env::args().collect();
|
|
10440
10895
|
let family = arguments.get(1).map(String::as_str).unwrap_or("");
|
|
@@ -10961,6 +11416,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10961
11416
|
Ok(true)
|
|
10962
11417
|
}
|
|
10963
11418
|
|
|
11419
|
+
/// Where an online backup bundle may be written.
|
|
11420
|
+
///
|
|
11421
|
+
/// With `FELTDB_BACKUP_DIR` set (the managed deployments set it), the request
|
|
11422
|
+
/// names a bundle, not a path: a single relative component placed inside that
|
|
11423
|
+
/// directory. Without it the server is self-hosted and the operator's path is
|
|
11424
|
+
/// used as given, as before.
|
|
11425
|
+
fn backup_output_path(
|
|
11426
|
+
requested: &str,
|
|
11427
|
+
backup_dir: Option<std::ffi::OsString>,
|
|
11428
|
+
) -> Result<PathBuf, ApiError> {
|
|
11429
|
+
let Some(root) = backup_dir.filter(|value| !value.is_empty()) else {
|
|
11430
|
+
return Ok(PathBuf::from(requested));
|
|
11431
|
+
};
|
|
11432
|
+
let name = std::path::Path::new(requested);
|
|
11433
|
+
let mut components = name.components();
|
|
11434
|
+
let single = matches!(
|
|
11435
|
+
(components.next(), components.next()),
|
|
11436
|
+
(Some(std::path::Component::Normal(_)), None)
|
|
11437
|
+
);
|
|
11438
|
+
if !single {
|
|
11439
|
+
return Err(ApiError(
|
|
11440
|
+
StatusCode::BAD_REQUEST,
|
|
11441
|
+
format!(
|
|
11442
|
+
"backup output must be a bundle name, not a path: this server writes bundles inside FELTDB_BACKUP_DIR ({}); send e.g. {{\"output\":\"backup-2026-10-28\"}}",
|
|
11443
|
+
std::path::Path::new(&root).display()
|
|
11444
|
+
),
|
|
11445
|
+
));
|
|
11446
|
+
}
|
|
11447
|
+
Ok(PathBuf::from(root).join(name))
|
|
11448
|
+
}
|
|
11449
|
+
|
|
10964
11450
|
async fn create_online_backup(
|
|
10965
11451
|
State(state): State<AppState>,
|
|
10966
11452
|
Json(request): Json<OnlineBackupRequest>,
|
|
@@ -10990,7 +11476,7 @@ async fn create_online_backup(
|
|
|
10990
11476
|
})
|
|
10991
11477
|
.cloned()
|
|
10992
11478
|
.collect();
|
|
10993
|
-
let output =
|
|
11479
|
+
let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
|
|
10994
11480
|
let source = feltdb_server::backup::BackupSource {
|
|
10995
11481
|
data: state
|
|
10996
11482
|
.backup_artifacts
|
|
@@ -11002,14 +11488,22 @@ async fn create_online_backup(
|
|
|
11002
11488
|
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
11003
11489
|
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
11004
11490
|
};
|
|
11491
|
+
let records = operator_record_path(&state, BACKUP_RECORDS);
|
|
11005
11492
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
11006
|
-
let
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11493
|
+
let manifest = {
|
|
11494
|
+
let _guard = guard;
|
|
11495
|
+
feltdb_server::backup::create_bundle_with_source(
|
|
11496
|
+
&output,
|
|
11497
|
+
&artifacts,
|
|
11498
|
+
&[("database.snapshot.json".to_string(), snapshot_bytes)],
|
|
11499
|
+
source,
|
|
11500
|
+
)?
|
|
11501
|
+
};
|
|
11502
|
+
// Verified after the barrier is released: restoring into scratch and
|
|
11503
|
+
// opening the result is what turns "a bundle exists" into "a bundle
|
|
11504
|
+
// restores", and the record says which of the two is true.
|
|
11505
|
+
record_online_backup(&records, &output, &manifest);
|
|
11506
|
+
Ok::<_, std::io::Error>(manifest)
|
|
11013
11507
|
})
|
|
11014
11508
|
.await
|
|
11015
11509
|
.map_err(|error| {
|
|
@@ -11033,6 +11527,236 @@ async fn create_online_backup(
|
|
|
11033
11527
|
/// finding was that losing security evidence was invisible, and a signal an
|
|
11034
11528
|
/// operator has to know to go and look for is not much better than no signal.
|
|
11035
11529
|
/// It rides with the metrics they already read.
|
|
11530
|
+
const BACKUP_RECORDS: &str = "backups.json";
|
|
11531
|
+
|
|
11532
|
+
/// Operator records live beside the durable log (`<data>.<name>`), on the
|
|
11533
|
+
/// same volume, like the readiness probe they are derived from.
|
|
11534
|
+
fn operator_record_path(state: &AppState, name: &str) -> PathBuf {
|
|
11535
|
+
state.readiness_probe.with_extension(name)
|
|
11536
|
+
}
|
|
11537
|
+
|
|
11538
|
+
fn read_records(path: &std::path::Path) -> Vec<Value> {
|
|
11539
|
+
std::fs::read(path)
|
|
11540
|
+
.ok()
|
|
11541
|
+
.and_then(|bytes| serde_json::from_slice::<Vec<Value>>(&bytes).ok())
|
|
11542
|
+
.unwrap_or_default()
|
|
11543
|
+
}
|
|
11544
|
+
|
|
11545
|
+
/// Append one record; bounded so the file cannot grow without limit.
|
|
11546
|
+
fn append_record(path: &std::path::Path, record: Value) -> std::io::Result<()> {
|
|
11547
|
+
let mut records = read_records(path);
|
|
11548
|
+
records.push(record);
|
|
11549
|
+
let excess = records.len().saturating_sub(100);
|
|
11550
|
+
records.drain(..excess);
|
|
11551
|
+
let temporary = path.with_extension("json.tmp");
|
|
11552
|
+
let mut file = std::fs::File::create(&temporary)?;
|
|
11553
|
+
file.write_all(&serde_json::to_vec_pretty(&records).map_err(std::io::Error::other)?)?;
|
|
11554
|
+
file.sync_all()?;
|
|
11555
|
+
std::fs::rename(&temporary, path)
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11558
|
+
fn record_online_backup(
|
|
11559
|
+
records: &std::path::Path,
|
|
11560
|
+
output: &std::path::Path,
|
|
11561
|
+
manifest: &feltdb_server::backup::BackupManifest,
|
|
11562
|
+
) {
|
|
11563
|
+
let started = Instant::now();
|
|
11564
|
+
let restore = verify_restorable(output).map_err(|error| error.to_string());
|
|
11565
|
+
let record = json!({
|
|
11566
|
+
"kind": "online_backup",
|
|
11567
|
+
"bundle_id": feltdb_server::backup::backup_id(output).ok(),
|
|
11568
|
+
"output": output.display().to_string(),
|
|
11569
|
+
"created_ms": manifest.created_ms,
|
|
11570
|
+
"files": manifest.entries.len(),
|
|
11571
|
+
"verified": restore.is_ok(),
|
|
11572
|
+
"restore_verification": match &restore {
|
|
11573
|
+
Ok(detail) => json!({
|
|
11574
|
+
"method": "bundle verified, restored into a scratch directory on this host and opened",
|
|
11575
|
+
"verified_at_ms": unix_millis(),
|
|
11576
|
+
"duration_ms": started.elapsed().as_millis() as u64,
|
|
11577
|
+
"detail": detail,
|
|
11578
|
+
}),
|
|
11579
|
+
Err(error) => json!({ "method": "scratch restore", "error": error }),
|
|
11580
|
+
},
|
|
11581
|
+
});
|
|
11582
|
+
if let Err(error) = append_record(records, record) {
|
|
11583
|
+
tracing::error!(%error, "failed to record online backup");
|
|
11584
|
+
}
|
|
11585
|
+
}
|
|
11586
|
+
|
|
11587
|
+
fn unix_millis() -> u64 {
|
|
11588
|
+
std::time::SystemTime::now()
|
|
11589
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
11590
|
+
.unwrap_or_default()
|
|
11591
|
+
.as_millis() as u64
|
|
11592
|
+
}
|
|
11593
|
+
|
|
11594
|
+
/// The routes authenticated by a shared service secret rather than an API
|
|
11595
|
+
/// key. They are routed only when deliberately configured, so an authority
|
|
11596
|
+
/// that is not part of a managed Intent Engine deployment or a certification
|
|
11597
|
+
/// run has no such surface at all.
|
|
11598
|
+
fn service_secret_routes() -> Router<AppState> {
|
|
11599
|
+
let mut routes = Router::new();
|
|
11600
|
+
let intent_secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").unwrap_or_default();
|
|
11601
|
+
if intent_secret.len() >= 32 {
|
|
11602
|
+
routes = routes
|
|
11603
|
+
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
11604
|
+
.route(
|
|
11605
|
+
"/internal/intents",
|
|
11606
|
+
axum::routing::post(internal_create_intent),
|
|
11607
|
+
)
|
|
11608
|
+
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
11609
|
+
.route(
|
|
11610
|
+
"/internal/intent-events",
|
|
11611
|
+
axum::routing::post(internal_create_intent_event),
|
|
11612
|
+
);
|
|
11613
|
+
} else if !intent_secret.is_empty() {
|
|
11614
|
+
tracing::warn!("FELTDB_INTENT_SERVICE_SECRET is shorter than 32 bytes; /internal/intents* is not routed");
|
|
11615
|
+
}
|
|
11616
|
+
let certification = matches!(
|
|
11617
|
+
std::env::var("FELTDB_CERTIFICATION_MODE").as_deref(),
|
|
11618
|
+
Ok("1" | "true")
|
|
11619
|
+
);
|
|
11620
|
+
if certification {
|
|
11621
|
+
tracing::warn!("FELTDB_CERTIFICATION_MODE is set: certification fixture deletion is routed");
|
|
11622
|
+
routes = routes.route(
|
|
11623
|
+
"/api/certification/fixtures/{application_id}",
|
|
11624
|
+
axum::routing::delete(delete_certification_fixture),
|
|
11625
|
+
);
|
|
11626
|
+
}
|
|
11627
|
+
routes
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
/// A real write → fsync → read-back probe of the storage volume.
|
|
11631
|
+
fn storage_probe(path: &std::path::Path) -> Value {
|
|
11632
|
+
let started = Instant::now();
|
|
11633
|
+
let token = format!("feltdb-status-probe-{}\n", uuid::Uuid::new_v4().simple());
|
|
11634
|
+
let mut wrote = false;
|
|
11635
|
+
let mut synced = false;
|
|
11636
|
+
let result = (|| -> std::io::Result<bool> {
|
|
11637
|
+
if let Some(parent) = path.parent() {
|
|
11638
|
+
std::fs::create_dir_all(parent)?;
|
|
11639
|
+
}
|
|
11640
|
+
let mut file = OpenOptions::new()
|
|
11641
|
+
.create(true)
|
|
11642
|
+
.write(true)
|
|
11643
|
+
.truncate(true)
|
|
11644
|
+
.open(path)?;
|
|
11645
|
+
file.write_all(token.as_bytes())?;
|
|
11646
|
+
wrote = true;
|
|
11647
|
+
file.sync_all()?;
|
|
11648
|
+
synced = true;
|
|
11649
|
+
Ok(std::fs::read(path)? == token.as_bytes())
|
|
11650
|
+
})();
|
|
11651
|
+
let read_back = matches!(result, Ok(true));
|
|
11652
|
+
json!({
|
|
11653
|
+
"ok": read_back,
|
|
11654
|
+
"write": wrote,
|
|
11655
|
+
"fsync": synced,
|
|
11656
|
+
"read_back": read_back,
|
|
11657
|
+
"checked_at_ms": unix_millis(),
|
|
11658
|
+
"latency_us": started.elapsed().as_micros() as u64,
|
|
11659
|
+
"error": result.err().map(|error| error.to_string()),
|
|
11660
|
+
})
|
|
11661
|
+
}
|
|
11662
|
+
|
|
11663
|
+
/// `GET /v1/status` — the operator's view, needs `operator:read`.
|
|
11664
|
+
///
|
|
11665
|
+
/// Every field is observed by this request or read from a record this server
|
|
11666
|
+
/// wrote when the event happened; nothing is asserted. Availability (this
|
|
11667
|
+
/// process answers and its volume accepts a synced write) is reported apart
|
|
11668
|
+
/// from verified durability (a backup of this state was restored and opened).
|
|
11669
|
+
async fn operator_status(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
11670
|
+
let probe_path = operator_record_path(&state, "status-probe");
|
|
11671
|
+
let backups_path = operator_record_path(&state, BACKUP_RECORDS);
|
|
11672
|
+
let (probe, backups) = tokio::task::spawn_blocking(move || {
|
|
11673
|
+
(storage_probe(&probe_path), read_records(&backups_path))
|
|
11674
|
+
})
|
|
11675
|
+
.await
|
|
11676
|
+
.map_err(|error| {
|
|
11677
|
+
tracing::error!(%error, "status worker failed");
|
|
11678
|
+
ApiError(StatusCode::SERVICE_UNAVAILABLE, "status check temporarily unavailable".into())
|
|
11679
|
+
})?;
|
|
11680
|
+
let database = state.db.health();
|
|
11681
|
+
let last_backup = backups
|
|
11682
|
+
.iter()
|
|
11683
|
+
.rev()
|
|
11684
|
+
.find(|record| record["kind"] == "online_backup")
|
|
11685
|
+
.map(|record| {
|
|
11686
|
+
json!({
|
|
11687
|
+
"bundle_id": record["bundle_id"],
|
|
11688
|
+
"created_ms": record["created_ms"],
|
|
11689
|
+
"output": record["output"],
|
|
11690
|
+
"verified": record["verified"],
|
|
11691
|
+
})
|
|
11692
|
+
});
|
|
11693
|
+
let last_verified_restore = backups
|
|
11694
|
+
.iter()
|
|
11695
|
+
.rev()
|
|
11696
|
+
.find(|record| record["verified"] == true)
|
|
11697
|
+
.map(|record| {
|
|
11698
|
+
json!({
|
|
11699
|
+
"bundle_id": record["bundle_id"],
|
|
11700
|
+
"verified_at_ms": record["restore_verification"]["verified_at_ms"],
|
|
11701
|
+
"method": record["restore_verification"]["method"],
|
|
11702
|
+
})
|
|
11703
|
+
});
|
|
11704
|
+
let clock = state.lease_clock.is_healthy();
|
|
11705
|
+
let membership_healthy = !matches!(
|
|
11706
|
+
state.cluster.proposal().map(|value| value.phase),
|
|
11707
|
+
Some(ProposalPhase::Preparing | ProposalPhase::Prepared | ProposalPhase::Aborted)
|
|
11708
|
+
);
|
|
11709
|
+
let available = probe["ok"] == true;
|
|
11710
|
+
Ok(Json(json!({
|
|
11711
|
+
"status": if available && overall_status(membership_healthy, clock, &database.storage) == "healthy" { "available" } else { "degraded" },
|
|
11712
|
+
"release": {
|
|
11713
|
+
"version": env!("CARGO_PKG_VERSION"),
|
|
11714
|
+
"release_id": option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
11715
|
+
"git_commit": option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
11716
|
+
"build_timestamp": option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
|
|
11717
|
+
"deployment_id": std::env::var("FELTDB_DEPLOYMENT_ID").or_else(|_| std::env::var("FLY_IMAGE_REF")).ok(),
|
|
11718
|
+
"promoted_revision": std::env::var("FELTDB_PROMOTED_REVISION").ok(),
|
|
11719
|
+
},
|
|
11720
|
+
"capabilities": {
|
|
11721
|
+
"public_contract": "feltdb.public.substrate@1.0",
|
|
11722
|
+
"capability_fingerprint": control_plane::public_contract_capability_fingerprint(),
|
|
11723
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
11724
|
+
"authentication": state.auth_enabled,
|
|
11725
|
+
"autonomous_workers": state.worker_enabled,
|
|
11726
|
+
"configured_peers": state.cluster.get().peers.len(),
|
|
11727
|
+
"durable_work_authority": is_work_authority(&state),
|
|
11728
|
+
},
|
|
11729
|
+
"availability": {
|
|
11730
|
+
"serving": true,
|
|
11731
|
+
"storage_writable": available,
|
|
11732
|
+
"clock": if clock { "healthy" } else { "unhealthy" },
|
|
11733
|
+
"membership": if membership_healthy { "healthy" } else { "recovering-or-attention-required" },
|
|
11734
|
+
},
|
|
11735
|
+
"storage": {
|
|
11736
|
+
"replay": database.storage.label(),
|
|
11737
|
+
"durable_format": database.durable_format.to_string(),
|
|
11738
|
+
"probe": probe,
|
|
11739
|
+
},
|
|
11740
|
+
"backups": {
|
|
11741
|
+
"last": last_backup,
|
|
11742
|
+
"recorded": backups.len(),
|
|
11743
|
+
},
|
|
11744
|
+
"restore": {
|
|
11745
|
+
"last_verified": last_verified_restore,
|
|
11746
|
+
"status": if last_verified_restore.is_some() { "verified" } else { "never_verified" },
|
|
11747
|
+
"off_host": "not observable by this server",
|
|
11748
|
+
},
|
|
11749
|
+
"durability": {
|
|
11750
|
+
"verified": last_verified_restore.is_some(),
|
|
11751
|
+
"basis": if last_verified_restore.is_some() {
|
|
11752
|
+
"a backup of this authority was restored into scratch on this host and opened; off-host recovery and power-loss durability are not proven by this endpoint"
|
|
11753
|
+
} else {
|
|
11754
|
+
"no backup of this authority has been restored and verified; availability is not durability"
|
|
11755
|
+
},
|
|
11756
|
+
},
|
|
11757
|
+
})))
|
|
11758
|
+
}
|
|
11759
|
+
|
|
11036
11760
|
async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
|
|
11037
11761
|
let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
|
|
11038
11762
|
if let Some(fields) = body.as_object_mut() {
|
|
@@ -11075,6 +11799,7 @@ async fn sync_pull(
|
|
|
11075
11799
|
operations,
|
|
11076
11800
|
versions: authority_position,
|
|
11077
11801
|
catchup,
|
|
11802
|
+
durable_work_authority: state.work_authority,
|
|
11078
11803
|
}))
|
|
11079
11804
|
}
|
|
11080
11805
|
|
|
@@ -11239,10 +11964,13 @@ fn start_workload_recovery(state: &AppState) {
|
|
|
11239
11964
|
let state = state.clone();
|
|
11240
11965
|
tokio::spawn(async move {
|
|
11241
11966
|
loop {
|
|
11242
|
-
if state.cluster.get().is_member {
|
|
11967
|
+
if state.cluster.get().is_member && is_work_authority(&state) {
|
|
11243
11968
|
let outcome = match (state.mesh.lock(), state.workloads.lock()) {
|
|
11969
|
+
// Deadlines first: work past one is timed out under its
|
|
11970
|
+
// own decision, not recovered as a lost worker.
|
|
11244
11971
|
(Ok(mut mesh), Ok(mut workloads)) => mesh
|
|
11245
|
-
.
|
|
11972
|
+
.enforce_deadlines(unix_seconds_i64(), &mut workloads)
|
|
11973
|
+
.and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
|
|
11246
11974
|
.map(|_| ())
|
|
11247
11975
|
.map_err(|error| error.message),
|
|
11248
11976
|
_ => Err("worker mesh or workload store unavailable".to_string()),
|
|
@@ -11325,8 +12053,12 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11325
12053
|
}
|
|
11326
12054
|
}
|
|
11327
12055
|
|
|
11328
|
-
|
|
11329
|
-
|
|
12056
|
+
// Triggers admit work and the pass claims it: Durable Work writes, so
|
|
12057
|
+
// only on the authority.
|
|
12058
|
+
if is_work_authority(state) {
|
|
12059
|
+
evaluate_state_triggers(state)?;
|
|
12060
|
+
execute_eligible_workloads(state, &worker)?;
|
|
12061
|
+
}
|
|
11330
12062
|
Ok(())
|
|
11331
12063
|
}
|
|
11332
12064
|
|
|
@@ -12141,6 +12873,17 @@ async fn sync_with_peer(
|
|
|
12141
12873
|
.json::<SyncPullResponse>()
|
|
12142
12874
|
.await?;
|
|
12143
12875
|
|
|
12876
|
+
// Two nodes configured as the Durable Work authority would each commit
|
|
12877
|
+
// work the other cannot see in time; both refuse until one is restarted
|
|
12878
|
+
// without `--work-authority`.
|
|
12879
|
+
if state.work_authority && pull.durable_work_authority {
|
|
12880
|
+
if !state
|
|
12881
|
+
.work_authority_conflict
|
|
12882
|
+
.swap(true, std::sync::atomic::Ordering::SeqCst)
|
|
12883
|
+
{
|
|
12884
|
+
tracing::error!(%peer, "two nodes are configured as the Durable Work authority; refusing Durable Work writes");
|
|
12885
|
+
}
|
|
12886
|
+
}
|
|
12144
12887
|
let mut incoming = pull.operations;
|
|
12145
12888
|
incoming.sort_by(|left, right| {
|
|
12146
12889
|
left.instance_id
|
|
@@ -14977,7 +15720,7 @@ async fn commit_transaction(
|
|
|
14977
15720
|
"committed": false,
|
|
14978
15721
|
"code": "PRECONDITION_FAILED",
|
|
14979
15722
|
"error": failure.to_string(),
|
|
14980
|
-
"failure":
|
|
15723
|
+
"failure": failure.public_json(&feltdb::ReadLayout::Direct),
|
|
14981
15724
|
}),
|
|
14982
15725
|
),
|
|
14983
15726
|
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
@@ -14994,7 +15737,29 @@ async fn commit_transaction(
|
|
|
14994
15737
|
}),
|
|
14995
15738
|
)
|
|
14996
15739
|
}
|
|
14997
|
-
|
|
15740
|
+
// The commit could not be made durable. It is neither a lost
|
|
15741
|
+
// race nor a success, and answering 409 here once let a caller
|
|
15742
|
+
// read a storage failure as "someone else won". The outcome is
|
|
15743
|
+
// to be treated as unknown and reconciled by retrying with the
|
|
15744
|
+
// same transaction id.
|
|
15745
|
+
storage @ (FlowError::Io(_) | FlowError::Serde(_)) => ApiError::structured(
|
|
15746
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
15747
|
+
json!({
|
|
15748
|
+
"committed": "unknown",
|
|
15749
|
+
"code": "DURABILITY_FAILURE",
|
|
15750
|
+
"message": storage.to_string(),
|
|
15751
|
+
"transaction_id": request.transaction_id,
|
|
15752
|
+
}),
|
|
15753
|
+
),
|
|
15754
|
+
other => ApiError::structured(
|
|
15755
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
15756
|
+
json!({
|
|
15757
|
+
"committed": "unknown",
|
|
15758
|
+
"code": "EXECUTION_FAILED",
|
|
15759
|
+
"message": other.to_string(),
|
|
15760
|
+
"transaction_id": request.transaction_id,
|
|
15761
|
+
}),
|
|
15762
|
+
),
|
|
14998
15763
|
}
|
|
14999
15764
|
})?;
|
|
15000
15765
|
let operation_ids = state
|
|
@@ -16437,7 +17202,8 @@ async fn control_plane_agent(
|
|
|
16437
17202
|
"workload store unavailable".into(),
|
|
16438
17203
|
)
|
|
16439
17204
|
})?
|
|
16440
|
-
.
|
|
17205
|
+
.fresh_list(&tenant, &application_id)
|
|
17206
|
+
.map_err(workload_error)?
|
|
16441
17207
|
.into_iter()
|
|
16442
17208
|
.filter(|workload| {
|
|
16443
17209
|
serde_json::to_value(&workload.requested_by)
|
|
@@ -17032,3 +17798,32 @@ mod authority_gate_tests {
|
|
|
17032
17798
|
}
|
|
17033
17799
|
}
|
|
17034
17800
|
}
|
|
17801
|
+
|
|
17802
|
+
#[cfg(test)]
|
|
17803
|
+
mod backup_output_tests {
|
|
17804
|
+
use super::backup_output_path;
|
|
17805
|
+
use std::path::PathBuf;
|
|
17806
|
+
|
|
17807
|
+
#[test]
|
|
17808
|
+
fn a_confined_server_writes_named_bundles_inside_its_backup_directory() {
|
|
17809
|
+
let root = Some("/data/backups".into());
|
|
17810
|
+
assert_eq!(
|
|
17811
|
+
backup_output_path("nightly-1", root.clone()).unwrap(),
|
|
17812
|
+
PathBuf::from("/data/backups/nightly-1")
|
|
17813
|
+
);
|
|
17814
|
+
for refused in ["/data/state.log", "../state.log", "a/b", "..", ".", "", "/"] {
|
|
17815
|
+
assert!(
|
|
17816
|
+
backup_output_path(refused, root.clone()).is_err(),
|
|
17817
|
+
"{refused:?} must be refused"
|
|
17818
|
+
);
|
|
17819
|
+
}
|
|
17820
|
+
}
|
|
17821
|
+
|
|
17822
|
+
#[test]
|
|
17823
|
+
fn a_self_hosted_server_uses_the_operator_path() {
|
|
17824
|
+
assert_eq!(
|
|
17825
|
+
backup_output_path("/srv/backups/x", None).unwrap(),
|
|
17826
|
+
PathBuf::from("/srv/backups/x")
|
|
17827
|
+
);
|
|
17828
|
+
}
|
|
17829
|
+
}
|