Skip to content

Commit 234d192

Browse files
committed
feat: record project role coverage changes in the snapshot change feed
When a publication changes a custom role's effective grants, either by removing pairs or by changing selector coverage, the change is appended to that snapshot's change feed as role:<key>, as CHI-907 section 13 requires. Validated: the native role tests (13 on SQLite and PostgreSQL), the full orchestration native suite (104) and make check-ci.
1 parent dd19d9d commit 234d192

6 files changed

Lines changed: 94 additions & 9 deletions

File tree

‎crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,45 @@ pub(crate) async fn current_role(
262262
))
263263
}
264264

265+
fn grants_digest(grants: Option<&[RoleGrant]>) -> Result<Option<String>, ControlError> {
266+
let Some(grants) = grants else {
267+
return Ok(None);
268+
};
269+
let text =
270+
serde_json::to_string(grants).map_err(|error| ControlError::Invalid(error.to_string()))?;
271+
Ok(Some(sha256_hex(text.as_bytes())))
272+
}
273+
274+
/// Append an effective role's coverage change to the snapshot's change feed, after its node changes.
275+
pub(crate) async fn record_role_change(
276+
transaction: ControlTransaction,
277+
(snapshot_id, role_key): (Uuid, &str),
278+
(before, after): (Option<&[RoleGrant]>, Option<&[RoleGrant]>),
279+
) -> Result<ControlTransaction, ControlError> {
280+
let kind = if after.is_some() {
281+
"changed"
282+
} else {
283+
"removed"
284+
};
285+
let (transaction, rows) = query(
286+
transaction,
287+
"SELECT COALESCE(MAX(change_seq) + 1, 0) AS next FROM sqb_snapshot_changes WHERE snapshot_id=$1",
288+
vec![Parameter::Id(snapshot_id)],
289+
)
290+
.await?;
291+
let sequence = rows
292+
.first()
293+
.and_then(|row| row["next"].as_i64())
294+
.unwrap_or_default();
295+
let (transaction, _) = query(transaction,
296+
"INSERT INTO sqb_snapshot_changes (snapshot_id, change_seq, change_kind, subject_key, before_hash, after_hash) \
297+
VALUES ($1,$2,$3,$4,$5,$6)",
298+
vec![Parameter::Id(snapshot_id), Parameter::Integer(sequence), Parameter::Text(kind.into()),
299+
Parameter::Text(format!("role:{role_key}")), Parameter::OptionalText(grants_digest(before)?),
300+
Parameter::OptionalText(grants_digest(after)?)]).await?;
301+
Ok(transaction)
302+
}
303+
265304
pub(crate) async fn set_effective_role(
266305
mut transaction: ControlTransaction,
267306
(location_id, target, role_key): (Uuid, &str, &str),

‎crates/sqlbuild-orchestration/src/authorization/main/publish_custom_roles.rs‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
//! Record each published custom role; removed pairs and selector coverage apply now, additions wait.
1+
//! Record each published custom role; removals and coverage apply now and enter the change feed; additions wait.
22
33
use serde_json::Value;
44

55
use crate::authorization::_helpers::custom_roles::{
6-
decode_roles, effective_role, narrows, retained_grants, set_effective_role,
6+
decode_grants, decode_roles, effective_role, narrows, record_role_change, retained_grants,
7+
set_effective_role,
78
};
89
use crate::snapshots::models::SnapshotRecord;
910
use crate::store::errors::ControlError;
@@ -20,15 +21,22 @@ pub(crate) async fn publish_custom_roles(
2021
let rows;
2122
(transaction, rows) = query(
2223
transaction,
23-
"SELECT role_key FROM sqb_effective_roles WHERE location_id=$1 AND target=$2",
24+
"SELECT role_key,resolved_grants_json FROM sqb_effective_roles WHERE location_id=$1 AND target=$2",
2425
vec![Parameter::Id(scope.0), Parameter::Text(scope.1.into())],
2526
)
2627
.await?;
27-
for key in rows
28+
for row in rows
2829
.iter()
29-
.filter_map(|row| row["role_key"].as_str())
30-
.filter(|key| !roles.contains_key(*key))
30+
.filter(|row| !roles.contains_key(row["role_key"].as_str().unwrap_or_default()))
3131
{
32+
let key = row["role_key"].as_str().unwrap_or_default();
33+
let before = decode_grants(&row["resolved_grants_json"])?;
34+
transaction = record_role_change(
35+
transaction,
36+
(record.snapshot_id, key),
37+
(Some(&before), None),
38+
)
39+
.await?;
3240
(transaction, _) = query(
3341
transaction,
3442
"DELETE FROM sqb_effective_roles WHERE location_id=$1 AND target=$2 AND role_key=$3",
@@ -59,6 +67,14 @@ pub(crate) async fn publish_custom_roles(
5967
} else {
6068
digest.as_str()
6169
};
70+
if next != grants {
71+
transaction = record_role_change(
72+
transaction,
73+
(record.snapshot_id, key),
74+
(Some(&grants), Some(&next)),
75+
)
76+
.await?;
77+
}
6278
if next != grants || next_digest != digest {
6379
transaction =
6480
set_effective_role(transaction, (scope.0, scope.1, key), (next_digest, &next))

‎crates/sqlbuild-orchestration/tests/credentials/roles/helpers.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,28 @@ pub(crate) async fn granting_role(
217217
authorize_member(store, fixture, (action, &[])).await.ok()
218218
}
219219

220+
/// Custom role entries of the snapshot change feed, by publication generation.
221+
pub(crate) async fn role_changes(store: &ControlStore) -> Vec<(i64, String)> {
222+
let transaction = begin(store, TransactionMode::Read).await.expect("read");
223+
let (transaction, rows) = query(
224+
transaction,
225+
"SELECT s.generation, c.change_kind FROM sqb_snapshot_changes c JOIN sqb_snapshots s ON s.snapshot_id=c.snapshot_id \
226+
WHERE c.subject_key='role:orders_support' ORDER BY s.generation, c.change_seq",
227+
vec![],
228+
)
229+
.await
230+
.expect("role changes");
231+
commit(transaction).await.expect("commit");
232+
rows.iter()
233+
.map(|row| {
234+
(
235+
row["generation"].as_i64().unwrap_or_default(),
236+
row["change_kind"].as_str().unwrap_or_default().into(),
237+
)
238+
})
239+
.collect()
240+
}
241+
220242
/// Role approval audit facts in commit order: action and recorded outcome.
221243
pub(crate) async fn role_audit(store: &ControlStore) -> Vec<(String, Value)> {
222244
let transaction = begin(store, TransactionMode::Read).await.expect("read");

‎crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::authorization::main::compile_roles::compile_roles;
99
use crate::credentials::models::{Action, RoleAssignmentRequest, RoleGrant, RoleKey};
1010
use crate::credentials::tests::role_helpers::{
1111
SUPPORT_ROLE, activate, assign, authorize_member, developer_grants, orders_grant,
12-
publish_support_role, role_fixture, try_request,
12+
publish_support_role, role_changes, role_fixture, try_request,
1313
};
1414
use crate::credentials::tests::test_types::{ProjectRoleTestCase, RolesFileTestCase};
1515
use crate::runs::tests::helpers::snapshot;
@@ -92,6 +92,12 @@ fn given_approved_selector_when_it_matches_more_nodes_then_coverage_grows_withou
9292
"{}: coverage growth is not a widening to approve",
9393
test_case.description
9494
);
95+
assert_eq!(
96+
role_changes(store).await,
97+
[(2, "changed".to_owned()), (3, "changed".to_owned())],
98+
"{}: the dropped view grant and the grown coverage enter the change feed",
99+
test_case.description
100+
);
95101
close_test_store(fixture).await;
96102
});
97103
}

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ definition, and any publication that adds a selector and permission pair, takes
127127
an administrator requests it with `POST /api/admin/role-activations` and another administrator
128128
approves it with `POST /api/admin/role-approvals`. A sole administrator must confirm a
129129
self-approval. Removed pairs stop applying on publish. An unchanged selector covers new
130-
matching nodes on publish without approval. A denial lists the nodes the request was missing.
130+
matching nodes on publish without approval. Either change appears in the publication's change
131+
list as `role:<key>`. A denial lists the nodes the request was missing.
131132

132133
## Repair control views
133134

‎website/src/content/docs/docs/cli/server.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ definition, and any publication that adds a selector and permission pair, takes
118118
an administrator requests it with `POST /api/admin/role-activations` and another administrator
119119
approves it with `POST /api/admin/role-approvals`. A sole administrator must confirm a
120120
self-approval. Removed pairs stop applying on publish. An unchanged selector covers new
121-
matching nodes on publish without approval. A denial lists the nodes the request was missing.
121+
matching nodes on publish without approval. Either change appears in the publication's change
122+
list as `role:<key>`. A denial lists the nodes the request was missing.
122123

123124
## Repair control views
124125

0 commit comments

Comments
 (0)