use std::fmt;
use std::sync::Arc;
use std::sync::OnceLock;
use color_eyre::eyre::{Result, bail};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::graph_id::GraphId;
pub const TENANT_ID_MAX_LEN: usize = 64;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
#[serde(transparent)]
pub struct TenantId(String);
impl TenantId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for TenantId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for TenantId {
type Error = color_eyre::eyre::Error;
fn try_from(value: String) -> Result<Self> {
validate_tenant_id(value.as_str())?;
Ok(Self(value))
}
}
impl TryFrom<&str> for TenantId {
type Error = color_eyre::eyre::Error;
fn try_from(value: &str) -> Result<Self> {
validate_tenant_id(value)?;
Ok(Self(value.to_string()))
}
}
impl<'de> Deserialize<'de> for TenantId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::try_from(s).map_err(serde::de::Error::custom)
}
}
fn validate_tenant_id(value: &str) -> Result<()> {
if value.is_empty() {
bail!("tenant_id must not be empty");
}
if value.len() > TENANT_ID_MAX_LEN {
bail!(
"tenant_id '{}' is {} chars; max {}",
value,
value.len(),
TENANT_ID_MAX_LEN
);
}
if !tenant_id_regex().is_match(value) {
bail!("tenant_id '{}' must match ^[a-zA-Z0-9-]{{1,64}}$", value);
}
Ok(())
}
fn tenant_id_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9-]{1,64}$").expect("regex literal"))
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct GraphKey {
pub tenant_id: Option<TenantId>,
pub graph_id: GraphId,
}
impl GraphKey {
pub fn cluster(graph_id: GraphId) -> Self {
Self {
tenant_id: None,
graph_id,
}
}
pub fn cloud(tenant_id: TenantId, graph_id: GraphId) -> Self {
Self {
tenant_id: Some(tenant_id),
graph_id,
}
}
}
impl fmt::Display for GraphKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.tenant_id {
Some(t) => write!(f, "{}/{}", t, self.graph_id),
None => write!(f, "{}", self.graph_id),
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum Scope {
Full,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum AuthSource {
Static,
}
#[derive(Debug, Clone)]
pub struct ResolvedActor {
pub actor_id: Arc<str>,
pub tenant_id: Option<TenantId>,
pub scopes: Vec<Scope>,
pub source: AuthSource,
}
impl ResolvedActor {
pub fn cluster_static(actor_id: Arc<str>) -> Self {
Self {
actor_id,
tenant_id: None,
scopes: vec![Scope::Full],
source: AuthSource::Static,
}
}
pub fn actor_id_str(&self) -> &str {
&self.actor_id
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tenant_id_accepts_simple_values() {
for ok in ["alpha", "tenant-001", "X", "01HZWA0KT0H0V0V0V0V0V0V0V0"] {
TenantId::try_from(ok).unwrap_or_else(|_| panic!("expected accept: {ok}"));
}
}
#[test]
fn tenant_id_rejects_empty_and_over_max() {
assert!(TenantId::try_from("").is_err());
let too_long = "a".repeat(65);
assert!(TenantId::try_from(too_long.as_str()).is_err());
}
#[test]
fn tenant_id_rejects_path_traversal() {
assert!(TenantId::try_from("../etc").is_err());
assert!(TenantId::try_from("alpha/beta").is_err());
}
#[test]
fn tenant_id_deserialize_runs_validation() {
let bad: Result<TenantId, _> = serde_json::from_str("\"../evil\"");
assert!(bad.is_err());
}
#[test]
fn graph_key_cluster_constructor_sets_no_tenant() {
let id = GraphId::try_from("alpha").unwrap();
let key = GraphKey::cluster(id.clone());
assert!(key.tenant_id.is_none());
assert_eq!(key.graph_id, id);
}
#[test]
fn graph_key_cloud_constructor_sets_tenant() {
let tenant = TenantId::try_from("acme").unwrap();
let id = GraphId::try_from("alpha").unwrap();
let key = GraphKey::cloud(tenant.clone(), id.clone());
assert_eq!(key.tenant_id.as_ref(), Some(&tenant));
assert_eq!(key.graph_id, id);
}
#[test]
fn graph_key_displays_with_or_without_tenant() {
let id = GraphId::try_from("alpha").unwrap();
let cluster_key = GraphKey::cluster(id.clone());
assert_eq!(format!("{cluster_key}"), "alpha");
let tenant = TenantId::try_from("acme").unwrap();
let cloud_key = GraphKey::cloud(tenant, id);
assert_eq!(format!("{cloud_key}"), "acme/alpha");
}
#[test]
fn graph_key_is_hashable_for_map_use() {
use std::collections::HashMap;
let a = GraphKey::cluster(GraphId::try_from("alpha").unwrap());
let b = GraphKey::cluster(GraphId::try_from("alpha").unwrap());
let mut m: HashMap<GraphKey, u32> = HashMap::new();
m.insert(a, 1);
assert_eq!(m.get(&b), Some(&1));
}
#[test]
fn graph_key_distinguishes_tenants() {
let id = GraphId::try_from("alpha").unwrap();
let t1 = TenantId::try_from("acme").unwrap();
let t2 = TenantId::try_from("globex").unwrap();
let k1 = GraphKey::cloud(t1, id.clone());
let k2 = GraphKey::cloud(t2, id);
assert_ne!(k1, k2);
}
#[test]
fn resolved_actor_cluster_defaults() {
let actor = ResolvedActor::cluster_static(Arc::<str>::from("act-alice"));
assert_eq!(actor.actor_id_str(), "act-alice");
assert!(actor.tenant_id.is_none());
assert_eq!(actor.scopes, vec![Scope::Full]);
assert_eq!(actor.source, AuthSource::Static);
}
#[test]
fn scope_and_auth_source_are_non_exhaustive() {
let _scope = Scope::Full;
let _src = AuthSource::Static;
}
}