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
Original file line number Diff line number Diff line change
Expand Up @@ -858,9 +858,11 @@ async fn human_identity_is_the_principal_claim_and_a_human_token_without_it_is_r
let registry = compiled();
let idp = MockIdp::start().await;
let audience = "urn:example:breg";
let mut verifier = oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]);
verifier.allowed_clients = vec!["agent-client".to_owned()];
let auth = RegistryAuthenticator::new(
&registry,
oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]),
verifier,
Arc::new(JwksFetcher::new_with_fetch_url_policy(
idp.jwks_uri(),
JwksFetcherConfig::defaults(),
Expand All @@ -871,6 +873,7 @@ async fn human_identity_is_the_principal_claim_and_a_human_token_without_it_is_r
.expect("human verifier config is valid");
let claims = json!({
"aud": audience,
"azp": "agent-client",
"registry_principal": "reviewer-principal",
"registry_actor_kind": "human",
"registry_purpose": "case-management",
Expand Down
20 changes: 20 additions & 0 deletions crates/registry-breg/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,26 @@ fn validate_claim_mapping(
return Err(AuthenticationConfigError::InvalidClaimMapping);
}
}
if registry.actions().actions.iter().any(|action| {
action.permissions.iter().any(|profile| {
profile
.requester_clients
.iter()
.any(|client| !verifier.allowed_clients.contains(client))
}) || match &action.execution {
crate::model::CompiledActionExecution::Immediate => false,
crate::model::CompiledActionExecution::Proposal { proposal } => {
proposal.permissions.iter().any(|profile| {
profile
.requester_clients
.iter()
.any(|client| !verifier.allowed_clients.contains(client))
})
}
}
}) {
return Err(AuthenticationConfigError::NamedClientNotListed);
}
// A recipient's client that the verifier would refuse can never read under
// consent, so a mismatch is a configuration error, not a silent denial.
if registry.recipients().clients().any(|client| {
Expand Down
95 changes: 95 additions & 0 deletions crates/registry-breg/tests/http_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2631,3 +2631,98 @@ async fn dataset_only_client_and_standing_actor_mapping_are_checked_at_construct
authenticator_with_verifier(&registry, &idp, verifier, claims)
.expect("the dataset-only standing agent has an exact trusted actor mapping");
}

#[tokio::test]
async fn action_and_proposal_only_clients_are_checked_at_construction() {
let idp = MockIdp::start().await;
let mut action_source = action_only_claim_source();
action_source["accessProfiles"][0]["actorKind"] = json!("service");
action_source["accessProfiles"][0]["requesterClients"] = json!(["workflow-client"]);
let action_registry = compile_action_claim_source(&action_source);
assert!(action_registry
.entities()
.values()
.all(|entity| entity.access_profiles.is_empty()));
assert!(action_registry.actions().actions[0].permissions[0]
.requester_clients
.contains("workflow-client"));

let mut verifier = verifier_config(&idp);
verifier.allowed_clients = vec!["another-client".into()];
assert_eq!(
authenticator_with_verifier(&action_registry, &idp, verifier.clone(), authority_claims())
.unwrap_err(),
AuthenticationConfigError::NamedClientNotListed
);
verifier.allowed_clients = vec!["workflow-client".into()];
authenticator_with_verifier(&action_registry, &idp, verifier.clone(), authority_claims())
.expect("the admitted action-only service client is accepted");

let mut proposal_source = action_source;
proposal_source["accessProfiles"][0]["actorKind"] = json!("agent");
proposal_source["actions"][0]["execution"] = json!({
"mode": "proposal",
"proposal": {
"review": {"type": "none"},
"application": {"mode": "manual"},
"retention": {"mode": "retain"}
}
});
let mut starter = proposal_source["accessProfiles"][0].clone();
starter["id"] = json!("starter");
starter
.as_object_mut()
.expect("the starter profile is an object")
.remove("actorKind");
starter
.as_object_mut()
.expect("the starter profile is an object")
.remove("requesterClients");
starter["permissions"]["actions"][0]["operations"] = json!(["start"]);
proposal_source["accessProfiles"][0]["permissions"] = json!({
"proposals": [{
"action": "register-child",
"operations": ["edit"],
"ownership": "caller",
"targets": [
{"recordType": "parent", "rowBoundaries": "unrestricted"},
{"recordType": "child", "rowBoundaries": "unrestricted"}
]
}]
});
proposal_source["accessProfiles"]
.as_array_mut()
.expect("access profiles are a list")
.push(starter);
let proposal_registry = compile_action_claim_source(&proposal_source);
assert!(proposal_registry.actions().actions[0]
.permissions
.iter()
.all(|profile| profile.profile_id != "registrar"));
let proposal = match &proposal_registry.actions().actions[0].execution {
registry_breg::model::CompiledActionExecution::Proposal { proposal } => proposal,
registry_breg::model::CompiledActionExecution::Immediate => {
panic!("the fixture compiles as a Proposal")
}
};
assert!(proposal
.permissions
.iter()
.any(|profile| profile.profile_id == "registrar"
&& profile.requester_clients.contains("workflow-client")));

verifier.allowed_clients = vec!["another-client".into()];
assert_eq!(
authenticator_with_verifier(
&proposal_registry,
&idp,
verifier.clone(),
authority_claims()
)
.unwrap_err(),
AuthenticationConfigError::NamedClientNotListed
);
verifier.allowed_clients = vec!["workflow-client".into()];
authenticator_with_verifier(&proposal_registry, &idp, verifier, authority_claims())
.expect("an admitted Proposal-only standing agent needs no trusted actor mapping");
}
72 changes: 62 additions & 10 deletions crates/registry-bregctl/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2964,22 +2964,29 @@ fn check_refuses_a_runtime_configuration_in_the_reader_words() {
}

/// The minimal project with its reader profile limited to the client `portal`.
fn minimal_project_limited_to_portal(scratch: &TestProject) {
fn minimal_project_limited_to_portal(scratch: &TestProject, include_record_permissions: bool) {
let example = minimal_example();
let registry = fs::read_to_string(example.join("registry.yaml"))
.expect("the minimal registry reads")
.replace(
" requiredPurposes: [directory-administration]\n",
" requiredPurposes: [directory-administration]\n actorKind: service\n requesterClients: [portal]\n",
);
assert!(registry.contains("requesterClients: [portal]"));
let mut registry = registry_platform_yaml::Reader::new("registry.yaml")
.scan(&fs::read(example.join("registry.yaml")).expect("the minimal registry reads"))
.unwrap()
.unwrap()
.to_json_value();
registry["accessProfiles"][0]["actorKind"] = json!("service");
registry["accessProfiles"][0]["requesterClients"] = json!(["portal"]);
if !include_record_permissions {
registry["accessProfiles"][0]["permissions"]
.as_object_mut()
.unwrap()
.remove("records");
}
let registry = serde_norway::to_string(&registry).expect("the edited registry encodes");
fs::write(scratch.path().join("registry.yaml"), registry).expect("the registry writes");
}

#[test]
fn check_refuses_a_runtime_that_lists_none_of_the_clients_a_profile_names() {
let scratch = TestProject::from_registry_source(b"");
minimal_project_limited_to_portal(&scratch);
minimal_project_limited_to_portal(&scratch, true);
let runtime = edited_runtime_config(&scratch, |source| {
source.replace(
"allowedClients: [directory-client]",
Expand Down Expand Up @@ -3023,6 +3030,51 @@ fn check_refuses_a_runtime_that_lists_none_of_the_clients_a_profile_names() {
assert!(!human_output(&human).contains("portal"), "{human:?}");
}

#[test]
fn check_refuses_unlisted_clients_on_action_and_proposal_only_profiles() {
let scratch = TestProject::from_registry_source(b"");
minimal_project_limited_to_portal(&scratch, false);
let refused_runtime = edited_runtime_config(&scratch, |source| {
source.replace(
"allowedClients: [directory-client]",
"allowedClients: unrestricted",
)
});

let refused = bregctl(&[
"--format",
"json",
"check",
path(scratch.path()),
"--runtime-config",
path(&refused_runtime),
]);
assert_eq!(refused.status.code(), Some(1), "{refused:?}");
let report = json_stdout(&refused);
let diagnostics = report["diagnostics"].as_array().expect("diagnostics list");
assert_eq!(diagnostics.len(), 1, "{report}");
assert_eq!(diagnostics[0]["code"], "breg.runtime.clients-unlisted");
assert_eq!(
diagnostics[0]["path"],
"/authentication/oidc/allowedClients"
);
assert!(!report.to_string().contains("portal"), "{report}");

let accepted_runtime = edited_runtime_config(&scratch, |source| {
source.replace(
"allowedClients: [directory-client]",
"allowedClients: [portal]",
)
});
let accepted = bregctl(&[
"check",
path(scratch.path()),
"--runtime-config",
path(&accepted_runtime),
]);
assert_eq!(accepted.status.code(), Some(0), "{accepted:?}");
}

#[test]
fn check_refuses_a_principal_claim_the_access_profiles_do_not_use() {
let scratch = TestProject::from_registry_source(b"");
Expand Down Expand Up @@ -3158,7 +3210,7 @@ fn check_warns_of_a_wildcard_spelled_allowed_client_and_denies_it_on_request() {
#[test]
fn check_accepts_a_runtime_that_lists_the_clients_a_profile_names() {
let scratch = TestProject::from_registry_source(b"");
minimal_project_limited_to_portal(&scratch);
minimal_project_limited_to_portal(&scratch, true);
let runtime = edited_runtime_config(&scratch, |source| {
source.replace(
"allowedClients: [directory-client]",
Expand Down
89 changes: 89 additions & 0 deletions crates/registry-cli-docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -19065,6 +19065,95 @@
"constraints": [],
"subcommands": []
},
{
"name": "generate",
"invocation": "messagingctl generate",
"about": "Generate bounded contracts for another tool from an editable project",
"long_about": null,
"usage": "messagingctl generate [OPTIONS] <COMMAND>",
"arguments": [],
"options": [
{
"display": "--format <FORMAT>",
"description": "Emit the selected command's report in this format",
"always_required": false,
"repeatable": false,
"default_values": [
"human"
],
"possible_values": [
"human",
"json"
],
"environment": null
},
{
"display": "-h, --help",
"description": "Print help",
"always_required": false,
"repeatable": false,
"default_values": [],
"possible_values": [],
"environment": null
}
],
"constraints": [],
"subcommands": [
{
"name": "templates",
"invocation": "messagingctl generate templates",
"about": "Write every checked template version's locale and data contract",
"long_about": null,
"usage": "messagingctl generate templates [OPTIONS] --project <DIRECTORY> --output <FILE>",
"arguments": [],
"options": [
{
"display": "--project <DIRECTORY>",
"description": "Editable Messaging project holding messaging.yaml",
"always_required": true,
"repeatable": false,
"default_values": [],
"possible_values": [],
"environment": null
},
{
"display": "--output <FILE>",
"description": "New JSON file to create. Existing files are never overwritten",
"always_required": true,
"repeatable": false,
"default_values": [],
"possible_values": [],
"environment": null
},
{
"display": "--format <FORMAT>",
"description": "Emit the selected command's report in this format",
"always_required": false,
"repeatable": false,
"default_values": [
"human"
],
"possible_values": [
"human",
"json"
],
"environment": null
},
{
"display": "-h, --help",
"description": "Print help",
"always_required": false,
"repeatable": false,
"default_values": [],
"possible_values": [],
"environment": null
}
],
"constraints": [],
"subcommands": []
}
]
},
{
"name": "check",
"invocation": "messagingctl check",
Expand Down
5 changes: 5 additions & 0 deletions crates/registry-coordinator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ name = "recovery_audit"
path = "tests/recovery_audit.rs"
required-features = ["postgres-test"]

[[test]]
name = "read_lease_recovery"
path = "tests/read_lease_recovery.rs"
required-features = ["postgres-test"]

[[test]]
name = "process_restart"
path = "tests/process_restart.rs"
Expand Down
Loading
Loading