use runx_contracts::{JsonObject, JsonValue, json_string_field as string_field};
use serde::{Deserialize, Serialize};
use super::rfc3339::parse_rfc3339_moment;
use super::{AuthorityKind, LocalAdmissionGrant, scope::scope_allows};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CredentialGrantRequirementError {
message: String,
}
impl CredentialGrantRequirementError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub(crate) fn message(&self) -> &str {
&self.message
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct CredentialGrantRequirement {
pub provider: String,
pub scopes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope_family: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authority_kind: Option<AuthorityKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_locator: Option<String>,
}
pub(crate) fn credential_grant_requirement(
auth: Option<&JsonValue>,
) -> Result<Option<CredentialGrantRequirement>, CredentialGrantRequirementError> {
match auth {
None | Some(JsonValue::Null) | Some(JsonValue::Bool(false)) => Ok(None),
Some(JsonValue::Object(object)) => requirement_from_object(object),
Some(_) => Err(CredentialGrantRequirementError::new(
"connected auth must be an object, null, or false",
)),
}
}
pub(crate) fn find_matching_grant<'a>(
requirement: &CredentialGrantRequirement,
grants: &'a [LocalAdmissionGrant],
connected_auth_checked_at: Option<&str>,
wildcard_scopes_trusted: bool,
) -> Option<&'a LocalAdmissionGrant> {
grants.iter().find(|grant| {
grant.provider == requirement.provider
&& grant.status == Some(super::LocalAdmissionGrantStatus::Active)
&& grant_lifetime_allows(grant, connected_auth_checked_at)
&& requirement.scopes.iter().all(|scope| {
grant
.scopes
.iter()
.any(|granted_scope| {
scope_allows(granted_scope, scope, wildcard_scopes_trusted)
})
})
&& grant_reference_matches(requirement, grant)
})
}
fn grant_lifetime_allows(grant: &LocalAdmissionGrant, checked_at: Option<&str>) -> bool {
let Some(expires_at) = grant.expires_at.as_deref() else {
return false;
};
let Some(checked_at) = checked_at.and_then(parse_rfc3339_moment) else {
return false;
};
let Some(expires_at) = parse_rfc3339_moment(expires_at) else {
return false;
};
if checked_at >= expires_at {
return false;
}
match grant.not_before.as_deref().map(parse_rfc3339_moment) {
Some(Some(not_before)) => checked_at >= not_before,
Some(None) => false,
None => true,
}
}
pub(crate) fn grant_reference_matches(
requirement: &CredentialGrantRequirement,
grant: &LocalAdmissionGrant,
) -> bool {
if !has_requirement_reference(requirement) {
return !has_grant_reference(grant);
}
grant.scope_family == requirement.scope_family
&& grant.authority_kind == requirement.authority_kind
&& grant.target_repo == requirement.target_repo
&& grant.target_locator == requirement.target_locator
}
pub(crate) fn has_grant_reference(grant: &LocalAdmissionGrant) -> bool {
truthy_string(&grant.scope_family)
|| grant.authority_kind.is_some()
|| truthy_string(&grant.target_repo)
|| truthy_string(&grant.target_locator)
}
fn has_requirement_reference(requirement: &CredentialGrantRequirement) -> bool {
truthy_string(&requirement.scope_family)
|| requirement.authority_kind.is_some()
|| truthy_string(&requirement.target_repo)
|| truthy_string(&requirement.target_locator)
}
fn requirement_from_object(
object: &JsonObject,
) -> Result<Option<CredentialGrantRequirement>, CredentialGrantRequirementError> {
let auth_type = string_field(object, "type");
if matches!(auth_type, Some("env" | "none" | "local")) {
return Ok(None);
}
let provider = string_field(object, "provider")
.or(auth_type)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| CredentialGrantRequirementError::new("connected auth provider is required"))?
.to_owned();
let scopes = string_array_field(object, "scopes")?;
if scopes.is_empty() {
return Err(CredentialGrantRequirementError::new(
"connected auth scopes must include at least one non-empty string",
));
}
Ok(Some(CredentialGrantRequirement {
provider,
scopes,
scope_family: owned_string_field(object, "scope_family"),
authority_kind: authority_kind_field(object, "authority_kind"),
target_repo: owned_string_field(object, "target_repo"),
target_locator: owned_string_field(object, "target_locator"),
}))
}
fn owned_string_field(object: &JsonObject, field: &str) -> Option<String> {
string_field(object, field).map(ToOwned::to_owned)
}
fn string_array_field(
object: &JsonObject,
field: &str,
) -> Result<Vec<String>, CredentialGrantRequirementError> {
match object.get(field) {
Some(JsonValue::Array(values)) => values
.iter()
.map(|value| match value {
JsonValue::String(scope) if !scope.trim().is_empty() => Ok(scope.clone()),
JsonValue::String(_) => Err(CredentialGrantRequirementError::new(format!(
"connected auth {field} cannot include empty strings"
))),
_ => Err(CredentialGrantRequirementError::new(format!(
"connected auth {field} must contain only strings"
))),
})
.collect(),
Some(_) => Err(CredentialGrantRequirementError::new(format!(
"connected auth {field} must be an array"
))),
None => Ok(Vec::new()),
}
}
fn authority_kind_field(object: &JsonObject, field: &str) -> Option<AuthorityKind> {
match string_field(object, field) {
Some("read_only") => Some(AuthorityKind::ReadOnly),
Some("constructive") => Some(AuthorityKind::Constructive),
Some("destructive") => Some(AuthorityKind::Destructive),
_ => None,
}
}
fn truthy_string(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use runx_contracts::{JsonObject, JsonValue};
use super::{CredentialGrantRequirement, credential_grant_requirement, find_matching_grant};
use crate::policy::{LocalAdmissionGrant, LocalAdmissionGrantStatus};
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn first_matching_grant_wins(first_id in grant_id(), second_id in grant_id()) {
prop_assume!(first_id != second_id);
let requirement = CredentialGrantRequirement {
provider: "github".to_owned(),
scopes: vec!["repo:read".to_owned()],
scope_family: None,
authority_kind: None,
target_repo: None,
target_locator: None,
};
let first = matching_grant(first_id.clone(), "repo:*");
let second = matching_grant(second_id, "*");
let grants = vec![first, second];
let matched = find_matching_grant(
&requirement,
&grants,
Some("2026-05-22T00:00:00Z"),
false,
);
prop_assert_eq!(
matched.map(|grant| grant.grant_id.as_str()),
Some(first_id.as_str()),
);
}
}
#[test]
fn missing_status_denies_even_when_lifetime_is_valid() {
let requirement = github_repo_read_requirement();
let mut grant = matching_grant("grant_a".to_owned(), "repo:*");
grant.status = None;
let grants = vec![grant];
let matched =
find_matching_grant(&requirement, &grants, Some("2026-05-22T00:00:00Z"), false);
assert!(matched.is_none());
}
#[test]
fn active_grant_without_expiry_denies() {
let requirement = github_repo_read_requirement();
let mut grant = matching_grant("grant_a".to_owned(), "repo:*");
grant.expires_at = None;
let grants = vec![grant];
let matched =
find_matching_grant(&requirement, &grants, Some("2026-05-22T00:00:00Z"), false);
assert!(matched.is_none());
}
#[test]
fn active_grant_without_checked_at_denies() {
let requirement = github_repo_read_requirement();
let grants = vec![matching_grant("grant_a".to_owned(), "repo:*")];
let matched = find_matching_grant(&requirement, &grants, None, false);
assert!(matched.is_none());
}
#[test]
fn expired_grant_denies() {
let requirement = github_repo_read_requirement();
let grants = vec![matching_grant("grant_a".to_owned(), "repo:*")];
let matched =
find_matching_grant(&requirement, &grants, Some("2026-05-23T00:00:00Z"), false);
assert!(matched.is_none());
}
#[test]
fn malformed_lifetime_denies() {
let requirement = github_repo_read_requirement();
let mut grant = matching_grant("grant_a".to_owned(), "repo:*");
grant.expires_at = Some("2026-5-23T00:00:00Z".to_owned());
let grants = vec![grant];
let matched =
find_matching_grant(&requirement, &grants, Some("2026-05-22T00:00:00Z"), false);
assert!(matched.is_none());
}
#[test]
fn not_before_future_grant_denies() {
let requirement = github_repo_read_requirement();
let mut grant = matching_grant("grant_a".to_owned(), "repo:*");
grant.not_before = Some("2026-05-23T00:00:00Z".to_owned());
let grants = vec![grant];
let matched =
find_matching_grant(&requirement, &grants, Some("2026-05-22T00:00:00Z"), false);
assert!(matched.is_none());
}
#[test]
fn non_object_connected_auth_is_rejected() {
let auth = JsonValue::String("github".to_owned());
let error = credential_grant_requirement(Some(&auth)).unwrap_err();
assert_eq!(
error.message(),
"connected auth must be an object, null, or false"
);
}
#[test]
fn connected_auth_requires_provider() {
let auth = JsonValue::Object(JsonObject::from_iter([(
"scopes".to_owned(),
JsonValue::Array(vec![JsonValue::String("repo:read".to_owned())]),
)]));
let error = credential_grant_requirement(Some(&auth)).unwrap_err();
assert_eq!(error.message(), "connected auth provider is required");
}
#[test]
fn connected_auth_requires_non_empty_scopes() {
let auth = JsonValue::Object(JsonObject::from_iter([(
"provider".to_owned(),
JsonValue::String("github".to_owned()),
)]));
let error = credential_grant_requirement(Some(&auth)).unwrap_err();
assert_eq!(
error.message(),
"connected auth scopes must include at least one non-empty string"
);
}
#[test]
fn connected_auth_scope_values_must_be_non_empty_strings() {
let auth = JsonValue::Object(JsonObject::from_iter([
(
"provider".to_owned(),
JsonValue::String("github".to_owned()),
),
(
"scopes".to_owned(),
JsonValue::Array(vec![JsonValue::String(String::new())]),
),
]));
let error = credential_grant_requirement(Some(&auth)).unwrap_err();
assert_eq!(
error.message(),
"connected auth scopes cannot include empty strings"
);
}
fn github_repo_read_requirement() -> CredentialGrantRequirement {
CredentialGrantRequirement {
provider: "github".to_owned(),
scopes: vec!["repo:read".to_owned()],
scope_family: None,
authority_kind: None,
target_repo: None,
target_locator: None,
}
}
fn matching_grant(grant_id: String, scope: &str) -> LocalAdmissionGrant {
LocalAdmissionGrant {
grant_id,
provider: "github".to_owned(),
scopes: vec![scope.to_owned()],
status: Some(LocalAdmissionGrantStatus::Active),
not_before: Some("2026-05-21T00:00:00Z".to_owned()),
expires_at: Some("2026-05-23T00:00:00Z".to_owned()),
scope_family: None,
authority_kind: None,
target_repo: None,
target_locator: None,
}
}
fn grant_id() -> impl Strategy<Value = String> {
prop::sample::select(&["grant_a", "grant_b", "grant_c", "grant_d"]).prop_map(str::to_owned)
}
}