use crate::api::Client;
use crate::error::{Error, Result};
use crate::types::DeploymentStatus;
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gate {
pub provider: String,
pub public_paths: Option<Vec<GatePath>>,
pub session_ttl_secs: Option<u64>,
pub base_path: Option<String>,
pub cookie_name: Option<String>,
pub forward_identity: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatePath {
pub path: String,
pub scope: String,
}
impl Gate {
pub fn provider(name: impl Into<String>) -> Self {
Self {
provider: name.into(),
public_paths: None,
session_ttl_secs: None,
base_path: None,
cookie_name: None,
forward_identity: None,
}
}
pub fn public(self, prefix: impl Into<String>) -> Self {
self.path(prefix, "public")
}
pub fn path(mut self, prefix: impl Into<String>, scope: impl Into<String>) -> Self {
self.public_paths
.get_or_insert_with(Vec::new)
.push(GatePath {
path: prefix.into(),
scope: scope.into(),
});
self
}
pub fn session_ttl_secs(mut self, secs: u64) -> Self {
self.session_ttl_secs = Some(secs);
self
}
pub fn base_path(mut self, path: impl Into<String>) -> Self {
self.base_path = Some(path.into());
self
}
pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
self.cookie_name = Some(name.into());
self
}
pub fn forward_identity(mut self, on: bool) -> Self {
self.forward_identity = Some(on);
self
}
pub fn apply_to(&self, spec: &mut Value) -> Result<()> {
let obj = spec
.as_object_mut()
.ok_or_else(|| Error::Invalid("a deployment spec must be a JSON object".into()))?;
let auth = obj
.entry("auth")
.or_insert_with(|| Value::Object(Map::new()));
if !auth.is_object() {
*auth = Value::Object(Map::new());
}
let auth = auth.as_object_mut().expect("made an object above");
auth.insert("provider_ref".into(), Value::String(self.provider.clone()));
for identity in [
"provider",
"client_id",
"client_secret",
"allowed_domains",
"allowed_emails",
"jwt",
"cookie_domain",
] {
auth.remove(identity);
}
if let Some(paths) = &self.public_paths {
auth.insert(
"public_paths".into(),
paths
.iter()
.map(|p| json!({"path": p.path, "scope": p.scope}))
.collect(),
);
}
if let Some(ttl) = self.session_ttl_secs {
auth.insert("session_ttl_secs".into(), ttl.into());
}
if let Some(b) = &self.base_path {
auth.insert("base_path".into(), b.clone().into());
}
if let Some(c) = &self.cookie_name {
auth.insert("cookie_name".into(), c.clone().into());
}
if let Some(f) = self.forward_identity {
auth.insert("forward_identity".into(), f.into());
}
Ok(())
}
}
fn plugin_for(name: &str) -> Option<&'static str> {
match name {
"heyo" => Some("auth-heyo"),
"google" => Some("auth-google"),
_ => None,
}
}
impl Client {
pub async fn set_auth(&self, id: &str, gate: &Gate) -> Result<DeploymentStatus> {
let mut spec = self.raw().spec(id).await?;
let namespace = spec
.get("namespace")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
if !self
.auth_provider_exists(&namespace, &gate.provider)
.await?
{
let how = match plugin_for(&gate.provider) {
Some(plugin) => format!(
"install the {plugin} plugin there (`install_plugin({namespace:?}, {plugin:?}, …)`)"
),
None => "declare it with `create_auth_provider`".to_string(),
};
return Err(Error::Invalid(format!(
"namespace {namespace:?} has no auth provider {:?}; {how}",
gate.provider
)));
}
gate.apply_to(&mut spec)?;
self.replace_deployment(id, &spec).await
}
pub async fn clear_auth(&self, id: &str) -> Result<DeploymentStatus> {
let mut spec = self.raw().spec(id).await?;
if let Some(obj) = spec.as_object_mut() {
obj.remove("auth");
}
self.replace_deployment(id, &spec).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::Method;
use crate::transport::stub::Stub;
use std::sync::Arc;
fn client(stub: Stub) -> (Client, Arc<Stub>) {
let stub = Arc::new(stub);
(Client::with_transport(stub.clone()), stub)
}
fn status(auth: Value) -> Value {
json!({"spec": {"id": "reports", "namespace": "team-a", "routes": [{"host": "r.example.com"}],
"upstreams": ["127.0.0.1:8080"], "auth": auth}})
}
#[test]
fn a_gate_replaces_inline_identity_and_keeps_what_it_leaves_unset() {
let mut spec = status(json!({
"provider": "google", "client_id": "x", "client_secret": {"secret": "g"},
"allowed_domains": ["example.com"], "session_ttl_secs": 600, "cookie_name": "s"
}))["spec"]
.clone();
Gate::provider("heyo")
.public("/healthz")
.apply_to(&mut spec)
.unwrap();
assert_eq!(
spec["auth"],
json!({"provider_ref": "heyo", "session_ttl_secs": 600, "cookie_name": "s",
"public_paths": [{"path": "/healthz", "scope": "public"}]})
);
let mut bare = json!({"id": "web"});
Gate::provider("google")
.path("/api/", "view")
.forward_identity(false)
.apply_to(&mut bare)
.unwrap();
assert_eq!(
bare["auth"],
json!({"provider_ref": "google", "forward_identity": false,
"public_paths": [{"path": "/api/", "scope": "view"}]})
);
}
#[tokio::test]
async fn set_auth_checks_the_provider_then_replaces_the_spec() {
let (c, stub) = client(
Stub::new()
.json(200, status(Value::Null))
.json(
200,
json!({"name": "heyo", "namespace": "team-a", "provider": "jwt"}),
)
.json(200, status(json!({"provider_ref": "heyo"}))),
);
c.set_auth("reports", &Gate::provider("heyo").public("/healthz"))
.await
.unwrap();
let calls = stub.calls();
let seen: Vec<String> = calls
.iter()
.map(|c| format!("{} {}", c.method.as_str(), c.path))
.collect();
assert_eq!(
seen,
[
"GET /deployments/reports",
"GET /auth-providers/team-a/heyo",
"PUT /deployments/reports"
]
);
assert_eq!(calls[2].method, Method::Put);
assert_eq!(
calls[2].body.as_ref().unwrap()["auth"],
json!({"provider_ref": "heyo", "public_paths": [{"path": "/healthz", "scope": "public"}]})
);
}
#[tokio::test]
async fn a_missing_provider_names_the_plugin_that_declares_it() {
let (c, stub) = client(Stub::new().json(200, status(Value::Null)).json(
404,
json!({"error": "no auth provider \"google\" in namespace \"team-a\""}),
));
let e = c
.set_auth("reports", &Gate::provider("google"))
.await
.unwrap_err();
assert!(matches!(e, Error::Invalid(_)), "{e:?}");
assert!(e.to_string().contains("auth-google"), "{e}");
assert_eq!(stub.call_count(), 2, "nothing was written");
}
#[tokio::test]
async fn clear_auth_removes_the_gate() {
let (c, stub) = client(
Stub::new()
.json(200, status(json!({"provider_ref": "heyo"})))
.json(200, status(Value::Null)),
);
c.clear_auth("reports").await.unwrap();
let body = stub.calls()[1].body.clone().unwrap();
assert!(body.get("auth").is_none(), "{body}");
}
}