use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use async_trait::async_trait;
use engenho_apiserver::authz::{
Attributes, Authorizer as _, GROUP_MASTERS, RbacAuthorizer, RbacStoreEnv,
};
use engenho_apiserver::sa_token;
use engenho_controllers::CrdController;
use engenho_types::auth::{GROUP_AUTHENTICATED, UserInfo};
use engenho_types::generated_v1_34::catalog::{RESOURCE_CATALOG, Subresource};
use engenho_types::generated_v1_34::rbac_v1::{
ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef,
};
use serde_json::Value;
use super::{Finding, Match, Subject};
pub(super) struct Listed {
pub(super) cluster_role_bindings: Vec<Value>,
pub(super) role_bindings: Vec<Value>,
pub(super) cluster_roles: Vec<Value>,
pub(super) roles: Vec<Value>,
pub(super) crds: Vec<Value>,
}
struct Typed {
cluster_role_bindings: Vec<ClusterRoleBinding>,
role_bindings: Vec<RoleBinding>,
cluster_roles: BTreeMap<String, ClusterRole>,
roles: BTreeMap<(String, String), Role>,
}
macro_rules! decoded {
($values:expr, $ty:ty) => {
$values
.into_iter()
.filter_map(|v| serde_json::from_value::<$ty>(v).ok())
};
}
impl Typed {
fn of(listed: Listed) -> Self {
Self {
cluster_role_bindings: decoded!(listed.cluster_role_bindings, ClusterRoleBinding)
.collect(),
role_bindings: decoded!(listed.role_bindings, RoleBinding).collect(),
cluster_roles: decoded!(listed.cluster_roles, ClusterRole)
.map(|cr| (cr.metadata.name.clone(), cr))
.collect(),
roles: decoded!(listed.roles, Role)
.map(|r| {
let ns = r.metadata.namespace.clone().unwrap_or_default();
((ns, r.metadata.name.clone()), r)
})
.collect(),
}
}
fn rules(&self, role_ref: &RoleRef, binding_ns: Option<&str>) -> &[PolicyRule] {
match (role_ref.kind.as_str(), binding_ns) {
("ClusterRole", _) => self
.cluster_roles
.get(&role_ref.name)
.map_or(&[], |cr| cr.rules.as_slice()),
("Role", Some(ns)) => self
.roles
.get(&(ns.to_owned(), role_ref.name.clone()))
.map_or(&[], |r| r.rules.as_slice()),
_ => &[],
}
}
fn every_rule(&self) -> impl Iterator<Item = &PolicyRule> {
self.cluster_roles
.values()
.flat_map(|cr| &cr.rules)
.chain(self.roles.values().flat_map(|r| &r.rules))
}
}
struct ListedEnv<'a>(&'a Typed);
#[async_trait]
impl RbacStoreEnv for ListedEnv<'_> {
async fn list_cluster_role_bindings(&self) -> Vec<ClusterRoleBinding> {
self.0.cluster_role_bindings.clone()
}
async fn list_role_bindings(&self, ns: &str) -> Vec<RoleBinding> {
self.0
.role_bindings
.iter()
.filter(|rb| rb.metadata.namespace.as_deref() == Some(ns))
.cloned()
.collect()
}
async fn get_cluster_role(&self, name: &str) -> Option<ClusterRole> {
self.0.cluster_roles.get(name).cloned()
}
async fn get_role(&self, ns: &str, name: &str) -> Option<Role> {
self.0.roles.get(&(ns.to_owned(), name.to_owned())).cloned()
}
}
const fn segment(sub: Subresource) -> &'static str {
match sub {
Subresource::Status => "status",
Subresource::Scale => "scale",
Subresource::Log => "log",
Subresource::Token => "token",
Subresource::Exec => "exec",
Subresource::Attach => "attach",
Subresource::PortForward => "portforward",
Subresource::Eviction => "eviction",
}
}
struct Served(BTreeMap<String, BTreeSet<(String, &'static str)>>);
impl Served {
fn of(crds: &[Value]) -> Self {
let mut served: BTreeMap<String, BTreeSet<(String, &'static str)>> = BTreeMap::new();
for d in RESOURCE_CATALOG {
for sub in d.subresources {
served
.entry(d.plural.to_owned())
.or_default()
.insert((d.group.to_owned(), segment(*sub)));
}
}
for entry in crds.iter().flat_map(CrdController::extract_entries) {
if entry.serves_status {
served
.entry(entry.plural.clone())
.or_default()
.insert((entry.group.clone(), segment(Subresource::Status)));
}
}
Self(served)
}
fn of_parent<'a>(
&'a self,
parent: &str,
api_groups: &'a [String],
) -> impl Iterator<Item = (&'a str, &'static str)> {
let admits = |group: &str| api_groups.iter().any(|g| g == "*" || g == group);
self.0
.get(parent)
.into_iter()
.flatten()
.filter(move |(group, _)| admits(group))
.map(|(group, subresource)| (group.as_str(), *subresource))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Dim {
Named(String),
Other,
}
impl fmt::Display for Dim {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Named(v) => f.write_str(v),
Self::Other => f.write_str("(any other)"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SubjectRef {
pub kind: String,
pub name: String,
pub namespace: Option<String>,
}
impl fmt::Display for SubjectRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ", self.kind)?;
if let Some(ns) = &self.namespace {
write!(f, "{ns}/")?;
}
f.write_str(&self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Via {
pub kind: String,
pub name: String,
}
impl fmt::Display for Via {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.kind, self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LostGrant {
pub verb: Dim,
pub group: String,
pub parent: String,
pub subresource: &'static str,
pub name: Dim,
pub namespace: Option<String>,
pub via: Via,
}
impl LostGrant {
#[must_use]
pub fn resource(&self) -> GrantedResource<'_> {
GrantedResource(self)
}
}
pub struct GrantedResource<'a>(&'a LostGrant);
impl fmt::Display for GrantedResource<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.0.parent, self.0.subresource)
}
}
impl fmt::Display for LostGrant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} (group {:?}, name {}) ",
self.verb,
self.resource(),
self.group,
self.name
)?;
match &self.namespace {
Some(ns) => write!(f, "in namespace {ns}")?,
None => f.write_str("cluster-wide")?,
}
write!(
f,
": granted only by bare {:?} in {}",
self.parent, self.via
)
}
}
struct Vocabulary {
verbs: BTreeSet<String>,
names: BTreeSet<String>,
}
impl Vocabulary {
fn of<'a>(rules: impl Iterator<Item = &'a PolicyRule>) -> Self {
let mut vocabulary = Self {
verbs: BTreeSet::new(),
names: BTreeSet::new(),
};
for rule in rules {
vocabulary
.verbs
.extend(rule.verbs.iter().filter(|v| *v != "*").cloned());
vocabulary.names.extend(rule.resource_names.iter().cloned());
}
vocabulary
}
}
fn every(vocabulary: &BTreeSet<String>) -> Vec<Dim> {
vocabulary
.iter()
.cloned()
.map(Dim::Named)
.chain(std::iter::once(Dim::Other))
.collect()
}
fn absent_from(named: &BTreeSet<String>) -> String {
let mut sentinel = String::from("~census-other");
while named.contains(&sentinel) {
sentinel.push('~');
}
sentinel
}
struct Sentinels {
verb: String,
name: String,
}
impl Sentinels {
fn value<'a>(dim: &'a Dim, sentinel: &'a str) -> &'a str {
match dim {
Dim::Named(v) => v,
Dim::Other => sentinel,
}
}
}
pub(super) const GROUP_SERVICEACCOUNTS: &str = "system:serviceaccounts";
fn identity(subject: &SubjectRef) -> Option<UserInfo> {
match subject.kind.as_str() {
"ServiceAccount" => {
let ns = subject.namespace.as_deref().unwrap_or_default();
Some(UserInfo {
username: sa_token::subject_for(ns, &subject.name),
groups: sa_token::groups_for(ns),
..UserInfo::default()
})
}
"User" => {
let anonymous = UserInfo::anonymous();
Some(if subject.name == anonymous.username {
anonymous
} else {
UserInfo::from_client_cert(&subject.name, &[])
})
}
"Group" => Some(member_of(&subject.name)),
_ => None,
}
}
pub(super) fn member_of(group: &str) -> UserInfo {
let anonymous = UserInfo::anonymous();
if anonymous.groups.iter().any(|g| g == group) {
return anonymous;
}
let sa_namespace = group
.strip_prefix(GROUP_SERVICEACCOUNTS)
.and_then(|rest| rest.strip_prefix(':'));
let groups = match (group, sa_namespace) {
(_, Some(ns)) => sa_token::groups_for(ns),
(GROUP_SERVICEACCOUNTS, None) => vec![
GROUP_AUTHENTICATED.to_owned(),
GROUP_SERVICEACCOUNTS.to_owned(),
],
(GROUP_AUTHENTICATED, None) => vec![GROUP_AUTHENTICATED.to_owned()],
(other, None) => return UserInfo::from_client_cert("", &[other.to_owned()]),
};
UserInfo {
groups,
..UserInfo::default()
}
}
fn subject_ref(
s: &engenho_types::generated_v1_34::rbac_v1::Subject,
binding_ns: Option<&str>,
) -> SubjectRef {
let namespace = (s.kind == "ServiceAccount")
.then(|| s.namespace.as_deref().or(binding_ns).map(str::to_owned))
.flatten();
SubjectRef {
kind: s.kind.clone(),
name: s.name.clone(),
namespace,
}
}
struct Binding<'a> {
namespace: Option<&'a str>,
role_ref: &'a RoleRef,
subjects: Vec<SubjectRef>,
}
pub(super) async fn bare_parent_only(listed: Listed) -> Vec<Match> {
let served = Served::of(&listed.crds);
let typed = Typed::of(listed);
let vocabulary = Vocabulary::of(typed.every_rule());
let sentinels = Sentinels {
verb: absent_from(&vocabulary.verbs),
name: absent_from(&vocabulary.names),
};
let authorizer = RbacAuthorizer::new(ListedEnv(&typed));
let bindings = typed
.cluster_role_bindings
.iter()
.map(|crb| Binding {
namespace: None,
role_ref: &crb.role_ref,
subjects: crb.subjects.iter().map(|s| subject_ref(s, None)).collect(),
})
.chain(typed.role_bindings.iter().map(|rb| {
let ns = rb.metadata.namespace.as_deref();
Binding {
namespace: ns,
role_ref: &rb.role_ref,
subjects: rb.subjects.iter().map(|s| subject_ref(s, ns)).collect(),
}
}));
let mut lost: BTreeSet<(SubjectRef, LostGrant)> = BTreeSet::new();
for binding in bindings {
let rules = typed.rules(binding.role_ref, binding.namespace);
let grants: Vec<LostGrant> = rules
.iter()
.flat_map(|rule| probes(rule, &served, &vocabulary, &binding))
.collect();
for subject in &binding.subjects {
if subject.kind == "Group" && subject.name == GROUP_MASTERS {
continue;
}
let Some(user) = identity(subject) else {
continue;
};
for grant in &grants {
let attrs = Attributes {
user: user.clone(),
verb: Sentinels::value(&grant.verb, &sentinels.verb).to_owned(),
group: grant.group.clone(),
version: String::new(),
resource: grant.parent.clone(),
subresource: Some(grant.subresource.to_owned()),
namespace: grant.namespace.clone(),
name: Some(Sentinels::value(&grant.name, &sentinels.name).to_owned()),
non_resource_url: None,
};
if !authorizer.authorize(&attrs).await.is_allow() {
lost.insert((subject.clone(), grant.clone()));
}
}
}
}
lost.into_iter()
.map(|(subject, grant)| Match {
subject: Subject::Rbac(subject),
finding: Finding::SubresourceLost(grant),
})
.collect()
}
fn probes(
rule: &PolicyRule,
served: &Served,
vocabulary: &Vocabulary,
binding: &Binding<'_>,
) -> Vec<LostGrant> {
if !rule.non_resource_urls.is_empty() {
return Vec::new();
}
let verbs = if rule.verbs.iter().any(|v| v == "*") {
every(&vocabulary.verbs)
} else {
rule.verbs.iter().cloned().map(Dim::Named).collect()
};
let names = if rule.resource_names.is_empty() {
every(&vocabulary.names)
} else {
rule.resource_names
.iter()
.cloned()
.map(Dim::Named)
.collect()
};
let via = Via {
kind: binding.role_ref.kind.clone(),
name: binding.role_ref.name.clone(),
};
let mut out = Vec::new();
for parent in rule
.resources
.iter()
.filter(|r| *r != "*" && !r.contains('/'))
{
for (group, subresource) in served.of_parent(parent, &rule.api_groups) {
let create_only =
engenho_apiserver::PodStreamKind::is_streaming_subresource(subresource)
|| subresource == "eviction";
for verb in &verbs {
if create_only && matches!(verb, Dim::Named(v) if v != "create") {
continue;
}
for name in &names {
out.push(LostGrant {
verb: verb.clone(),
group: group.to_owned(),
parent: parent.clone(),
subresource,
name: name.clone(),
namespace: binding.namespace.map(str::to_owned),
via: via.clone(),
});
}
}
}
}
out
}