Skip to content

Commit abc475a

Browse files
authored
Merge pull request #147 from Govcraft/fix/remaining-open-issues
Fix PATCH authorization, data correctness, attachments, and invitations
2 parents c88c535 + 7fdf231 commit abc475a

68 files changed

Lines changed: 3315 additions & 362 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Data correctness issues 140 through 142
2+
3+
Preserve enum data during widening and reorder migrations with Identity; represent removed variants explicitly in a new non-exhaustive ValueTransform variant and null only those values before new constraints are installed. Widening is safe, narrowing requires confirmation. Exercise planner and PostgreSQL/SurrealDB generated migrations plus storage behavior where available.
4+
5+
Define null equality as absent or explicit null; inequality is its complement, including non-null comparisons. Compile PostgreSQL null predicates without binds and use IS DISTINCT FROM for non-null inequality; align SurrealDB and MSSQL missing fields. Test filtering and counts together using reusable backend test cases.
6+
7+
Make entity query ordering total by appending id ascending unless explicitly sorted by id. Preserve descending id requests, accept direction as the HTTP alias for order, and validate reserved id filter paths. Fix MSSQL id extraction and SurrealDB record-id literals. Test stable pages, duplicate sort values, descending order and id ranges.
8+
9+
Keep existing typed IDs/errors and pure query/codegen helpers. No dependencies required. Additive transform IR suggests core minor version; parent coordinates all version changes. Run focused nextest and deny-warning Clippy, then parent integration gates for all supported backends.
10+
11+
# Attachment clear concurrency support (issue 120 review)
12+
13+
Add default-unsupported `EntityStore::update_field_if_matches(schema, id, FieldName, expected DynamicValue, value DynamicValue) -> Result<bool, BackendError>` and dynamic bridge. Backend implementations atomically compare the current field and update just it, returning false for a missing/stale attachment. PostgreSQL UPDATE RETURNING participates in the existing revision transaction; SurrealDB uses a conditional UPDATE; SQL Server uses JSON_MODIFY with a bound exact JSON comparison. Shared tests verify stale snapshots do not detach replacements, concurrent clears have one winner, unrelated updates survive and null/missing results are safe. Parent owns actor message, HTTP route and audit handling.

‎.github/workflows/cel-kani.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CEL scalar proofs
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'crates/schema-forge-cel/**'
7+
- 'crates/schema-forge-core/**'
8+
- 'Cargo.lock'
9+
- 'Cargo.toml'
10+
- '.github/workflows/cel-kani.yml'
11+
push:
12+
branches: [main]
13+
paths:
14+
- 'crates/schema-forge-cel/**'
15+
- 'crates/schema-forge-core/**'
16+
- 'Cargo.lock'
17+
- 'Cargo.toml'
18+
- '.github/workflows/cel-kani.yml'
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
scalar-proofs:
26+
runs-on: ubuntu-24.04
27+
timeout-minutes: 45
28+
steps:
29+
- uses: actions/checkout@v4
30+
- uses: dtolnay/rust-toolchain@stable
31+
- name: Install build prerequisites
32+
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
33+
- name: Install pinned integer solver
34+
run: |
35+
curl -fLsS -o "$RUNNER_TEMP/z3.zip" https://github.com/Z3Prover/z3/releases/download/z3-5.1.0/z3-5.1.0-x64-glibc-2.39.zip
36+
echo "f47be8d27d3230e823bf1eeede2fe0abaca55bb78d0b59974370e6689a92284a $RUNNER_TEMP/z3.zip" | sha256sum -c -
37+
unzip -q "$RUNNER_TEMP/z3.zip" -d "$RUNNER_TEMP/z3"
38+
echo "$RUNNER_TEMP/z3/z3-5.1.0-x64-glibc-2.39/bin" >> "$GITHUB_PATH"
39+
- name: Install pinned verifier
40+
run: |
41+
cargo install --locked kani-verifier --version 0.67.0
42+
cargo kani setup
43+
- name: Prove production scalar contracts and temporal arithmetic
44+
run: cargo kani -p schema-forge-cel -Z function-contracts --jobs 4 --output-format terse

‎Cargo.lock‎

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/schema-forge-acton/Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "schema-forge-acton"
3-
version = "0.39.0"
3+
version = "0.40.0"
44
edition = "2021"
55

66
[dependencies]
@@ -44,7 +44,7 @@ aws-lc-rs = { version = "1", features = ["fips"], optional = true }
4444
rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "logging"] }
4545
schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" }
4646
lettre = { version = "0.11.22", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "builder", "pool", "hostname"] }
47-
schema-forge-cel = { version = "0.9.0", path = "../schema-forge-cel" }
47+
schema-forge-cel = { version = "0.10.0", path = "../schema-forge-cel" }
4848
rust_xlsxwriter = { version = "0.95.0", features = ["chrono"] }
4949
zip = "8.6.0"
5050

‎crates/schema-forge-acton/src/access.rs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,45 @@ pub fn filter_entity_fields(
299299
fields_to_remove
300300
}
301301

