use std::fmt::{self, Display, Formatter};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::json;
use url::Url;
use crate::auth::SecretBearer;
use crate::card::operator::FORBIDDEN_HTTP_HEADERS;
use crate::error::WyrdError;
use crate::ids::{ConnectionName, OperatorConnectionId};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
strum::Display,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum OperatorProvider {
Slack,
PagerDuty,
Http,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum OperatorConnectionStatus {
Active,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(transparent)]
pub struct HttpsOrigin(String);
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct OriginError(String);
impl HttpsOrigin {
pub fn parse(value: &str) -> Result<Self, OriginError> {
let url = Url::parse(value).map_err(|error| OriginError(error.to_string()))?;
Self::of(&url, true)
}
pub fn of_url(url: &Url) -> Result<Self, OriginError> {
Self::of(url, false)
}
fn of(url: &Url, bare: bool) -> Result<Self, OriginError> {
let loopback = match url.host() {
Some(url::Host::Domain(host)) => host == "localhost",
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
None => return Err(OriginError("origin must have a host".to_owned())),
};
match url.scheme() {
"https" => {}
"http" if loopback => {}
scheme => return Err(OriginError(format!("scheme {scheme} is not https"))),
}
if !url.username().is_empty() || url.password().is_some() {
return Err(OriginError("origin must not carry userinfo".to_owned()));
}
if bare && (url.path() != "/" || url.query().is_some() || url.fragment().is_some()) {
return Err(OriginError(
"origin must not carry a path, query, or fragment".to_owned(),
));
}
Ok(Self(url.origin().ascii_serialization()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for HttpsOrigin {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for HttpsOrigin {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "scheme", rename_all = "snake_case", deny_unknown_fields)]
pub enum HttpConnectionAuth {
Bearer {
token: SecretBearer,
},
Basic {
username: SecretBearer,
password: SecretBearer,
},
Header {
name: String,
value: SecretBearer,
},
}
impl HttpConnectionAuth {
fn split(self) -> Result<(HttpAuthScheme, ConnectionSecret), WyrdError> {
Ok(match self {
Self::Bearer { token } => (HttpAuthScheme::Bearer, ConnectionSecret::token(token)?),
Self::Basic { username, password } => {
if username.expose().is_empty() || username.expose().contains(':') {
return Err(invalid("auth.username", "must be non-empty without ':'"));
}
if password.expose().is_empty() {
return Err(invalid("auth.password", "must not be empty"));
}
(
HttpAuthScheme::Basic,
ConnectionSecret::Basic { username, password },
)
}
Self::Header { name, value } => {
let forbidden = FORBIDDEN_HTTP_HEADERS
.iter()
.any(|header| name.eq_ignore_ascii_case(header));
let token = name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b));
if name.is_empty() || !token || forbidden {
return Err(invalid(
"auth.name",
"must be a non-server-owned header name",
));
}
(
HttpAuthScheme::Header { name },
ConnectionSecret::token(value)?,
)
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "scheme", rename_all = "snake_case", deny_unknown_fields)]
pub enum HttpAuthScheme {
Bearer,
Basic,
Header {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum CreateOperatorConnectionRequest {
Slack {
name: ConnectionName,
workspace_id: String,
bot_token: SecretBearer,
},
PagerDuty {
name: ConnectionName,
integration_key: SecretBearer,
},
Http {
name: ConnectionName,
origin: HttpsOrigin,
auth: HttpConnectionAuth,
},
}
impl CreateOperatorConnectionRequest {
pub fn split(
self,
) -> Result<(ConnectionName, OperatorConnectionConfig, ConnectionSecret), WyrdError> {
Ok(match self {
Self::Slack {
name,
workspace_id,
bot_token,
} => (
name,
OperatorConnectionConfig::slack(workspace_id)?,
ConnectionSecret::token(bot_token)?,
),
Self::PagerDuty {
name,
integration_key,
} => (
name,
OperatorConnectionConfig::PagerDuty {},
ConnectionSecret::token(integration_key)?,
),
Self::Http { name, origin, auth } => {
let (auth, secret) = auth.split()?;
(
name,
OperatorConnectionConfig::Http { origin, auth },
secret,
)
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum UpdateOperatorConnectionRequest {
Slack {
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bot_token: Option<SecretBearer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<OperatorConnectionStatus>,
},
PagerDuty {
#[serde(default, skip_serializing_if = "Option::is_none")]
integration_key: Option<SecretBearer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<OperatorConnectionStatus>,
},
Http {
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<HttpsOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth: Option<HttpConnectionAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<OperatorConnectionStatus>,
},
}
#[derive(Debug)]
pub struct ConnectionUpdate {
pub config: OperatorConnectionConfig,
pub secret: Option<ConnectionSecret>,
pub status: Option<OperatorConnectionStatus>,
}
impl UpdateOperatorConnectionRequest {
pub fn apply(self, current: OperatorConnectionConfig) -> Result<ConnectionUpdate, WyrdError> {
match (self, current) {
(
Self::Slack {
workspace_id,
bot_token,
status,
},
OperatorConnectionConfig::Slack {
workspace_id: stored,
},
) => Ok(ConnectionUpdate {
config: OperatorConnectionConfig::slack(workspace_id.unwrap_or(stored))?,
secret: bot_token.map(ConnectionSecret::token).transpose()?,
status,
}),
(
Self::PagerDuty {
integration_key,
status,
},
OperatorConnectionConfig::PagerDuty {},
) => Ok(ConnectionUpdate {
config: OperatorConnectionConfig::PagerDuty {},
secret: integration_key.map(ConnectionSecret::token).transpose()?,
status,
}),
(
Self::Http {
origin,
auth,
status,
},
OperatorConnectionConfig::Http {
origin: stored_origin,
auth: stored_auth,
},
) => {
let (auth, secret) = match auth {
Some(auth) => {
let (scheme, secret) = auth.split()?;
(scheme, Some(secret))
}
None => (stored_auth, None),
};
Ok(ConnectionUpdate {
config: OperatorConnectionConfig::Http {
origin: origin.unwrap_or(stored_origin),
auth,
},
secret,
status,
})
}
(update, current) => Err(invalid(
"provider",
&format!(
"update for {} does not match the {} connection",
update.provider(),
current.provider()
),
)),
}
}
#[must_use]
pub const fn provider(&self) -> OperatorProvider {
match self {
Self::Slack { .. } => OperatorProvider::Slack,
Self::PagerDuty { .. } => OperatorProvider::PagerDuty,
Self::Http { .. } => OperatorProvider::Http,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum OperatorConnectionConfig {
Slack {
workspace_id: String,
},
PagerDuty {},
Http {
origin: HttpsOrigin,
auth: HttpAuthScheme,
},
}
impl OperatorConnectionConfig {
#[must_use]
pub const fn provider(&self) -> OperatorProvider {
match self {
Self::Slack { .. } => OperatorProvider::Slack,
Self::PagerDuty {} => OperatorProvider::PagerDuty,
Self::Http { .. } => OperatorProvider::Http,
}
}
fn slack(workspace_id: String) -> Result<Self, WyrdError> {
if workspace_id.trim().is_empty() {
return Err(invalid("workspace_id", "must not be empty"));
}
Ok(Self::Slack { workspace_id })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct OperatorConnectionView {
pub connection_id: OperatorConnectionId,
pub name: ConnectionName,
#[serde(flatten)]
pub config: OperatorConnectionConfig,
pub status: OperatorConnectionStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConnectionSecret {
Token {
value: SecretBearer,
},
Basic {
username: SecretBearer,
password: SecretBearer,
},
}
impl ConnectionSecret {
fn token(value: SecretBearer) -> Result<Self, WyrdError> {
if value.expose().is_empty() {
return Err(invalid("secret", "must not be empty"));
}
Ok(Self::Token { value })
}
}
fn invalid(field: &str, reason: &str) -> WyrdError {
WyrdError::OperatorConnectionInvalid {
message: format!("{field}: {reason}"),
details: json!({ "field": field, "reason": reason }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origins_normalize_and_refuse_extras() {
let origin = HttpsOrigin::parse("HTTPS://Hooks.Example.COM:443").expect("valid origin");
assert_eq!(origin.as_str(), "https://hooks.example.com");
assert_eq!(
HttpsOrigin::parse("https://hooks.example.com:8443/")
.expect("explicit port")
.as_str(),
"https://hooks.example.com:8443"
);
assert!(HttpsOrigin::parse("http://127.0.0.1:9000").is_ok());
for bad in [
"http://hooks.example.com",
"https://hooks.example.com/path",
"https://hooks.example.com/?q=1",
"https://user:pw@hooks.example.com",
"ftp://hooks.example.com",
] {
assert!(HttpsOrigin::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn create_splits_config_from_secret() {
let request: CreateOperatorConnectionRequest = serde_json::from_value(json!({
"provider": "http", "name": "hooks", "origin": "https://hooks.example.com",
"auth": { "scheme": "header", "name": "X-Api-Key", "value": "s3cret" }
}))
.expect("documented create parses");
assert!(!format!("{request:?}").contains("s3cret"));
let (name, config, secret) = request.split().expect("valid create");
assert_eq!(name.as_str(), "hooks");
assert_eq!(
serde_json::to_value(&config).expect("config serializes"),
json!({ "provider": "http", "origin": "https://hooks.example.com", "auth": { "scheme": "header", "name": "X-Api-Key" } })
);
assert!(matches!(secret, ConnectionSecret::Token { value } if value.expose() == "s3cret"));
let empty: CreateOperatorConnectionRequest = serde_json::from_value(json!({
"provider": "pager_duty", "name": "pagerduty", "integration_key": ""
}))
.expect("parses");
assert_eq!(
empty.split().expect_err("empty key").code(),
"WYRD_OPERATOR_400_INVALID_CONNECTION"
);
let host: CreateOperatorConnectionRequest = serde_json::from_value(json!({
"provider": "http", "name": "hooks", "origin": "https://hooks.example.com",
"auth": { "scheme": "header", "name": "Authorization", "value": "x" }
}))
.expect("parses");
assert!(host.split().is_err());
}
#[test]
fn update_preserves_replaces_and_refuses_provider_change() {
let stored = OperatorConnectionConfig::Http {
origin: HttpsOrigin::parse("https://hooks.example.com").expect("origin"),
auth: HttpAuthScheme::Bearer,
};
let status_only: UpdateOperatorConnectionRequest =
serde_json::from_value(json!({ "provider": "http", "status": "disabled" }))
.expect("parses");
let update = status_only.apply(stored.clone()).expect("status update");
assert_eq!(update.config, stored);
assert!(update.secret.is_none());
assert_eq!(update.status, Some(OperatorConnectionStatus::Disabled));
let rotate: UpdateOperatorConnectionRequest = serde_json::from_value(json!({
"provider": "http", "auth": { "scheme": "basic", "username": "u", "password": "p" }
}))
.expect("parses");
let update = rotate.apply(stored.clone()).expect("auth update");
assert!(matches!(
update.config,
OperatorConnectionConfig::Http {
auth: HttpAuthScheme::Basic,
..
}
));
assert!(update.secret.is_some());
let wrong: UpdateOperatorConnectionRequest =
serde_json::from_value(json!({ "provider": "slack", "bot_token": "x" }))
.expect("parses");
assert!(wrong.apply(stored).is_err());
assert!(
serde_json::from_value::<UpdateOperatorConnectionRequest>(
json!({ "provider": "slack", "name": "renamed" })
)
.is_err()
);
}
#[test]
fn view_shape_is_redacted() {
let view = OperatorConnectionView {
connection_id: OperatorConnectionId::new_v7(),
name: ConnectionName::new("ops-slack").expect("name"),
config: OperatorConnectionConfig::Slack {
workspace_id: "T1".to_owned(),
},
status: OperatorConnectionStatus::Active,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let value = serde_json::to_value(&view).expect("serializes");
assert_eq!(value["provider"], "slack");
assert_eq!(value["workspace_id"], "T1");
assert!(value.get("bot_token").is_none());
let back: OperatorConnectionView = serde_json::from_value(value).expect("round trips");
assert_eq!(back, view);
}
}