Skip to content
Draft
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
163 changes: 163 additions & 0 deletions crates/openshell-core/src/policy_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,166 @@ fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec<u8> {
pub fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
format!("{:x}", Sha256::digest(canonical_policy_bytes(policy)))
}

/// Derive token-grant authorities from a provider record and its complete profile rule.
///
/// The record identity separates providers whose names sanitize to the same policy
/// key. Hashing the rule content and endpoint position prevents a refreshed profile
/// from using grants through an older policy generation. Generated names and
/// provenance are excluded so both gateway construction paths derive the same value.
pub fn provider_token_grant_owners(provider_id: &str, rule: &NetworkPolicyRule) -> Vec<String> {
let mut authority = rule.clone();
authority.name.clear();
clear_rule_token_grant_provenance(&mut authority);
let rule_bytes = canonical_rule_bytes(&authority);
authority
.endpoints
.iter()
.enumerate()
.map(|(index, _)| {
token_grant_owner(
b"openshell:provider-token-grant-owner:v1",
provider_id.as_bytes(),
&rule_bytes,
index,
)
})
.collect()
}

/// Stamp endpoint authorities for a complete gateway-global policy replacement.
///
/// Global policy suppresses provider profile authorization rules. Its own endpoint
/// authorities must therefore be used for grants, while the original credential
/// host, port, and path selectors continue to limit where a token may be sent.
/// The complete policy hash invalidates every stamp when the global policy changes.
pub fn stamp_global_token_grant_owners(policy: &mut ProtoSandboxPolicy) {
let mut authority = policy.clone();
for rule in authority.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner.clear();
endpoint.provider_credentialed = false;
}
}
let policy_hash = deterministic_policy_hash(&authority);
for (name, rule) in &mut policy.network_policies {
for (index, endpoint) in rule.endpoints.iter_mut().enumerate() {
endpoint.token_grant_owner = token_grant_owner(
b"openshell:global-token-grant-owner:v1",
policy_hash.as_bytes(),
name.as_bytes(),
index,
);
}
}
}

fn clear_rule_token_grant_provenance(rule: &mut NetworkPolicyRule) {
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner.clear();
endpoint.provider_credentialed = false;
endpoint.advisor_proposed = false;
}
}

fn token_grant_owner(domain: &[u8], identity: &[u8], policy: &[u8], index: usize) -> String {
let mut input = Vec::new();
append_canonical_bytes(&mut input, domain);
append_canonical_bytes(&mut input, identity);
append_canonical_bytes(&mut input, policy);
input.extend_from_slice(&canonical_size(index));
format!("grant-owner:v1:{:x}", Sha256::digest(input))
}

#[cfg(test)]
mod token_grant_owner_tests {
use super::*;
use crate::proto::{NetworkBinary, NetworkEndpoint};

fn profile_rule() -> NetworkPolicyRule {
NetworkPolicyRule {
endpoints: vec![
NetworkEndpoint {
host: "api.example.test".into(),
path: "/public/**".into(),
..Default::default()
},
NetworkEndpoint {
host: "api.example.test".into(),
path: "/private/**".into(),
..Default::default()
},
],
binaries: vec![NetworkBinary {
path: "/usr/bin/client".into(),
..Default::default()
}],
..Default::default()
}
}

#[test]
fn provider_owner_changes_with_identity_profile_or_endpoint_position() {
let rule = profile_rule();
let original = provider_token_grant_owners("provider-a", &rule);
assert_ne!(original[0], original[1]);
assert_ne!(original, provider_token_grant_owners("provider-b", &rule));

let mut changed = rule.clone();
changed.endpoints.swap(0, 1);
assert!(
!provider_token_grant_owners("provider-a", &changed)
.iter()
.any(|owner| original.contains(owner))
);
changed = rule.clone();
changed.binaries[0].path = "/usr/bin/other-client".into();
assert_ne!(
original,
provider_token_grant_owners("provider-a", &changed)
);
changed = rule;
changed.endpoints[0].access = 3;
assert_ne!(
original,
provider_token_grant_owners("provider-a", &changed)
);
}

#[test]
fn provider_owner_ignores_generated_names_and_untrusted_provenance() {
let mut rule = profile_rule();
let owners = provider_token_grant_owners("provider-a", &rule);
rule.name = "_provider_colliding_name_2".into();
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner = "forged".into();
endpoint.provider_credentialed = true;
endpoint.advisor_proposed = true;
}
assert_eq!(owners, provider_token_grant_owners("provider-a", &rule));
}

