use kanidm_proto::{
constants::{
ATTR_ALLOW_PRIMARY_CRED_FALLBACK, ATTR_AUTH_PASSWORD_MINIMUM_LENGTH,
ATTR_AUTH_SESSION_EXPIRY, ATTR_CREDENTIAL_TYPE_MINIMUM, ATTR_GIDNUMBER,
ATTR_LIMIT_SEARCH_MAX_FILTER_TEST, ATTR_LIMIT_SEARCH_MAX_RESULTS, ATTR_PRIVILEGE_EXPIRY,
ATTR_WEBAUTHN_ATTESTATION_CA_LIST, ENTRYCLASS_ACCOUNT_POLICY,
},
v1::Entry,
};
use kaniop_k8s_util::types::get_first_cloned;
use kaniop_operator::controller::kanidm::KanidmResource;
use kaniop_operator::crd::KanidmRef;
use kaniop_operator::kanidm::crd::Kanidm;
use std::fmt;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
use kube::CustomResource;
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[kube(
category = "kaniop",
group = "kaniop.rs",
version = "v1beta1",
kind = "KanidmGroup",
plural = "kanidmgroups",
singular = "kanidmgroup",
shortname = "kg",
namespaced,
status = "KanidmGroupStatus",
doc = r#"The Kanidm group custom resource definition (CRD) defines a group in Kanidm."#,
printcolumn = r#"{"name":"Kanidm","type":"string","jsonPath":".status.kanidmRef"}"#,
printcolumn = r#"{"name":"ManagedBy","type":"string","jsonPath":".spec.entryManagedBy"}"#,
printcolumn = r#"{"name":"GID","type":"integer","jsonPath":".status.gid"}"#,
printcolumn = r#"{"name":"Ready","type":"boolean","jsonPath":".status.ready"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#,
derive = "Default"
)]
#[serde(rename_all = "camelCase")]
pub struct KanidmGroupSpec {
pub kanidm_ref: KanidmRef,
#[schemars(extend("x-kubernetes-validations" = [{"message": "kanidmName cannot be changed.", "rule": "self == oldSelf"}]))]
#[serde(skip_serializing_if = "Option::is_none")]
pub kanidm_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_managed_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mail: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub members: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub posix_attributes: Option<KanidmGroupPosixAttributes>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_policy: Option<KanidmGroupAccountPolicy>,
}
impl KanidmResource for KanidmGroup {
#[inline]
fn kanidm_ref_spec(&self) -> &KanidmRef {
&self.spec.kanidm_ref
}
#[inline]
fn get_namespace_selector(kanidm: &Kanidm) -> &Option<LabelSelector> {
&kanidm.spec.group_namespace_selector
}
#[inline]
fn kanidm_name_override(&self) -> Option<&str> {
self.spec.kanidm_name.as_deref()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct KanidmGroupPosixAttributes {
pub gidnumber: Option<u32>,
}
impl PartialEq for KanidmGroupPosixAttributes {
fn eq(&self, other: &Self) -> bool {
self.gidnumber.is_none() || self.gidnumber == other.gidnumber
}
}
impl From<Entry> for KanidmGroupPosixAttributes {
fn from(entry: Entry) -> Self {
KanidmGroupPosixAttributes {
gidnumber: get_first_cloned(&entry, ATTR_GIDNUMBER).and_then(|s| s.parse::<u32>().ok()),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CredentialTypeMinimum {
#[default]
Any,
Mfa,
Passkey,
AttestedPasskey,
}
impl fmt::Display for CredentialTypeMinimum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CredentialTypeMinimum::Any => write!(f, "any"),
CredentialTypeMinimum::Mfa => write!(f, "mfa"),
CredentialTypeMinimum::Passkey => write!(f, "passkey"),
CredentialTypeMinimum::AttestedPasskey => write!(f, "attested_passkey"),
}
}
}
impl CredentialTypeMinimum {
pub fn from_kanidm_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"any" => Some(CredentialTypeMinimum::Any),
"mfa" => Some(CredentialTypeMinimum::Mfa),
"passkey" => Some(CredentialTypeMinimum::Passkey),
"attested_passkey" => Some(CredentialTypeMinimum::AttestedPasskey),
_ => None,
}
}
}
#[cfg(feature = "schemars")]
fn credential_type_minimum_schema(
_generator: &mut schemars::generate::SchemaGenerator,
) -> schemars::Schema {
schemars::json_schema!({
"description": "Minimum security strength of credentials that may be assigned to accounts affected by this policy. In order from weakest to strongest: any < mfa < passkey < attested_passkey.\n\nMore info: https://kanidm.github.io/kanidm/stable/accounts/account_policy.html#credential-type-minimum",
"type": "string",
"enum": ["any", "mfa", "passkey", "attested_passkey"],
"x-enum-descriptions": [
"Any credential type is allowed (weakest)",
"Multi-factor authentication required",
"Passkey (WebAuthn) required",
"Attested passkey required (strongest) - requires configuring WebAuthn attestation CA list"
],
"nullable": true
})
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct KanidmGroupAccountPolicy {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_session_expiry: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "credential_type_minimum_schema", default)
)]
pub credential_type_minimum: Option<CredentialTypeMinimum>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_minimum_length: Option<u32>,
#[cfg_attr(feature = "schemars", schemars(extend("x-kubernetes-validations" = [
{
"message": "privilegeExpiry must be at most 3600 seconds (1 hour)",
"rule": "self <= 3600"
}
])))]
#[serde(skip_serializing_if = "Option::is_none")]
pub privilege_expiry: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webauthn_attestation_ca_list: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_primary_cred_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit_search_max_results: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit_search_max_filter_test: Option<u32>,
}
#[derive(Clone, Debug, Default)]
pub struct KanidmGroupAccountPolicyAttributes {
pub enabled: bool,
pub auth_session_expiry: Option<u32>,
pub credential_type_minimum: Option<CredentialTypeMinimum>,
pub password_minimum_length: Option<u32>,
pub privilege_expiry: Option<u32>,
pub webauthn_attestation_ca_list: Option<String>,
pub allow_primary_cred_fallback: Option<bool>,
pub limit_search_max_results: Option<u32>,
pub limit_search_max_filter_test: Option<u32>,
}
impl From<&Entry> for KanidmGroupAccountPolicyAttributes {
fn from(entry: &Entry) -> Self {
let enabled = entry
.attrs
.get("class")
.is_some_and(|classes| classes.iter().any(|c| c == ENTRYCLASS_ACCOUNT_POLICY));
KanidmGroupAccountPolicyAttributes {
enabled,
auth_session_expiry: get_first_cloned(entry, ATTR_AUTH_SESSION_EXPIRY)
.and_then(|s| s.parse::<u32>().ok()),
credential_type_minimum: get_first_cloned(entry, ATTR_CREDENTIAL_TYPE_MINIMUM)
.and_then(|s| CredentialTypeMinimum::from_kanidm_str(&s)),
password_minimum_length: get_first_cloned(entry, ATTR_AUTH_PASSWORD_MINIMUM_LENGTH)
.and_then(|s| s.parse::<u32>().ok()),
privilege_expiry: get_first_cloned(entry, ATTR_PRIVILEGE_EXPIRY)
.and_then(|s| s.parse::<u32>().ok()),
webauthn_attestation_ca_list: get_first_cloned(
entry,
ATTR_WEBAUTHN_ATTESTATION_CA_LIST,
),
allow_primary_cred_fallback: get_first_cloned(entry, ATTR_ALLOW_PRIMARY_CRED_FALLBACK)
.and_then(|s| s.parse::<bool>().ok()),
limit_search_max_results: get_first_cloned(entry, ATTR_LIMIT_SEARCH_MAX_RESULTS)
.and_then(|s| s.parse::<u32>().ok()),
limit_search_max_filter_test: get_first_cloned(
entry,
ATTR_LIMIT_SEARCH_MAX_FILTER_TEST,
)
.and_then(|s| s.parse::<u32>().ok()),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct KanidmGroupStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conditions: Option<Vec<Condition>>,
pub ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gid: Option<u32>,
pub kanidm_ref: String,
}