Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions crates/registry-bregctl/src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ fn has_local_subject_marker(value: &Value) -> bool {
}
}

/// Resolve an explicit local fixture identity, never an arbitrary expectation.
/// Resolve explicit fixture assertions about the selected local client's subject.
/// The caller writes only its private captured project, not authored inputs.
fn resolve_local_subject_markers(
journeys: &mut Value,
Expand All @@ -963,7 +963,9 @@ fn resolve_local_subject_markers(
.as_array_mut()
.context("journey steps must be an array")?
{
if step["claims"]["principal"] != LOCAL_SUBJECT_MARKER {
let principal_marker = step["claims"]["principal"] == LOCAL_SUBJECT_MARKER;
let row_claim_marker = step["claims"]["directClaims"]["sub"] == LOCAL_SUBJECT_MARKER;
if !principal_marker && !row_claim_marker {
continue;
}
let profile = step["accessProfile"]
Expand All @@ -981,18 +983,23 @@ fn resolve_local_subject_markers(
.any(|profile| profile.principal_claim != "sub")
{
bail!(
"$localClientSubject is supported only as claims.principal for an authenticated sub access profile"
"$localClientSubject requires an authenticated access profile with principalClaim: sub"
);
}
let client = journey_client(clients, &journey_id, step_id, profile)?;
step["claims"]["principal"] = json!(registry_thunderid_tooling::local::agent_id(
issuer_instance,
&client.id
));
let subject = registry_thunderid_tooling::local::agent_id(issuer_instance, &client.id);
if principal_marker {
step["claims"]["principal"] = json!(subject);
}
if row_claim_marker {
step["claims"]["directClaims"]["sub"] = json!(subject);
}
}
}
if has_local_subject_marker(journeys) {
bail!("$localClientSubject must appear only as a sub profile's journey claims.principal");
bail!(
"$localClientSubject must appear only as journey claims.principal or claims.directClaims.sub"
);
}
Ok(())
}
Expand Down
58 changes: 47 additions & 11 deletions crates/registry-bregctl/src/dev/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,8 +629,9 @@ fn local_subject_marker_uses_actual_issuer_owner_and_exact_client_binding() {
let compiled = local_principal_compiled(&local_principal_source("sub"));
let mut journeys = json!({"journeys":[{"id":"read","steps":[
{"id":"ordinary","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER}},
{"id":"variant","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER}},
{"id":"literal","accessProfile":"operator","claims":{"principal":"authored-expectation"}}
{"id":"variant","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER,"directClaims":{"sub":LOCAL_SUBJECT_MARKER}}},
{"id":"literal","accessProfile":"operator","claims":{"principal":"authored-expectation","directClaims":{"sub":LOCAL_SUBJECT_MARKER}}},
{"id":"literal-row-claim","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER,"directClaims":{"sub":"different-subject"}}}
]}]});
resolve_local_subject_markers(&mut journeys, &clients, &compiled, "actual-issuer-owner")
.unwrap();
Expand All @@ -648,17 +649,52 @@ fn local_subject_marker_uses_actual_issuer_owner_and_exact_client_binding() {
journeys["journeys"][0]["steps"][2]["claims"]["principal"],
"authored-expectation"
);
assert_eq!(
journeys["journeys"][0]["steps"][1]["claims"]["directClaims"]["sub"],
journeys["journeys"][0]["steps"][1]["claims"]["principal"]
);
assert_eq!(
journeys["journeys"][0]["steps"][2]["claims"]["directClaims"]["sub"],
journeys["journeys"][0]["steps"][0]["claims"]["principal"]
);
assert_eq!(
journeys["journeys"][0]["steps"][3]["claims"]["directClaims"]["sub"],
"different-subject"
);
assert!(!has_local_subject_marker(&journeys));
for claim in ["registry_principal", "sub"] {
let mut misplaced = json!({"journeys":[{"id":"read","steps":[{"id":"ordinary","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER},"other":LOCAL_SUBJECT_MARKER}]}]});
assert!(resolve_local_subject_markers(
&mut misplaced,
&clients,
&local_principal_compiled(&local_principal_source(claim)),
"owner"
)
.is_err());
}

#[test]
fn local_subject_marker_refuses_other_locations_profiles_and_missing_clients() {
let (_temporary, _, mut clients, _) = fixture();
let compiled = local_principal_compiled(&local_principal_source("sub"));
let journeys = |step| json!({"journeys":[{"id":"read","steps":[step]}]});
for step in [
json!({"id":"ordinary","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER},"request":{"data":{"owner-subject":LOCAL_SUBJECT_MARKER}}}),
json!({"id":"ordinary","accessProfile":"operator","claims":{"directClaims":{"other":LOCAL_SUBJECT_MARKER}}}),
json!({"id":"ordinary","accessProfile":"operator","claims":{"directClaims":{"sub":[LOCAL_SUBJECT_MARKER]}}}),
] {
let error =
resolve_local_subject_markers(&mut journeys(step), &clients, &compiled, "owner")
.unwrap_err();
assert!(error.to_string().contains("must appear only"));
}

let mut marked = journeys(
json!({"id":"ordinary","accessProfile":"operator","claims":{"directClaims":{"sub":LOCAL_SUBJECT_MARKER}}}),
);
let other_profile = local_principal_compiled(&local_principal_source("registry_principal"));
let error =
resolve_local_subject_markers(&mut marked.clone(), &clients, &other_profile, "owner")
.unwrap_err();
assert!(error.to_string().contains("principalClaim: sub"));

clients.clients.clear();
let error =
resolve_local_subject_markers(&mut marked, &clients, &compiled, "owner").unwrap_err();
assert!(error
.to_string()
.contains("needs one unambiguous local client"));
}

#[test]
Expand Down
125 changes: 125 additions & 0 deletions crates/registry-bregctl/tests/dev_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,131 @@ seed:
fs::remove_dir_all(parent).unwrap();
}

#[test]
#[ignore = "requires matching installed breg/bregctl binaries and Docker"]
fn installed_dev_rehearses_subject_bound_rows_with_two_local_clients() {
let binary = Path::new(env!("CARGO_BIN_EXE_bregctl"));
let temporary = tempfile::Builder::new()
.prefix("breg-native-subject-test-")
.tempdir_in(binary.parent().unwrap())
.unwrap();
fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700)).unwrap();
let parent = fs::canonicalize(temporary.keep()).unwrap();
let session = Session {
project: parent.join("registry"),
path: std::env::join_paths([binary.parent().unwrap()]).unwrap(),
docker: installed("docker"),
};
fs::create_dir_all(session.project.join("tests")).unwrap();
write(
&session.project.join("registry.yaml"),
br#"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1
kind: BRegProject
project: {id: subject-bound-registry, version: "1", defaultLanguage: en, canonicalBaseIri: https://subject-bound.example.test}
package: {sourceRevision: synthetic-1}
datasets: [{id: test-dataset}]
recordTypes:
- id: record
primaryDataset: test-dataset
route: records
mutationMode: mutable
fields:
- {id: owner-subject, type: string, maximumLength: 64, required: true, classification: internal}
accessProfiles:
- id: self-service
principalClaim: sub
actorKind: human
requesterClients: [owner, other]
requiredScopes: [registry:records:write]
permissions:
records:
- recordType: record
operations: [create, list]
readableFields: [owner-subject]
writableFields: [owner-subject]
rowBoundaries:
- {field: owner-subject, claim: sub, operator: equals}
"#,
);
write(
&session.project.join("dev-clients.yaml"),
br#"apiVersion: id.registrystack.org/formats/breg/dev-clients/v1alpha1
kind: BRegDevClients
clients:
- id: owner
accessProfiles: [self-service]
allowHumanFixture: true
scopes: [registry:records:write]
claims: {registry_actor_kind: human}
- id: other
accessProfiles: [self-service]
allowHumanFixture: true
scopes: [registry:records:write]
claims: {registry_actor_kind: human}
testBindings: [{journeyId: ownership, stepId: list-as-other}]
seed: []
"#,
);
let owner_subject =
registry_thunderid_tooling::local::agent_id("subject-bound-registry", "owner");
let journeys = r#"apiVersion: id.registrystack.org/formats/breg/journeys/v1
kind: BRegJourneys
journeys:
- id: ownership
steps:
- id: create-as-owner
recordType: record
accessProfile: self-service
claims:
principal: "$localClientSubject"
scopes: [registry:records:write]
actorKind: human
requesterClient: owner
directClaims: {sub: "$localClientSubject"}
request: {type: create, data: {owner-subject: "synthetic-owner-subject"}}
expect: {outcome: success, status: 201}
capture: owned-record
- id: list-as-owner
recordType: record
accessProfile: self-service
claims:
principal: "$localClientSubject"
scopes: [registry:records:write]
actorKind: human
requesterClient: owner
directClaims: {sub: "$localClientSubject"}
request: {type: list}
expect:
outcome: success
status: 200
count: 1
members:
owned-record:
fields: {owner-subject: "synthetic-owner-subject"}
- id: list-as-other
recordType: record
accessProfile: self-service
claims:
principal: "$localClientSubject"
scopes: [registry:records:write]
actorKind: human
requesterClient: other
directClaims: {sub: "$localClientSubject"}
request: {type: list}
expect: {outcome: success, status: 200, count: 0}
"#
.replace("synthetic-owner-subject", &owner_subject);
let journeys_path = session.project.join("tests/journeys.yaml");
write(&journeys_path, journeys.as_bytes());

let report = session.start();
assert_eq!(report["outcome"], "ready");
assert_eq!(fs::read(journeys_path).unwrap(), journeys.as_bytes());
session.remove();
drop(session);
fs::remove_dir_all(parent).unwrap();
}

#[test]
#[ignore = "requires matching installed breg/bregctl binaries and Docker; runs a retained local database"]
fn installed_dev_switches_one_clients_claims_and_recovers_import_seed() {
Expand Down
69 changes: 31 additions & 38 deletions crates/registry-casework/tests/breg_review_journey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1494,45 +1494,38 @@ async fn native_proposal_journey(producer_id: &'static str) {
.unwrap(),
"a submitted review cannot grant BReg application authority"
);
let claimed = reqwest::Client::new()
.post(
casework_url
.join(&format!("v1/review-tasks/{id}/claim"))
.unwrap(),
)
.bearer_auth(&human)
.header("registry-casework-profile", "staff")
.header("registry-source-profile", "proposal-reviewer")
.header(
"if-match",
format!("\"{}\"", shown["revision"].as_i64().unwrap()),
)
.header("idempotency-key", "native-claim")
.send()
.await
.unwrap();
let claim_status = claimed.status();
let claimed: Value = claimed.json().await.unwrap();
assert_eq!(claim_status, StatusCode::OK, "{claimed}");
let decided = reqwest::Client::new()
.post(
casework_url
.join(&format!("v1/review-tasks/{id}/decisions"))
.unwrap(),
)
.bearer_auth(&human)
.header("registry-casework-profile", "staff")
.header("registry-source-profile", "proposal-reviewer")
.header(
"if-match",
format!("\"{}\"", claimed["revision"].as_i64().unwrap()),
)
.header("idempotency-key", "native-approve")
.json(&json!({"decision":{"type":"approve","outcome":"approved"}}))
.send()
// Run the adopter example against the same live BReg and Casework routers.
use std::io::Write;
let mut auth_header = tempfile::NamedTempFile::new_in(scratch.path()).unwrap();
writeln!(
auth_header,
"Authorization: Bearer {human}\nRegistry-Casework-Profile: staff\nRegistry-Review-Profile: staff"
)
.unwrap();
let mut example = std::process::Command::new("bash");
example
.arg(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../products/casework/examples/canonical-review/approve-task.sh"
))
.args([casework_url.as_str(), id])
.arg(auth_header.path())
.arg("proposal-reviewer");
let example = tokio::task::spawn_blocking(move || example.output())
.await
.unwrap();
assert_eq!(decided.status(), StatusCode::NO_CONTENT);
.unwrap()
.expect("run the canonical staff review example");
assert!(
example.status.success(),
"staff review example failed: {}",
String::from_utf8_lossy(&example.stderr)
);
let receipt: Value = serde_json::from_slice(&example.stdout).unwrap();
assert_eq!(receipt["decision"], "approve");
assert_eq!(receipt["outcome"], "approved");
assert_eq!(receipt["policy"], serde_json::to_value(&binding).unwrap());
assert!(receipt.get("reason").is_none());
assert!(receipt.get("result").is_none());
let supervisor = minted(
&idp,
json!([CASEWORK_AUDIENCE, BREG_AUDIENCE]),
Expand Down
1 change: 1 addition & 0 deletions crates/registry-caseworkctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,7 @@ mod tests {
("casework.yaml", include_bytes!("../../../products/casework/examples/canonical-review/casework.yaml").as_slice()),
("schemas/correct-address.schema.json", include_bytes!("../../../products/casework/examples/canonical-review/schemas/correct-address.schema.json").as_slice()),
("dev-clients.yaml", include_bytes!("../../../products/casework/examples/canonical-review/dev-clients.yaml").as_slice()),
("approve-task.sh", include_bytes!("../../../products/casework/examples/canonical-review/approve-task.sh").as_slice()),
] {
assert_eq!(std::fs::read(project.join(name)).unwrap(), bytes, "{name}");
}
Expand Down
4 changes: 4 additions & 0 deletions crates/registry-caseworkctl/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ pub(super) fn init(project: &Path, template: &str) -> Result<Value> {
stage.path().join("README.md"),
include_bytes!("../../../products/casework/examples/canonical-review/README.md"),
)?;
fs::write(
stage.path().join("approve-task.sh"),
include_bytes!("../../../products/casework/examples/canonical-review/approve-task.sh"),
)?;
}
if approval_pattern {
fs::write(
Expand Down
Loading
Loading