#[test]
fn global_owner_rebinds_on_policy_change_without_mutating_provenance() {
let mut policy = ProtoSandboxPolicy {
network_policies: HashMap::from([("global".into(), profile_rule())]),
..Default::default()
};
let endpoint = &mut policy.network_policies.get_mut("global").unwrap().endpoints[0];
endpoint.advisor_proposed = true;
endpoint.provider_credentialed = true;
stamp_global_token_grant_owners(&mut policy);
let first = policy.network_policies["global"].endpoints[0].clone();
assert!(first.advisor_proposed && first.provider_credentialed);
stamp_global_token_grant_owners(&mut policy);
assert_eq!(first, policy.network_policies["global"].endpoints[0]);

policy.network_policies.get_mut("global").unwrap().binaries[0].path =
"/usr/bin/replacement".into();
stamp_global_token_grant_owners(&mut policy);
assert_ne!(
first.token_grant_owner,
policy.network_policies["global"].endpoints[0].token_grant_owner
);
}
}
1 change: 1 addition & 0 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
// Provider credential provenance is derived by the
// gateway and cannot be authored in policy YAML.
provider_credentialed: false,
token_grant_owner: String::new(),
// Advisor provenance is internal runtime state, not
// a user-authored policy schema field.
advisor_proposed: false,
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,7 @@ impl ProviderTypeProfile {
refresh: credential.refresh.as_ref().map(credential_refresh_to_proto),
path_template: credential.path_template.clone(),
token_grant: credential.token_grant.as_ref().map(token_grant_to_proto),
token_grant_owners: Vec::new(),
})
.collect(),
files: self
Expand Down Expand Up @@ -1660,6 +1661,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
request_body_credential_rewrite: endpoint.request_body_credential_rewrite,
allow_uninspected_credentials: endpoint.allow_uninspected_credentials,
provider_credentialed: false,
token_grant_owner: String::new(),
advisor_proposed: false,
persisted_queries: endpoint.persisted_queries.clone(),
graphql_persisted_queries: endpoint
Expand Down
60 changes: 55 additions & 5 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

mod endpoint_status;
mod provisioning_clock;
#[cfg(test)]
mod token_grant_owners_tests;
pub use provisioning_clock::configuration_change;

pub(super) use endpoint_status::{
Expand Down Expand Up @@ -1921,7 +1923,9 @@ async fn current_effective_policy_for_sandbox(
&provider_names,
)
.await?;
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records).await
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records)
.await
.map(|(policy, _)| policy)
}

async fn current_effective_policy_from_records(
Expand All @@ -1930,7 +1934,7 @@ async fn current_effective_policy_from_records(
sandbox: &Sandbox,
sandbox_id: &str,
records: &[super::provider::ProviderEnvironmentRecord],
) -> Result<ProtoSandboxPolicy, Status> {
) -> Result<(ProtoSandboxPolicy, PolicySource), Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? {
// A global policy is the complete effective policy. Dormant sandbox
Expand All @@ -1940,7 +1944,8 @@ async fn current_effective_policy_from_records(
provider_policy_context_from_records(catalog, records),
global_policy,
PolicySource::Global,
);
)
.map(|policy| (policy, PolicySource::Global));
}

let policy = if let Some(record) = state
Expand All @@ -1964,6 +1969,7 @@ async fn current_effective_policy_from_records(
policy,
PolicySource::Sandbox,
)
.map(|policy| (policy, PolicySource::Sandbox))
}

