use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use vti_common::error::AppError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum VtcRole {
Admin,
Moderator,
Issuer,
Member,
Application,
Custom(String),
}
const CUSTOM_PREFIX: &str = "custom:";
const CUSTOM_NAME_MAX: usize = 64;
impl VtcRole {
fn as_wire(&self) -> String {
match self {
VtcRole::Admin => "admin".into(),
VtcRole::Moderator => "moderator".into(),
VtcRole::Issuer => "issuer".into(),
VtcRole::Member => "member".into(),
VtcRole::Application => "application".into(),
VtcRole::Custom(name) => format!("{CUSTOM_PREFIX}{name}"),
}
}
pub fn custom(name: impl Into<String>) -> Result<Self, AppError> {
let name = name.into();
validate_custom_name(&name)?;
Ok(VtcRole::Custom(name))
}
pub fn is_standard(&self) -> bool {
!matches!(self, VtcRole::Custom(_))
}
pub fn implied_admin_role(&self) -> Option<super::AdminRole> {
match self {
VtcRole::Admin => Some(super::AdminRole::CommunityAdmin),
VtcRole::Moderator => Some(super::AdminRole::Moderator),
VtcRole::Issuer => Some(super::AdminRole::CredentialOfficer),
VtcRole::Member | VtcRole::Application | VtcRole::Custom(_) => None,
}
}
pub fn is_assignable(&self) -> bool {
!matches!(self, VtcRole::Application)
}
pub fn refuse_unassignable(&self) -> Result<(), AppError> {
if self.is_assignable() {
Ok(())
} else {
Err(AppError::Validation(
"`application` is not a role anyone assigns: the community writes it only for a \
subject that holds git rights without being a member (the bridge, an external \
signer). Grant git rights with git-ns/right/grant, and admit a member with the \
join or invitation flow"
.into(),
))
}
}
pub fn implied_authority(&self) -> super::AdminAuthority {
self.implied_admin_role()
.map(super::AdminAuthority::for_role)
.unwrap_or_else(super::AdminAuthority::none)
}
pub fn implied_by(admin: Option<&super::AdminRole>) -> VtcRole {
match admin {
Some(super::AdminRole::CommunityAdmin) => VtcRole::Admin,
Some(super::AdminRole::Moderator) => VtcRole::Moderator,
Some(super::AdminRole::CredentialOfficer) => VtcRole::Issuer,
_ => VtcRole::Member,
}
}
}
impl fmt::Display for VtcRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.as_wire())
}
}
impl FromStr for VtcRole {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, AppError> {
match s {
"admin" => Ok(VtcRole::Admin),
"moderator" => Ok(VtcRole::Moderator),
"issuer" => Ok(VtcRole::Issuer),
"member" => Ok(VtcRole::Member),
"application" => Ok(VtcRole::Application),
other => {
if let Some(name) = other.strip_prefix(CUSTOM_PREFIX) {
validate_custom_name(name)?;
Ok(VtcRole::Custom(name.to_string()))
} else {
Err(AppError::Validation(format!(
"unknown VTC role '{other}'. Expected one of admin, moderator, issuer, \
member, application, or custom:<name>."
)))
}
}
}
}
}
impl utoipa::PartialSchema for VtcRole {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::SchemaType::Type(
utoipa::openapi::Type::String,
))
.description(Some(
"One of `admin`, `moderator`, `issuer`, `member`, `application`, or `custom:<name>` where `<name>` is 1..=64 lowercase alphanumerics, `-`, or `_`.",
))
.examples([serde_json::json!("admin")])
.into()
}
}
impl utoipa::ToSchema for VtcRole {}
impl Serialize for VtcRole {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_wire())
}
}
impl<'de> Deserialize<'de> for VtcRole {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
VtcRole::from_str(&s).map_err(serde::de::Error::custom)
}
}
fn validate_custom_name(name: &str) -> Result<(), AppError> {
if name.is_empty() {
return Err(AppError::Validation(
"custom role name cannot be empty".into(),
));
}
if name.len() > CUSTOM_NAME_MAX {
return Err(AppError::Validation(format!(
"custom role name too long (max {CUSTOM_NAME_MAX} chars)"
)));
}
if !name.chars().all(is_valid_name_char) {
return Err(AppError::Validation(format!(
"custom role name '{name}' contains disallowed characters \
(allowed: lowercase a–z, digits, `-`, `_`)"
)));
}
Ok(())
}
fn is_valid_name_char(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')
}
pub fn as_vti_role(role: &VtcRole) -> vti_common::acl::Role {
match role {
VtcRole::Admin => vti_common::acl::Role::Admin,
_ => vti_common::acl::Role::Reader,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_variants_round_trip_through_wire() {
for (variant, wire) in [
(VtcRole::Admin, "admin"),
(VtcRole::Moderator, "moderator"),
(VtcRole::Issuer, "issuer"),
(VtcRole::Member, "member"),
(VtcRole::Application, "application"),
] {
let serialised = serde_json::to_string(&variant).unwrap();
assert_eq!(serialised, format!("\"{wire}\""));
let deserialised: VtcRole = serde_json::from_str(&serialised).unwrap();
assert_eq!(deserialised, variant);
assert_eq!(variant.to_string(), wire);
}
}
#[test]
fn the_application_role_is_stored_but_never_assigned() {
let r: VtcRole = serde_json::from_str("\"application\"").unwrap();
assert_eq!(r, VtcRole::Application);
assert!(r.implied_admin_role().is_none());
assert!(!r.is_assignable());
assert!(r.refuse_unassignable().is_err());
assert!(VtcRole::Member.refuse_unassignable().is_ok());
}
#[test]
fn custom_variant_round_trips() {
let r = VtcRole::custom("editor").unwrap();
let serialised = serde_json::to_string(&r).unwrap();
assert_eq!(serialised, "\"custom:editor\"");
let deserialised: VtcRole = serde_json::from_str(&serialised).unwrap();
assert_eq!(deserialised, r);
}
#[test]
fn custom_constructor_validates_charset() {
assert!(VtcRole::custom("editor").is_ok());
assert!(VtcRole::custom("trust-anchor").is_ok());
assert!(VtcRole::custom("badge_holder_42").is_ok());
for bad in ["", "EDITOR", "with spaces", "colon:bad", "../../etc"] {
let err = VtcRole::custom(bad).expect_err(&format!("expected reject: {bad}"));
assert!(matches!(err, AppError::Validation(_)));
}
}
#[test]
fn deserialise_rejects_unknown_string() {
let err = serde_json::from_str::<VtcRole>("\"unknown\"").unwrap_err();
assert!(err.to_string().contains("unknown VTC role"));
}
#[test]
fn deserialise_rejects_custom_with_bad_charset() {
let err = serde_json::from_str::<VtcRole>("\"custom:UPPER\"").unwrap_err();
assert!(err.to_string().contains("disallowed characters"));
}
#[test]
fn deserialise_rejects_custom_with_empty_name() {
let err = serde_json::from_str::<VtcRole>("\"custom:\"").unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn deserialise_rejects_custom_with_name_too_long() {
let name = "a".repeat(CUSTOM_NAME_MAX + 1);
let err = serde_json::from_str::<VtcRole>(&format!("\"custom:{name}\"")).unwrap_err();
assert!(err.to_string().contains("too long"));
}
#[test]
fn custom_name_with_colon_does_not_smuggle_admin() {
let r: VtcRole = serde_json::from_str("\"custom:admin\"").unwrap();
assert_eq!(r, VtcRole::Custom("admin".into()));
assert_ne!(r, VtcRole::Admin);
}
#[test]
fn is_standard_returns_true_only_for_named_variants() {
assert!(VtcRole::Admin.is_standard());
assert!(VtcRole::Moderator.is_standard());
assert!(VtcRole::Issuer.is_standard());
assert!(VtcRole::Member.is_standard());
assert!(!VtcRole::Custom("x".into()).is_standard());
}
#[test]
fn from_str_handles_vti_common_admin_wire_shape() {
let role = VtcRole::from_str("admin").unwrap();
assert_eq!(role, VtcRole::Admin);
}
}