use std::sync::Arc;
use crate::config::ScopeEnforcement;
use crate::metrics::NAMESPACE_SCOPE_DECISIONS;
use crate::validation::namespace_match;
#[derive(Clone, Debug)]
pub enum NamespaceAuthority {
Unrestricted,
Scoped {
scopes: Arc<[Arc<[String]>]>,
provider: Arc<str>,
mode: ScopeEnforcement,
},
}
impl NamespaceAuthority {
#[cfg(test)]
pub fn from_oidc_scope(provider: &str, scope: &[String], mode: ScopeEnforcement) -> Self {
Self::from_oidc_scopes(provider, [scope], mode)
}
pub fn from_oidc_scopes<'a>(
provider: &str,
scopes: impl IntoIterator<Item = &'a [String]>,
mode: ScopeEnforcement,
) -> Self {
let scopes: Vec<Arc<[String]>> = scopes
.into_iter()
.filter(|scope| !scope.iter().any(|p| p == "*"))
.map(Arc::from)
.collect();
if scopes.is_empty() {
return NamespaceAuthority::Unrestricted;
}
NamespaceAuthority::Scoped {
scopes: Arc::from(scopes),
provider: Arc::from(provider),
mode,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamespaceDenied;
pub fn enforce_namespace_scope(
authority: &NamespaceAuthority,
namespace: &str,
) -> Result<(), NamespaceDenied> {
let (scopes, provider, mode) = match authority {
NamespaceAuthority::Unrestricted => return Ok(()),
NamespaceAuthority::Scoped {
scopes,
provider,
mode,
} => (scopes, provider, *mode),
};
let provider: &str = provider;
if scopes
.iter()
.all(|scope| scope.iter().any(|p| namespace_match(p, namespace)))
{
NAMESPACE_SCOPE_DECISIONS
.with_label_values(&[provider, "allow"])
.inc();
return Ok(());
}
match mode {
ScopeEnforcement::Enforce => {
NAMESPACE_SCOPE_DECISIONS
.with_label_values(&[provider, "deny"])
.inc();
tracing::warn!(
provider = %provider,
namespace = %namespace,
scopes = ?scopes,
"OIDC namespace_scope denied write outside provider scope"
);
Err(NamespaceDenied)
}
ScopeEnforcement::Audit => {
NAMESPACE_SCOPE_DECISIONS
.with_label_values(&[provider, "would_deny"])
.inc();
tracing::warn!(
provider = %provider,
namespace = %namespace,
scopes = ?scopes,
"OIDC namespace_scope (audit) would have denied write outside provider scope"
);
Ok(())
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn scope(patterns: &[&str]) -> Vec<String> {
patterns.iter().map(|s| s.to_string()).collect()
}
#[test]
fn default_star_scope_is_unrestricted() {
let auth =
NamespaceAuthority::from_oidc_scope("ci", &scope(&["*"]), ScopeEnforcement::Enforce);
assert!(matches!(auth, NamespaceAuthority::Unrestricted));
assert!(enforce_namespace_scope(&auth, "anyorg/whatever").is_ok());
let auth = NamespaceAuthority::from_oidc_scope(
"ci",
&scope(&["myorg/**", "*"]),
ScopeEnforcement::Enforce,
);
assert!(matches!(auth, NamespaceAuthority::Unrestricted));
}
#[test]
fn unrestricted_authority_always_allows() {
assert!(enforce_namespace_scope(&NamespaceAuthority::Unrestricted, "").is_ok());
assert!(enforce_namespace_scope(&NamespaceAuthority::Unrestricted, "a/b/c").is_ok());
}
#[test]
fn scoped_enforce_allows_inside_denies_outside() {
let auth = NamespaceAuthority::from_oidc_scope(
"github",
&scope(&["myorg/**"]),
ScopeEnforcement::Enforce,
);
assert!(enforce_namespace_scope(&auth, "myorg/repo").is_ok());
assert!(enforce_namespace_scope(&auth, "myorg/team/repo").is_ok());
assert_eq!(
enforce_namespace_scope(&auth, "other/repo"),
Err(NamespaceDenied)
);
assert_eq!(
enforce_namespace_scope(&auth, "myorg-evil/repo"),
Err(NamespaceDenied)
);
}
#[test]
fn audit_mode_allows_but_never_errors() {
let auth = NamespaceAuthority::from_oidc_scope(
"github",
&scope(&["myorg/**"]),
ScopeEnforcement::Audit,
);
assert!(enforce_namespace_scope(&auth, "myorg/repo").is_ok());
assert!(enforce_namespace_scope(&auth, "other/repo").is_ok());
}
#[test]
fn rule_scope_narrows_provider_scope() {
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["*"])[..], &scope(&["ci-transport/**"])[..]],
ScopeEnforcement::Enforce,
);
assert!(enforce_namespace_scope(&auth, "ci-transport/run1").is_ok());
assert_eq!(
enforce_namespace_scope(&auth, "other/repo"),
Err(NamespaceDenied)
);
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["myorg/**"])[..], &scope(&["myorg/ci/**"])[..]],
ScopeEnforcement::Enforce,
);
assert!(enforce_namespace_scope(&auth, "myorg/ci/x").is_ok());
assert_eq!(
enforce_namespace_scope(&auth, "myorg/other"),
Err(NamespaceDenied)
);
}
#[test]
fn rule_scope_cannot_widen_past_provider_ceiling() {
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["myorg/**"])[..], &scope(&["*"])[..]],
ScopeEnforcement::Enforce,
);
assert!(enforce_namespace_scope(&auth, "myorg/repo").is_ok());
assert_eq!(
enforce_namespace_scope(&auth, "other/repo"),
Err(NamespaceDenied)
);
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["myorg/**"])[..], &scope(&["elsewhere/**"])[..]],
ScopeEnforcement::Enforce,
);
assert_eq!(
enforce_namespace_scope(&auth, "myorg/repo"),
Err(NamespaceDenied)
);
assert_eq!(
enforce_namespace_scope(&auth, "elsewhere/repo"),
Err(NamespaceDenied)
);
}
#[test]
fn all_star_scopes_collapse_to_unrestricted() {
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["*"])[..], &scope(&["*"])[..]],
ScopeEnforcement::Enforce,
);
assert!(matches!(auth, NamespaceAuthority::Unrestricted));
}
#[test]
fn empty_rule_scope_in_conjunction_is_fail_closed() {
let auth = NamespaceAuthority::from_oidc_scopes(
"ci",
[&scope(&["*"])[..], &scope(&[])[..]],
ScopeEnforcement::Enforce,
);
assert_eq!(
enforce_namespace_scope(&auth, "anything"),
Err(NamespaceDenied)
);
}
#[test]
fn empty_scope_is_fail_closed() {
let auth =
NamespaceAuthority::from_oidc_scope("ci", &scope(&[]), ScopeEnforcement::Enforce);
assert_eq!(
enforce_namespace_scope(&auth, "anything"),
Err(NamespaceDenied)
);
}
#[test]
fn empty_namespace_under_scope_is_denied() {
let auth = NamespaceAuthority::from_oidc_scope(
"ci",
&scope(&["myorg/**"]),
ScopeEnforcement::Enforce,
);
assert_eq!(enforce_namespace_scope(&auth, ""), Err(NamespaceDenied));
}
}