302+
/// Filter a PATCH delta using the complete post-patch resource for Cedar's
303+
/// required attributes and resource-dependent policies. Authorization failures
304+
/// abort the write; explicit policy denials retain ordinary field filtering.
305+
pub(crate) fn filter_patch_fields(
306+
store: &Arc<PolicyStore>,
307+
delta: &mut Entity,
308+
resource: &Entity,
309+
schema: &SchemaDefinition,
310+
claims: Option<&Claims>,
311+
) -> Result<(), ForgeError> {
312+
let mut denied = Vec::new();
313+
for name in delta.fields.keys() {
314+
if schema
315+
.field(name)
316+
.is_none_or(|field| field.field_access().is_none())
317+
{
318+
continue;
319+
}
320+
let decision =
321+
authorize_field(store, claims, schema, resource, name, FieldDirection::Write).map_err(
322+
|_| ForgeError::Forbidden {
323+
message: "Could not authorize a patched field.".into(),
324+
},
325+
)?;
326+
if !decision.errors.is_empty() {
327+
return Err(ForgeError::Forbidden {
328+
message: "Could not authorize a patched field.".into(),
329+
});
330+
}
331+
if !decision.is_allow() {
332+
denied.push(name.clone());
333+
}
334+
}
335+
for name in denied {
336+
delta.fields.remove(&name);
337+
}
338+
Ok(())
339+
}
340+
302341
/// Inject tenant scoping filter into a query.
303342
///
304343
/// Adds `_tenant = <tenant_id>` filter based on the deepest tenant in the
@@ -1094,6 +1133,71 @@ mod tests {
10941133
Arc::new(PolicyStore::new(snapshot))
10951134
}
10961135

1136+
#[test]
1137+
fn patch_authorization_errors_abort_without_filtering_the_delta() {
1138+
let schema = schema_forge_dsl::parse(
1139+
r#"
1140+
schema Settings {
1141+
key: text required
1142+
draft: text @field_access(read: ["admin"], write: ["admin"])
1143+
}
1144+
"#,
1145+
)
1146+
.unwrap()
1147+
.remove(0);
1148+
let store = store_for(&schema, None);
1149+
let claims = make_claims(&["admin"]);
1150+
let mut delta = Entity::with_id(
1151+
EntityId::new("settings"),
1152+
schema.name.clone(),
1153+
BTreeMap::from([("draft".into(), DynamicValue::Text("changed".into()))]),
1154+
);
1155+
// A malformed full resource must never turn authorization failure
1156+
// into a successful empty update, even for a permitted role.
1157+
let incomplete = delta.clone();
1158+
let error = filter_patch_fields(&store, &mut delta, &incomplete, &schema, Some(&claims))
1159+
.unwrap_err();
1160+
assert!(matches!(error, ForgeError::Forbidden { .. }));
1161+
assert_eq!(delta.fields, incomplete.fields);
1162+
1163+
let mut complete = incomplete.clone();
1164+
complete
1165+
.fields
1166+
.insert("key".into(), DynamicValue::Text("deployment".into()));
1167+
filter_patch_fields(&store, &mut delta, &complete, &schema, Some(&claims)).unwrap();
1168+
assert_eq!(delta.fields, incomplete.fields);
1169+
1170+
let store = store_for(
1171+
&schema,
1172+
Some(
1173+
r#"
1174+
forbid (
1175+
principal,
1176+
action == Action::"WriteFieldSettings_draft",
1177+
resource is Settings
1178+
) when { 9223372036854775807 + 1 == 0 };
1179+
"#,
1180+
),
1181+
);
1182+
let decision = authorize_field(
1183+
&store,
1184+
Some(&claims),
1185+
&schema,
1186+
&complete,
1187+
"draft",
1188+
FieldDirection::Write,
1189+
)
1190+
.unwrap();
1191+
assert!(
1192+
!decision.errors.is_empty(),
1193+
"overflow must be a Cedar evaluation error"
1194+
);
1195+
let error =
1196+
filter_patch_fields(&store, &mut delta, &complete, &schema, Some(&claims)).unwrap_err();
1197+
assert!(matches!(error, ForgeError::Forbidden { .. }));
1198+
assert_eq!(delta.fields, incomplete.fields);
1199+
}
1200+
10971201
#[test]
10981202
fn export_unauthenticated_is_rejected() {
10991203
let schema = schema_read_for_analyst();

‎crates/schema-forge-acton/src/actor.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,28 @@ fn configure_backend_operations(actor: &mut ManagedActor<Idle, ForgeActor>) {
501501
})
502502
});
503503

504+
actor.act_on::<crate::messages::UpdateFieldIfMatches>(|actor, ctx| {
505+
let backend = actor.model.backend.clone();
506+
let request = ctx.message().clone();
507+
Reply::pending(async move {
508+
let result = match backend {
509+
Some(backend) => {
510+
backend
511+
.update_field_if_matches(
512+
&request.schema,
513+
&request.id,
514+
&request.field,
515+
&request.expected,
516+
&request.value,
517+
)
518+
.await
519+
}
520+
None => Err(no_backend_error()),
521+
};
522+
request.reply.send(result).await;
523+
})
524+
});
525+
504526
actor.act_on::<UpdateEntity>(|actor, ctx| {
505527
let backend = actor.model.backend.clone();
506528
let entity = ctx.message().entity.clone();

0 commit comments

Comments
 (0)