async fn effective_policy_for_source(
Expand Down Expand Up @@ -2027,6 +2033,9 @@ fn apply_captured_policy_context(
&provider_context.endpointless_provider_names,
);
stamp_provider_credentialed_endpoints(&mut policy, &provider_context.credentialed_scopes);
if matches!(policy_source, PolicySource::Global) {
openshell_core::policy_identity::stamp_global_token_grant_owners(&mut policy);
}

Ok(policy)
}
Expand Down Expand Up @@ -2936,6 +2945,9 @@ pub(super) async fn load_sandbox_config(
effective_policy,
&provider_policy_context.credentialed_scopes,
);
if matches!(policy_source, PolicySource::Global) {
openshell_core::policy_identity::stamp_global_token_grant_owners(effective_policy);
}
if let Err(error) = validate_uninspected_credentialed_endpoints(effective_policy) {
configuration_error = bounded_configuration_diagnostic(error.message());
}
Expand Down Expand Up @@ -2976,6 +2988,9 @@ pub(super) async fn load_sandbox_config(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
policy
.as_ref()
.filter(|_| matches!(policy_source, PolicySource::Global)),
)?;

Ok(GetSandboxConfigResponse {
Expand Down Expand Up @@ -3113,13 +3128,15 @@ fn compute_provider_env_revision_from_records(
catalog,
records,
&HashMap::new(),
None,
)
}

fn compute_provider_env_revision_from_records_and_policy_bindings(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
global_policy: Option<&ProtoSandboxPolicy>,
) -> Result<u64, Status> {
let mut hasher = Sha256::new();
hasher.update(b"openshell-provider-env-revision-v4");
Expand Down Expand Up @@ -3156,6 +3173,13 @@ fn compute_provider_env_revision_from_records_and_policy_bindings(

hash_policy_credential_bindings(policy_bindings, &mut hasher);

if let Some(policy) = global_policy {
// Global policy replaces the grant owner list. Its changes must trigger
// a provider refetch even when the attached provider records are unchanged.
hasher.update(b"global-token-grant-authority");
hasher.update(deterministic_policy_hash(policy).as_bytes());
}

let digest = hasher.finalize();
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|_| Status::internal("provider env revision digest too short"),
Expand Down Expand Up @@ -3310,6 +3334,11 @@ fn provider_policy_context_from_records(

let rule_name = openshell_policy::provider_rule_name(provider.object_name());
let mut rule = profile.network_policy_rule(&rule_name);
let owners =
openshell_core::policy_identity::provider_token_grant_owners(&record.object_id, &rule);
for (endpoint, owner) in rule.endpoints.iter_mut().zip(owners) {
endpoint.token_grant_owner = owner;
}
if rule.endpoints.is_empty() {
endpointless_provider_names.insert(name.clone());
}
Expand Down Expand Up @@ -3397,6 +3426,7 @@ pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolic
for rule in policy.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.provider_credentialed = false;
endpoint.token_grant_owner.clear();
}
}
}
Expand Down Expand Up @@ -3548,7 +3578,7 @@ pub(super) async fn load_sandbox_provider_environment(
&provider_names,
)
.await?;
let effective_policy = current_effective_policy_from_records(
let (effective_policy, policy_source) = current_effective_policy_from_records(
state.as_ref(),
&provider_profile_catalog,
sandbox,
Expand All @@ -3568,6 +3598,7 @@ pub(super) async fn load_sandbox_provider_environment(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
matches!(policy_source, PolicySource::Global).then_some(&effective_policy),
)?;
let mut provider_environment =
super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials(
Expand All @@ -3580,6 +3611,25 @@ pub(super) async fn load_sandbox_provider_environment(
)
.await?;

if matches!(policy_source, PolicySource::Global) {
// A global policy replaces provider ACLs. Grants retain their profile
// destination selectors, but only the selected global endpoint may
// authorize their use. Keeping every global owner avoids reimplementing
// host/path intersection here; the relay checks both selectors.
let mut owners: Vec<_> = effective_policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.map(|endpoint| endpoint.token_grant_owner.clone())
.filter(|owner| !owner.is_empty())
.collect();
owners.sort();
owners.dedup();
for credential in provider_environment.dynamic_credentials.values_mut() {
credential.token_grant_owners.clone_from(&owners);
}
}

let mut readiness_reason = provider_environment.readiness_reason;

if supports_static_credential_bindings {
Expand Down Expand Up @@ -11072,7 +11122,7 @@ mod tests {
assert!(loaded.spec.unwrap().policy.is_none());
}

fn test_provider(name: &str, provider_type: &str) -> Provider {
pub(super) fn test_provider(name: &str, provider_type: &str) -> Provider {
Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("provider-{name}"),
Expand Down
Loading
Loading