use serde::{Deserialize, Serialize};
use crate::core::OidcBinding;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcSubjectPolicy {
issuer: String,
repository: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_ref: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum OidcPolicyError {
#[error("invalid OIDC policy: {0}")]
InvalidPolicy(String),
#[error("attestation carries no OIDC binding — signer presented no verified OIDC identity")]
MissingBinding,
#[error("OIDC binding lacks the '{0}' claim the policy requires")]
MissingClaim(&'static str),
#[error("OIDC {claim} mismatch: policy trusts '{expected}', binding presented '{got}'")]
Mismatch {
claim: &'static str,
expected: String,
got: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct OidcPolicyJoin {
pub issuer: String,
pub repository: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflow_ref: Option<String>,
pub subject: String,
}
impl OidcSubjectPolicy {
pub fn parse(json: &str) -> Result<Self, OidcPolicyError> {
let policy: Self = serde_json::from_str(json)
.map_err(|e| OidcPolicyError::InvalidPolicy(e.to_string()))?;
if policy.issuer.trim().is_empty() {
return Err(OidcPolicyError::InvalidPolicy("issuer is empty".into()));
}
if policy.repository.trim().is_empty() {
return Err(OidcPolicyError::InvalidPolicy("repository is empty".into()));
}
if let Some(wf) = &policy.workflow_ref
&& wf.trim().is_empty()
{
return Err(OidcPolicyError::InvalidPolicy(
"workflow_ref is empty".into(),
));
}
Ok(policy)
}
pub fn issuer(&self) -> &str {
&self.issuer
}
pub fn repository(&self) -> &str {
&self.repository
}
pub fn join(&self, binding: &OidcBinding) -> Result<OidcPolicyJoin, OidcPolicyError> {
if binding.issuer != self.issuer {
return Err(OidcPolicyError::Mismatch {
claim: "issuer",
expected: self.issuer.clone(),
got: binding.issuer.clone(),
});
}
let claims = binding
.normalized_claims
.as_ref()
.ok_or(OidcPolicyError::MissingClaim("repository"))?;
let repository = claims
.get("repository")
.and_then(|v| v.as_str())
.ok_or(OidcPolicyError::MissingClaim("repository"))?;
if repository != self.repository {
return Err(OidcPolicyError::Mismatch {
claim: "repository",
expected: self.repository.clone(),
got: repository.to_string(),
});
}
let mut joined_workflow_ref = None;
if let Some(pinned) = &self.workflow_ref {
let presented = claims
.get("workflow_ref")
.and_then(|v| v.as_str())
.ok_or(OidcPolicyError::MissingClaim("workflow_ref"))?;
let presented_path = presented.split('@').next().unwrap_or(presented);
let matches = if pinned.contains('@') {
presented == pinned
} else {
presented_path == pinned
};
if !matches {
return Err(OidcPolicyError::Mismatch {
claim: "workflow_ref",
expected: pinned.clone(),
got: presented.to_string(),
});
}
joined_workflow_ref = Some(presented.to_string());
}
Ok(OidcPolicyJoin {
issuer: self.issuer.clone(),
repository: self.repository.clone(),
workflow_ref: joined_workflow_ref,
subject: binding.subject.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const GH_ISSUER: &str = "https://token.actions.githubusercontent.com";
fn binding(claims: serde_json::Value) -> OidcBinding {
let map = match claims {
serde_json::Value::Object(m) => m,
_ => panic!("claims must be an object"),
};
OidcBinding {
issuer: GH_ISSUER.to_string(),
subject: "repo:acme/widget:ref:refs/tags/v1.0".to_string(),
audience: "https://github.com/acme".to_string(),
token_exp: 4_102_444_800,
platform: Some("github".to_string()),
jti: Some("jti-1".to_string()),
normalized_claims: Some(map),
}
}
fn policy(workflow_ref: Option<&str>) -> OidcSubjectPolicy {
let mut v = serde_json::json!({
"issuer": GH_ISSUER,
"repository": "acme/widget",
});
if let Some(wf) = workflow_ref {
v["workflow_ref"] = serde_json::Value::String(wf.to_string());
}
OidcSubjectPolicy::parse(&v.to_string()).expect("valid policy")
}
#[test]
fn parse_rejects_empty_issuer() {
let err = OidcSubjectPolicy::parse(r#"{"issuer":" ","repository":"acme/widget"}"#)
.expect_err("empty issuer must not parse");
assert!(matches!(err, OidcPolicyError::InvalidPolicy(_)));
}
#[test]
fn parse_rejects_unknown_fields() {
let err = OidcSubjectPolicy::parse(r#"{"issuer":"i","repository":"r","extra":"smuggled"}"#)
.expect_err("unknown fields must not parse");
assert!(matches!(err, OidcPolicyError::InvalidPolicy(_)));
}
#[test]
fn join_passes_on_issuer_and_repository() {
let b = binding(serde_json::json!({ "repository": "acme/widget" }));
let join = policy(None).join(&b).expect("join must pass");
assert_eq!(join.repository, "acme/widget");
assert_eq!(join.subject, "repo:acme/widget:ref:refs/tags/v1.0");
}
#[test]
fn join_fails_closed_on_issuer_mismatch() {
let mut b = binding(serde_json::json!({ "repository": "acme/widget" }));
b.issuer = "https://evil.example".to_string();
let err = policy(None).join(&b).expect_err("must reject");
assert!(matches!(
err,
OidcPolicyError::Mismatch {
claim: "issuer",
..
}
));
}
#[test]
fn join_fails_closed_on_repository_mismatch() {
let b = binding(serde_json::json!({ "repository": "attacker/fork" }));
let err = policy(None).join(&b).expect_err("must reject");
assert!(matches!(
err,
OidcPolicyError::Mismatch {
claim: "repository",
..
}
));
}
#[test]
fn join_fails_closed_on_missing_claims() {
let mut b = binding(serde_json::json!({ "repository": "acme/widget" }));
b.normalized_claims = None;
let err = policy(None).join(&b).expect_err("must reject");
assert_eq!(err, OidcPolicyError::MissingClaim("repository"));
}
#[test]
fn workflow_path_pin_matches_any_ref() {
let b = binding(serde_json::json!({
"repository": "acme/widget",
"workflow_ref": "acme/widget/.github/workflows/release.yml@refs/tags/v1.0",
}));
let join = policy(Some("acme/widget/.github/workflows/release.yml"))
.join(&b)
.expect("path pin matches any ref");
assert_eq!(
join.workflow_ref.as_deref(),
Some("acme/widget/.github/workflows/release.yml@refs/tags/v1.0")
);
}
#[test]
fn workflow_exact_pin_requires_exact_ref() {
let b = binding(serde_json::json!({
"repository": "acme/widget",
"workflow_ref": "acme/widget/.github/workflows/release.yml@refs/heads/main",
}));
let err = policy(Some(
"acme/widget/.github/workflows/release.yml@refs/tags/v1.0",
))
.join(&b)
.expect_err("exact pin must reject other refs");
assert!(matches!(
err,
OidcPolicyError::Mismatch {
claim: "workflow_ref",
..
}
));
}
#[test]
fn workflow_pin_fails_closed_when_binding_lacks_workflow_ref() {
let b = binding(serde_json::json!({ "repository": "acme/widget" }));
let err = policy(Some("acme/widget/.github/workflows/release.yml"))
.join(&b)
.expect_err("must reject");
assert_eq!(err, OidcPolicyError::MissingClaim("workflow_ref"));
}
}