create-feltdb 0.5.2 → 0.5.4
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/managed-account.js +11 -4
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/lib.rs +31 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +39 -3
- package/dist/server-source/crates/feltdb-server/src/auth.rs +12 -6
- package/dist/server-source/crates/feltdb-server/src/key_management.rs +7 -4
- package/dist/server-source/crates/feltdb-server/src/main.rs +142 -30
- package/package.json +1 -1
package/dist/managed-account.js
CHANGED
|
@@ -15,18 +15,22 @@ export async function configureManagedAccount(options) {
|
|
|
15
15
|
body: JSON.stringify({ email: options.email, password: options.password }),
|
|
16
16
|
});
|
|
17
17
|
await responseJson(authenticated, 'Could not create or sign in to the managed account');
|
|
18
|
+
// fetch follows the feltdb.com -> www.feltdb.com redirect. Reuse the final
|
|
19
|
+
// origin so Node does not strip the explicit Cookie header on later
|
|
20
|
+
// cross-origin redirects.
|
|
21
|
+
const sessionOrigin = authenticated.url ? new URL(authenticated.url).origin : accountUrl;
|
|
18
22
|
const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0];
|
|
19
23
|
if (!cookie)
|
|
20
24
|
throw new Error('Managed account service did not return a secure session');
|
|
21
25
|
const headers = { Cookie: cookie, 'Content-Type': 'application/json' };
|
|
22
|
-
const tenants = await responseJson(await request(`${
|
|
26
|
+
const tenants = await responseJson(await request(`${sessionOrigin}/api/tenants`, { headers }), 'Could not load managed workspaces');
|
|
23
27
|
let tenant = Array.isArray(tenants) ? tenants[0] : undefined;
|
|
24
28
|
if (!tenant) {
|
|
25
|
-
tenant = await responseJson(await request(`${
|
|
29
|
+
tenant = await responseJson(await request(`${sessionOrigin}/api/tenants`, {
|
|
26
30
|
method: 'POST', headers, body: JSON.stringify({ name: `${options.applicationName} Workspace` }),
|
|
27
31
|
}), 'Could not create the managed workspace');
|
|
28
32
|
}
|
|
29
|
-
const applicationsUrl = `${
|
|
33
|
+
const applicationsUrl = `${sessionOrigin}/api/tenants/${encodeURIComponent(tenant.id)}/applications`;
|
|
30
34
|
const applications = await responseJson(await request(applicationsUrl, { headers }), 'Could not load managed applications');
|
|
31
35
|
let application = Array.isArray(applications)
|
|
32
36
|
? applications.find(value => value.name === options.applicationName)
|
|
@@ -36,7 +40,10 @@ export async function configureManagedAccount(options) {
|
|
|
36
40
|
method: 'POST', headers, body: JSON.stringify({ name: options.applicationName }),
|
|
37
41
|
}), 'Could not create the managed application');
|
|
38
42
|
}
|
|
39
|
-
|
|
43
|
+
// Provision through the account origin that issued the human session. The
|
|
44
|
+
// resulting key is still used against apiUrl, but setup must not require a
|
|
45
|
+
// browser session to be trusted by every managed data-plane hostname.
|
|
46
|
+
const key = await responseJson(await request(`${sessionOrigin}/api/keys`, {
|
|
40
47
|
method: 'POST', headers,
|
|
41
48
|
body: JSON.stringify({
|
|
42
49
|
id: `${options.applicationName}-cli`,
|
package/dist/package-versions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.5.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.4';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -308,6 +308,7 @@ struct Inner {
|
|
|
308
308
|
capability_locations: CapabilityLocationRegistry,
|
|
309
309
|
bootstrapped: bool,
|
|
310
310
|
applied_transactions: HashSet<String>,
|
|
311
|
+
transaction_payload_hashes: HashMap<String, String>,
|
|
311
312
|
collection_cardinality: HashMap<String, u64>,
|
|
312
313
|
}
|
|
313
314
|
|
|
@@ -361,6 +362,8 @@ pub enum JsonCasResult {
|
|
|
361
362
|
struct TransactionLogRecord {
|
|
362
363
|
record_type: String,
|
|
363
364
|
transaction_id: String,
|
|
365
|
+
#[serde(default)]
|
|
366
|
+
payload_hash: Option<String>,
|
|
364
367
|
state_before: u64,
|
|
365
368
|
state_after: u64,
|
|
366
369
|
rows: Vec<StoredRow>,
|
|
@@ -894,6 +897,10 @@ impl FeltDb {
|
|
|
894
897
|
.contains(transaction_id))
|
|
895
898
|
}
|
|
896
899
|
|
|
900
|
+
pub fn applied_transaction_payload_hash(&self, transaction_id: &str) -> Result<Option<String>> {
|
|
901
|
+
Ok(self.inner.lock().expect("lock poisoned").transaction_payload_hashes.get(transaction_id).cloned())
|
|
902
|
+
}
|
|
903
|
+
|
|
897
904
|
/// Atomically validates and durably applies an ordered mutation batch.
|
|
898
905
|
/// The complete transaction is represented by one log record and becomes
|
|
899
906
|
/// visible in memory only after that record has been appended successfully.
|
|
@@ -904,10 +911,29 @@ impl FeltDb {
|
|
|
904
911
|
preconditions: &[AtomicPrecondition],
|
|
905
912
|
mutations: &[AtomicMutation],
|
|
906
913
|
audit: Option<Value>,
|
|
914
|
+
) -> Result<AtomicCommit> {
|
|
915
|
+
self.apply_atomic_transaction_content_addressed(transaction_id, None, expected_parent, preconditions, mutations, audit)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/// Applies a transaction whose identifier is durably bound to a canonical
|
|
919
|
+
/// payload hash. The same ID with a different payload is always a conflict.
|
|
920
|
+
pub fn apply_atomic_transaction_content_addressed(
|
|
921
|
+
&self,
|
|
922
|
+
transaction_id: &str,
|
|
923
|
+
payload_hash: Option<&str>,
|
|
924
|
+
expected_parent: Option<u64>,
|
|
925
|
+
preconditions: &[AtomicPrecondition],
|
|
926
|
+
mutations: &[AtomicMutation],
|
|
927
|
+
audit: Option<Value>,
|
|
907
928
|
) -> Result<AtomicCommit> {
|
|
908
929
|
let (commit, events) = {
|
|
909
930
|
let mut inner = self.inner.lock().expect("lock poisoned");
|
|
910
931
|
if inner.applied_transactions.contains(transaction_id) {
|
|
932
|
+
if let Some(expected_hash) = payload_hash {
|
|
933
|
+
if inner.transaction_payload_hashes.get(transaction_id).map(String::as_str) != Some(expected_hash) {
|
|
934
|
+
return Err(FlowError::CapabilityError(format!("TRANSACTION_ID_PAYLOAD_MISMATCH:{transaction_id}")));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
911
937
|
return Ok(AtomicCommit {
|
|
912
938
|
transaction_id: transaction_id.into(),
|
|
913
939
|
state_before: inner.sequence,
|
|
@@ -1002,6 +1028,7 @@ impl FeltDb {
|
|
|
1002
1028
|
let record = TransactionLogRecord {
|
|
1003
1029
|
record_type: "feltdb.transaction.v1".into(),
|
|
1004
1030
|
transaction_id: transaction_id.into(),
|
|
1031
|
+
payload_hash: payload_hash.map(str::to_owned),
|
|
1005
1032
|
state_before,
|
|
1006
1033
|
state_after: next_sequence,
|
|
1007
1034
|
rows: rows.clone(),
|
|
@@ -1042,6 +1069,7 @@ impl FeltDb {
|
|
|
1042
1069
|
}
|
|
1043
1070
|
inner.sync_state.merge_vector_clock(&vector_clock);
|
|
1044
1071
|
inner.applied_transactions.insert(transaction_id.into());
|
|
1072
|
+
if let Some(hash) = payload_hash { inner.transaction_payload_hashes.insert(transaction_id.into(), hash.into()); }
|
|
1045
1073
|
let events = rows
|
|
1046
1074
|
.iter()
|
|
1047
1075
|
.map(|row| ChangeEvent {
|
|
@@ -2137,6 +2165,9 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
2137
2165
|
}
|
|
2138
2166
|
if value.get("record_type").and_then(Value::as_str) == Some("feltdb.transaction.v1") {
|
|
2139
2167
|
let transaction: TransactionLogRecord = serde_json::from_value(value)?;
|
|
2168
|
+
if let Some(hash) = transaction.payload_hash.clone() {
|
|
2169
|
+
inner.transaction_payload_hashes.insert(transaction.transaction_id.clone(), hash);
|
|
2170
|
+
}
|
|
2140
2171
|
if inner
|
|
2141
2172
|
.applied_transactions
|
|
2142
2173
|
.insert(transaction.transaction_id)
|
|
@@ -1913,6 +1913,23 @@ pub fn execute_transaction(
|
|
|
1913
1913
|
schema: &StateSchema,
|
|
1914
1914
|
request: &TransactionRequest,
|
|
1915
1915
|
write_policy: Option<PolicySubject>,
|
|
1916
|
+
) -> Result<TransactionResult, StateFailure> {
|
|
1917
|
+
execute_transaction_with_policies(db, schema, request, write_policy.as_ref(), None)
|
|
1918
|
+
}
|
|
1919
|
+
pub fn execute_transaction_with_collection_policies(
|
|
1920
|
+
db: &FeltDb,
|
|
1921
|
+
schema: &StateSchema,
|
|
1922
|
+
request: &TransactionRequest,
|
|
1923
|
+
write_policies: &BTreeMap<String, PolicySubject>,
|
|
1924
|
+
) -> Result<TransactionResult, StateFailure> {
|
|
1925
|
+
execute_transaction_with_policies(db, schema, request, None, Some(write_policies))
|
|
1926
|
+
}
|
|
1927
|
+
fn execute_transaction_with_policies(
|
|
1928
|
+
db: &FeltDb,
|
|
1929
|
+
schema: &StateSchema,
|
|
1930
|
+
request: &TransactionRequest,
|
|
1931
|
+
uniform_write_policy: Option<&PolicySubject>,
|
|
1932
|
+
collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
|
|
1916
1933
|
) -> Result<TransactionResult, StateFailure> {
|
|
1917
1934
|
if request.application_id != schema.application_id
|
|
1918
1935
|
|| request.revision_id != schema.revision_id
|
|
@@ -1944,19 +1961,25 @@ pub fn execute_transaction(
|
|
|
1944
1961
|
request.application_id.as_str(),
|
|
1945
1962
|
request.revision_id.as_str(),
|
|
1946
1963
|
request.schema_version,
|
|
1964
|
+
request.state_namespace.as_deref(),
|
|
1947
1965
|
request.causal_parent,
|
|
1948
1966
|
&request.authorization.subject,
|
|
1967
|
+
&request.preconditions,
|
|
1949
1968
|
&request.operations,
|
|
1950
1969
|
))
|
|
1951
1970
|
.map_err(StateFailure::storage)?;
|
|
1952
1971
|
let transaction_id = request
|
|
1953
1972
|
.transaction_id
|
|
1954
1973
|
.clone()
|
|
1955
|
-
.unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(canonical)));
|
|
1974
|
+
.unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(&canonical)));
|
|
1975
|
+
let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
|
|
1956
1976
|
if db
|
|
1957
1977
|
.has_applied_transaction(&transaction_id)
|
|
1958
1978
|
.map_err(StateFailure::storage)?
|
|
1959
1979
|
{
|
|
1980
|
+
if db.applied_transaction_payload_hash(&transaction_id).map_err(StateFailure::storage)?.as_deref() != Some(payload_hash.as_str()) {
|
|
1981
|
+
return Err(StateFailure::new("CONFLICT", "transaction ID was already committed with a different canonical payload"));
|
|
1982
|
+
}
|
|
1960
1983
|
let state = db.sequence().map_err(StateFailure::storage)?;
|
|
1961
1984
|
let cursor = db
|
|
1962
1985
|
.operation_versions()
|
|
@@ -2088,7 +2111,8 @@ pub fn execute_transaction(
|
|
|
2088
2111
|
.unwrap_or_else(|| persisted.map(|row| Some(row.value.clone())).unwrap_or(None));
|
|
2089
2112
|
|
|
2090
2113
|
// Per-operation record authorization
|
|
2091
|
-
|
|
2114
|
+
let write_policy = collection_write_policies.and_then(|policies| policies.get(&operation.collection)).or(uniform_write_policy);
|
|
2115
|
+
if let Some(policy) = write_policy {
|
|
2092
2116
|
let actor = if request.authorization.subject.is_empty()
|
|
2093
2117
|
|| request.authorization.subject == ":"
|
|
2094
2118
|
{
|
|
@@ -2279,8 +2303,9 @@ pub fn execute_transaction(
|
|
|
2279
2303
|
}
|
|
2280
2304
|
}
|
|
2281
2305
|
let commit = db
|
|
2282
|
-
.
|
|
2306
|
+
.apply_atomic_transaction_content_addressed(
|
|
2283
2307
|
&transaction_id,
|
|
2308
|
+
Some(&payload_hash),
|
|
2284
2309
|
request.causal_parent,
|
|
2285
2310
|
&preconditions,
|
|
2286
2311
|
&mutations,
|
|
@@ -2760,6 +2785,17 @@ mod tests {
|
|
|
2760
2785
|
assert_eq!(failure.actual, Some(Value::from(1)));
|
|
2761
2786
|
}
|
|
2762
2787
|
#[test]
|
|
2788
|
+
fn explicit_transaction_id_is_bound_to_canonical_payload_across_restart() {
|
|
2789
|
+
let path=std::env::temp_dir().join(format!("feltdb-content-id-{}-{}.log",std::process::id(),crate::now_ms()));
|
|
2790
|
+
let schema=schema();
|
|
2791
|
+
let request=TransactionRequest{transaction_id:Some("stable-client-id".into()),tenant_id:"tenant".into(),application_id:"app".into(),revision_id:"rev".into(),schema_version:1,state_namespace:None,causal_parent:None,authorization:auth(),preconditions:vec![],operations:vec![TransactionOperation{kind:TransactionOperationKind::Insert,collection:"incidents".into(),id:"content-bound".into(),value:serde_json::json!({"title":"Original"}),if_version:None}]};
|
|
2792
|
+
{let db=crate::open(&path).unwrap();assert!(!execute_transaction(&db,&schema,&request,None).unwrap().duplicate);assert!(execute_transaction(&db,&schema,&request,None).unwrap().duplicate);}
|
|
2793
|
+
let reopened=crate::open(&path).unwrap();assert!(execute_transaction(&reopened,&schema,&request,None).unwrap().duplicate);
|
|
2794
|
+
let mut changed=request.clone();changed.operations[0].value=serde_json::json!({"title":"Changed"});
|
|
2795
|
+
let failure=execute_transaction(&reopened,&schema,&changed,None).unwrap_err();assert_eq!(failure.code,"CONFLICT");assert!(failure.message.contains("different canonical payload"));
|
|
2796
|
+
std::fs::remove_file(path).ok();
|
|
2797
|
+
}
|
|
2798
|
+
#[test]
|
|
2763
2799
|
fn read_context_remains_a_stable_snapshot() {
|
|
2764
2800
|
let db = db("snapshot-read");
|
|
2765
2801
|
let schema = schema();
|
|
@@ -50,6 +50,12 @@ pub struct ApiKeySummary {
|
|
|
50
50
|
pub revoked: bool,
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
#[derive(Debug, Clone)]
|
|
54
|
+
pub struct CreatedApiKey {
|
|
55
|
+
pub id: String,
|
|
56
|
+
pub secret: String,
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
#[derive(Debug, Clone)]
|
|
54
60
|
pub struct Principal {
|
|
55
61
|
pub key_id: String,
|
|
@@ -152,7 +158,7 @@ impl KeyStore {
|
|
|
152
158
|
name: String,
|
|
153
159
|
scopes: Vec<String>,
|
|
154
160
|
namespaces: Vec<String>,
|
|
155
|
-
) -> Result<
|
|
161
|
+
) -> Result<CreatedApiKey, String> {
|
|
156
162
|
let secret: String = rand::thread_rng()
|
|
157
163
|
.sample_iter(&Alphanumeric)
|
|
158
164
|
.take(48)
|
|
@@ -173,7 +179,7 @@ impl KeyStore {
|
|
|
173
179
|
.write()
|
|
174
180
|
.map_err(|_| "API key store lock poisoned".to_string())?
|
|
175
181
|
.push(ApiKeyRecord {
|
|
176
|
-
id,
|
|
182
|
+
id: id.clone(),
|
|
177
183
|
name,
|
|
178
184
|
hash,
|
|
179
185
|
scopes,
|
|
@@ -181,7 +187,7 @@ impl KeyStore {
|
|
|
181
187
|
revoked: false,
|
|
182
188
|
});
|
|
183
189
|
self.persist()?;
|
|
184
|
-
Ok(token)
|
|
190
|
+
Ok(CreatedApiKey { id, secret: token })
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
pub fn list(&self) -> Result<Vec<ApiKeySummary>, String> {
|
|
@@ -255,13 +261,14 @@ mod tests {
|
|
|
255
261
|
.as_nanos()
|
|
256
262
|
));
|
|
257
263
|
let store = KeyStore::load(&path).unwrap();
|
|
258
|
-
let
|
|
264
|
+
let created = store
|
|
259
265
|
.create(
|
|
260
266
|
"test".to_string(),
|
|
261
267
|
vec!["state:read".to_string()],
|
|
262
268
|
vec!["alpha".to_string()],
|
|
263
269
|
)
|
|
264
270
|
.unwrap();
|
|
271
|
+
let token = created.secret;
|
|
265
272
|
|
|
266
273
|
let file = fs::read_to_string(&path).unwrap();
|
|
267
274
|
assert!(!file.contains(&token));
|
|
@@ -271,8 +278,7 @@ mod tests {
|
|
|
271
278
|
.permits("state:read"));
|
|
272
279
|
assert!(store.authenticate(&token, "beta").is_none());
|
|
273
280
|
assert!(store.authenticate("fdb_live_wrong", "alpha").is_none());
|
|
274
|
-
|
|
275
|
-
assert!(store.revoke(&id).unwrap());
|
|
281
|
+
assert!(store.revoke(&created.id).unwrap());
|
|
276
282
|
assert!(store.authenticate(&token, "alpha").is_none());
|
|
277
283
|
assert!(!store.revoke("missing").unwrap());
|
|
278
284
|
let _ = fs::remove_file(path);
|
|
@@ -45,11 +45,11 @@ pub async fn create_key(
|
|
|
45
45
|
.unwrap_or_default()
|
|
46
46
|
.as_secs();
|
|
47
47
|
|
|
48
|
-
let
|
|
49
|
-
let
|
|
48
|
+
let name = req.id.unwrap_or_else(|| format!("key-{}", timestamp));
|
|
49
|
+
let key = state
|
|
50
50
|
.keys
|
|
51
51
|
.create(
|
|
52
|
-
|
|
52
|
+
name,
|
|
53
53
|
req.scopes,
|
|
54
54
|
vec![req.namespace],
|
|
55
55
|
)
|
|
@@ -57,7 +57,10 @@ pub async fn create_key(
|
|
|
57
57
|
|
|
58
58
|
Ok((
|
|
59
59
|
StatusCode::CREATED,
|
|
60
|
-
Json(CreateKeyResponse {
|
|
60
|
+
Json(CreateKeyResponse {
|
|
61
|
+
secret: key.secret,
|
|
62
|
+
id: key.id,
|
|
63
|
+
}),
|
|
61
64
|
))
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -44,7 +44,7 @@ use feltdb::{
|
|
|
44
44
|
policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
|
|
45
45
|
state_contract::{
|
|
46
46
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
47
|
-
schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
|
|
47
|
+
execute_transaction_with_collection_policies, schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
|
|
48
48
|
StateSchema, TransactionOperation, TransactionOperationKind, TransactionRequest,
|
|
49
49
|
},
|
|
50
50
|
sync_contract::{
|
|
@@ -118,6 +118,8 @@ impl From<feltdb::FlowError> for ApiError {
|
|
|
118
118
|
#[derive(Serialize)]
|
|
119
119
|
struct HealthResponse<'a> {
|
|
120
120
|
status: &'a str,
|
|
121
|
+
version: &'a str,
|
|
122
|
+
git_commit: &'a str,
|
|
121
123
|
runtime: &'a str,
|
|
122
124
|
storage: &'a str,
|
|
123
125
|
fabric: &'a str,
|
|
@@ -4708,6 +4710,32 @@ async fn list_runtime_triggers(
|
|
|
4708
4710
|
Ok(Json(json!(contract.triggers)))
|
|
4709
4711
|
}
|
|
4710
4712
|
|
|
4713
|
+
fn state_policy_resource_matches(resource:&str,collection:&str)->bool{resource==collection||resource.strip_suffix('*').is_some_and(|prefix|!prefix.contains('*')&&collection.starts_with(prefix))}
|
|
4714
|
+
fn matching_state_policy<'a>(contract:&'a ApplicationRuntimeContract,collection:&str)->Option<&'a feltdb::application::PolicyDefinition>{contract.policies.definitions.iter().find(|p|p.resource==collection).or_else(||contract.policies.definitions.iter().find(|p|state_policy_resource_matches(&p.resource,collection)))}
|
|
4715
|
+
fn authorize_transaction_collections(
|
|
4716
|
+
principal: &Principal,
|
|
4717
|
+
contract: &ApplicationRuntimeContract,
|
|
4718
|
+
tenant: &str,
|
|
4719
|
+
collections: &[String],
|
|
4720
|
+
) -> (bool, BTreeMap<String, PolicySubject>) {
|
|
4721
|
+
if tenant != contract.tenant_id || collections.is_empty() {
|
|
4722
|
+
return (false, BTreeMap::new());
|
|
4723
|
+
}
|
|
4724
|
+
let mut write_policies = BTreeMap::new();
|
|
4725
|
+
for collection in collections {
|
|
4726
|
+
let decision = state_authorization(principal, contract, collection, "write");
|
|
4727
|
+
if !decision.capabilities.contains("state:write") {
|
|
4728
|
+
return (false, BTreeMap::new());
|
|
4729
|
+
}
|
|
4730
|
+
if let Some(subject) = matching_state_policy(contract, collection)
|
|
4731
|
+
.and_then(|policy| policy.write.as_deref())
|
|
4732
|
+
.and_then(PolicySubject::from_str)
|
|
4733
|
+
{
|
|
4734
|
+
write_policies.insert(collection.clone(), subject);
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
(true, write_policies)
|
|
4738
|
+
}
|
|
4711
4739
|
fn state_authorization(
|
|
4712
4740
|
principal: &Principal,
|
|
4713
4741
|
contract: &ApplicationRuntimeContract,
|
|
@@ -4716,13 +4744,8 @@ fn state_authorization(
|
|
|
4716
4744
|
) -> AuthorizationContext {
|
|
4717
4745
|
let subject = format!("{}:{}", principal.subject_type, principal.key_id);
|
|
4718
4746
|
|
|
4719
|
-
|
|
4720
|
-
let policy_subject =
|
|
4721
|
-
.policies
|
|
4722
|
-
.definitions
|
|
4723
|
-
.iter()
|
|
4724
|
-
.find(|p| p.resource == collection)
|
|
4725
|
-
.and_then(|p| {
|
|
4747
|
+
let policy=matching_state_policy(contract,collection);
|
|
4748
|
+
let policy_subject = policy.and_then(|p| {
|
|
4726
4749
|
if operation == "read" {
|
|
4727
4750
|
p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
|
|
4728
4751
|
} else {
|
|
@@ -4756,14 +4779,15 @@ fn state_authorization(
|
|
|
4756
4779
|
BTreeSet::new()
|
|
4757
4780
|
}
|
|
4758
4781
|
}
|
|
4759
|
-
} else {
|
|
4760
|
-
// No policy found, use default capabilities
|
|
4782
|
+
} else if policy.is_some_and(|p|p.capabilities.iter().any(|capability|capability==if operation=="read"{"state:read"}else{"state:write"})) {
|
|
4761
4783
|
let capability = if operation == "read" {
|
|
4762
4784
|
"state:read"
|
|
4763
4785
|
} else {
|
|
4764
4786
|
"state:write"
|
|
4765
4787
|
};
|
|
4766
4788
|
[capability.into()].into()
|
|
4789
|
+
} else {
|
|
4790
|
+
BTreeSet::new()
|
|
4767
4791
|
};
|
|
4768
4792
|
|
|
4769
4793
|
AuthorizationContext {
|
|
@@ -5091,27 +5115,21 @@ async fn execute_canonical_transaction(
|
|
|
5091
5115
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
5092
5116
|
input.transaction.schema_version = contract.state.schema.schema_version;
|
|
5093
5117
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
let
|
|
5105
|
-
.policies
|
|
5106
|
-
.definitions
|
|
5107
|
-
.iter()
|
|
5108
|
-
.find(|p| p.resource == collection)
|
|
5109
|
-
.and_then(|p| p.write.as_ref().and_then(|s| PolicySubject::from_str(s)));
|
|
5110
|
-
let result = execute_transaction(
|
|
5118
|
+
let collections = input.transaction.operations.iter()
|
|
5119
|
+
.map(|operation| operation.collection.clone())
|
|
5120
|
+
.collect::<Vec<_>>();
|
|
5121
|
+
let (authorized, write_policies) = authorize_transaction_collections(
|
|
5122
|
+
&principal,
|
|
5123
|
+
&contract,
|
|
5124
|
+
&tenant,
|
|
5125
|
+
&collections,
|
|
5126
|
+
);
|
|
5127
|
+
input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
|
|
5128
|
+
let result = execute_transaction_with_collection_policies(
|
|
5111
5129
|
&state.db,
|
|
5112
5130
|
&contract.state.schema,
|
|
5113
5131
|
&input.transaction,
|
|
5114
|
-
|
|
5132
|
+
&write_policies,
|
|
5115
5133
|
)
|
|
5116
5134
|
.map_err(state_contract_error)?;
|
|
5117
5135
|
audit(
|
|
@@ -6556,6 +6574,8 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
6556
6574
|
} else {
|
|
6557
6575
|
"degraded"
|
|
6558
6576
|
},
|
|
6577
|
+
version: env!("CARGO_PKG_VERSION"),
|
|
6578
|
+
git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
6559
6579
|
runtime: "self-hosted",
|
|
6560
6580
|
storage: "durable",
|
|
6561
6581
|
fabric: "healthy",
|
|
@@ -6659,12 +6679,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
6659
6679
|
.map(str::to_string)
|
|
6660
6680
|
.collect()
|
|
6661
6681
|
};
|
|
6662
|
-
let
|
|
6682
|
+
let key = store.create(
|
|
6663
6683
|
name,
|
|
6664
6684
|
values("--scope", "state:read,state:write,events:read"),
|
|
6665
6685
|
values("--namespace", "default"),
|
|
6666
6686
|
)?;
|
|
6667
|
-
println!(
|
|
6687
|
+
println!(
|
|
6688
|
+
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
6689
|
+
key.id, key.secret
|
|
6690
|
+
);
|
|
6668
6691
|
}
|
|
6669
6692
|
"list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
|
|
6670
6693
|
"revoke" => {
|
|
@@ -9088,3 +9111,92 @@ async fn shutdown_signal() {
|
|
|
9088
9111
|
let terminate = std::future::pending::<()>();
|
|
9089
9112
|
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
|
9090
9113
|
}
|
|
9114
|
+
|
|
9115
|
+
#[cfg(test)]
|
|
9116
|
+
mod authority_gate_tests {
|
|
9117
|
+
use super::{authorize_transaction_collections, state_authorization};
|
|
9118
|
+
use feltdb::{
|
|
9119
|
+
application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
|
|
9120
|
+
application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
|
|
9121
|
+
};
|
|
9122
|
+
use feltdb_server::auth::Principal;
|
|
9123
|
+
|
|
9124
|
+
fn contract(policies: Vec<PolicyDefinition>) -> ApplicationRuntimeContract {
|
|
9125
|
+
let mut manifest = ApplicationManifest::empty("tenant", "app", "Authority test");
|
|
9126
|
+
manifest.policies = policies;
|
|
9127
|
+
let revision = ApplicationRevision {
|
|
9128
|
+
revision_id: "apprev://tenant/app/test".into(),
|
|
9129
|
+
application_id: "app".into(),
|
|
9130
|
+
tenant_id: "tenant".into(),
|
|
9131
|
+
revision_number: 1,
|
|
9132
|
+
parent_revision_id: None,
|
|
9133
|
+
manifest_hash: manifest_hash(&manifest).unwrap(),
|
|
9134
|
+
manifest,
|
|
9135
|
+
created_by: "test".into(),
|
|
9136
|
+
created_at: 1,
|
|
9137
|
+
status: RevisionStatus::Committed,
|
|
9138
|
+
};
|
|
9139
|
+
resolve_runtime(&revision, "production", &RuntimeInventory::default()).unwrap()
|
|
9140
|
+
}
|
|
9141
|
+
|
|
9142
|
+
fn principal() -> Principal {
|
|
9143
|
+
Principal {
|
|
9144
|
+
key_id: "key-1".into(),
|
|
9145
|
+
scopes: vec!["state:read".into(), "state:write".into()],
|
|
9146
|
+
subject_type: "api_key".into(),
|
|
9147
|
+
identity_id: None,
|
|
9148
|
+
session_id: None,
|
|
9149
|
+
}
|
|
9150
|
+
}
|
|
9151
|
+
|
|
9152
|
+
fn allow(resource: &str) -> PolicyDefinition {
|
|
9153
|
+
PolicyDefinition {
|
|
9154
|
+
name: resource.into(),
|
|
9155
|
+
resource: resource.into(),
|
|
9156
|
+
read: None,
|
|
9157
|
+
write: None,
|
|
9158
|
+
capabilities: vec!["state:read".into(), "state:write".into()],
|
|
9159
|
+
}
|
|
9160
|
+
}
|
|
9161
|
+
|
|
9162
|
+
#[test]
|
|
9163
|
+
fn no_matching_policy_denies() {
|
|
9164
|
+
let contract = contract(vec![]);
|
|
9165
|
+
assert!(state_authorization(&principal(), &contract, "secrets", "read").capabilities.is_empty());
|
|
9166
|
+
let (allowed, _) = authorize_transaction_collections(
|
|
9167
|
+
&principal(), &contract, "tenant", &["secrets".into()],
|
|
9168
|
+
);
|
|
9169
|
+
assert!(!allowed);
|
|
9170
|
+
}
|
|
9171
|
+
|
|
9172
|
+
#[test]
|
|
9173
|
+
fn terminal_wildcard_policy_allows_matching_collection() {
|
|
9174
|
+
let contract = contract(vec![allow("buzz_*")]);
|
|
9175
|
+
let (allowed, _) = authorize_transaction_collections(
|
|
9176
|
+
&principal(), &contract, "tenant", &["buzz_rooms".into()],
|
|
9177
|
+
);
|
|
9178
|
+
assert!(allowed);
|
|
9179
|
+
}
|
|
9180
|
+
|
|
9181
|
+
#[test]
|
|
9182
|
+
fn multi_collection_transaction_authorizes_every_operation() {
|
|
9183
|
+
let contract = contract(vec![allow("alpha"), allow("beta")]);
|
|
9184
|
+
let (all_allowed, _) = authorize_transaction_collections(
|
|
9185
|
+
&principal(), &contract, "tenant", &["alpha".into(), "beta".into()],
|
|
9186
|
+
);
|
|
9187
|
+
let (mixed_allowed, _) = authorize_transaction_collections(
|
|
9188
|
+
&principal(), &contract, "tenant", &["alpha".into(), "secrets".into()],
|
|
9189
|
+
);
|
|
9190
|
+
assert!(all_allowed);
|
|
9191
|
+
assert!(!mixed_allowed);
|
|
9192
|
+
}
|
|
9193
|
+
|
|
9194
|
+
#[test]
|
|
9195
|
+
fn cross_tenant_transaction_denies() {
|
|
9196
|
+
let contract = contract(vec![allow("alpha")]);
|
|
9197
|
+
let (allowed, _) = authorize_transaction_collections(
|
|
9198
|
+
&principal(), &contract, "other-tenant", &["alpha".into()],
|
|
9199
|
+
);
|
|
9200
|
+
assert!(!allowed);
|
|
9201
|
+
}
|
|
9202
|
+
}
|