use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::client::{Client, Secret};
use crate::error::Result;
use crate::page::ListRequest;
use crate::ratelimit::{Scope, ScopeSet};
use crate::types::{DjangoDuration, RecordType, Subname};
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Token {
pub id: Uuid,
pub created: DateTime<Utc>,
#[serde(default)]
pub last_used: Option<DateTime<Utc>>,
pub owner: String,
#[serde(default)]
pub user_override: Option<String>,
#[serde(default)]
pub mfa: Option<bool>,
#[serde(default)]
pub name: String,
pub perm_create_domain: bool,
pub perm_delete_domain: bool,
pub perm_manage_tokens: bool,
#[serde(default)]
pub allowed_subnets: Vec<String>,
pub auto_policy: bool,
pub is_valid: bool,
#[serde(default)]
pub max_age: Option<DjangoDuration>,
#[serde(default)]
pub max_unused_period: Option<DjangoDuration>,
#[serde(default)]
pub token: Option<Secret>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct TokenUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
perm_create_domain: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
perm_delete_domain: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
perm_manage_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_policy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_subnets: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
max_age: Option<Option<DjangoDuration>>,
#[serde(skip_serializing_if = "Option::is_none")]
max_unused_period: Option<Option<DjangoDuration>>,
}
impl TokenUpdate {
pub fn new() -> Self {
Self::default()
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn perm_create_domain(mut self, allowed: bool) -> Self {
self.perm_create_domain = Some(allowed);
self
}
pub fn perm_delete_domain(mut self, allowed: bool) -> Self {
self.perm_delete_domain = Some(allowed);
self
}
pub fn perm_manage_tokens(mut self, allowed: bool) -> Self {
self.perm_manage_tokens = Some(allowed);
self
}
pub fn auto_policy(mut self, enabled: bool) -> Self {
self.auto_policy = Some(enabled);
self
}
pub fn allowed_subnets(mut self, subnets: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.allowed_subnets = Some(subnets.into_iter().map(Into::into).collect());
self
}
pub fn max_age(mut self, max_age: DjangoDuration) -> Self {
self.max_age = Some(Some(max_age));
self
}
pub fn clear_max_age(mut self) -> Self {
self.max_age = Some(None);
self
}
pub fn max_unused_period(mut self, period: DjangoDuration) -> Self {
self.max_unused_period = Some(Some(period));
self
}
pub fn clear_max_unused_period(mut self) -> Self {
self.max_unused_period = Some(None);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct TokenPolicy {
pub id: Uuid,
pub domain: Option<String>,
pub subname: Option<Subname>,
#[serde(rename = "type")]
pub record_type: Option<RecordType>,
pub perm_write: bool,
}
impl TokenPolicy {
pub fn is_default(&self) -> bool {
self.domain.is_none() && self.subname.is_none() && self.record_type.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NewTokenPolicy {
pub domain: Option<String>,
pub subname: Option<Subname>,
#[serde(rename = "type")]
pub record_type: Option<RecordType>,
pub perm_write: bool,
}
impl NewTokenPolicy {
pub fn default_policy(perm_write: bool) -> Self {
Self {
domain: None,
subname: None,
record_type: None,
perm_write,
}
}
pub fn for_domain(domain: impl Into<String>, perm_write: bool) -> Self {
Self {
domain: Some(domain.into()),
subname: None,
record_type: None,
perm_write,
}
}
pub fn subname(mut self, subname: Subname) -> Self {
self.subname = Some(subname);
self
}
pub fn record_type(mut self, record_type: RecordType) -> Self {
self.record_type = Some(record_type);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct TokenPolicyPatch {
#[serde(skip_serializing_if = "Option::is_none")]
domain: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
subname: Option<Option<Subname>>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
record_type: Option<Option<RecordType>>,
#[serde(skip_serializing_if = "Option::is_none")]
perm_write: Option<bool>,
}
impl TokenPolicyPatch {
pub fn new() -> Self {
Self::default()
}
pub fn perm_write(mut self, allowed: bool) -> Self {
self.perm_write = Some(allowed);
self
}
pub fn domain(mut self, domain: impl Into<String>) -> Self {
self.domain = Some(Some(domain.into()));
self
}
pub fn any_domain(mut self) -> Self {
self.domain = Some(None);
self
}
pub fn subname(mut self, subname: Subname) -> Self {
self.subname = Some(Some(subname));
self
}
pub fn any_subname(mut self) -> Self {
self.subname = Some(None);
self
}
pub fn record_type(mut self, record_type: RecordType) -> Self {
self.record_type = Some(Some(record_type));
self
}
pub fn any_record_type(mut self) -> Self {
self.record_type = Some(None);
self
}
pub fn is_empty(&self) -> bool {
self.domain.is_none()
&& self.subname.is_none()
&& self.record_type.is_none()
&& self.perm_write.is_none()
}
}
#[derive(Debug, Clone, Copy)]
pub struct TokensApi<'a> {
client: &'a Client,
}
impl<'a> TokensApi<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
fn scope() -> ScopeSet {
ScopeSet::new(Scope::AccountManagementPassive)
}
pub async fn create(&self, token: &TokenUpdate) -> Result<Token> {
let url = self.client.url(&["auth", "tokens"]);
let req = self
.client
.request(Method::POST, url, Self::scope())
.json(token)?;
self.client.send_json(req).await
}
pub fn list(&self) -> ListRequest<Token> {
ListRequest::new(
self.client.clone(),
self.client.url(&["auth", "tokens"]),
Self::scope(),
)
}
pub async fn get(&self, id: Uuid) -> Result<Token> {
let url = self.client.url(&["auth", "tokens", &id.to_string()]);
let req = self.client.request(Method::GET, url, Self::scope());
self.client.send_json(req).await
}
pub async fn try_get(&self, id: Uuid) -> Result<Option<Token>> {
let url = self.client.url(&["auth", "tokens", &id.to_string()]);
let req = self.client.request(Method::GET, url, Self::scope());
self.client.send_json_opt(req).await
}
pub async fn patch(&self, id: Uuid, update: &TokenUpdate) -> Result<Token> {
let url = self.client.url(&["auth", "tokens", &id.to_string()]);
let req = self
.client
.request(Method::PATCH, url, Self::scope())
.json(update)?;
self.client.send_json(req).await
}
pub async fn replace(&self, id: Uuid, update: &TokenUpdate) -> Result<Token> {
let url = self.client.url(&["auth", "tokens", &id.to_string()]);
let req = self
.client
.request(Method::PUT, url, Self::scope())
.json(update)?;
self.client.send_json(req).await
}
pub async fn delete(&self, id: Uuid) -> Result<()> {
let url = self.client.url(&["auth", "tokens", &id.to_string()]);
let req = self.client.request(Method::DELETE, url, Self::scope());
self.client.send_empty(req).await
}
pub fn policies(&self, token_id: Uuid) -> TokenPoliciesApi<'a> {
TokenPoliciesApi {
client: self.client,
token_id,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TokenPoliciesApi<'a> {
client: &'a Client,
token_id: Uuid,
}
impl TokenPoliciesApi<'_> {
fn scope() -> ScopeSet {
ScopeSet::new(Scope::AccountManagementPassive)
}
fn collection_url(&self) -> url::Url {
self.client.url(&[
"auth",
"tokens",
&self.token_id.to_string(),
"policies",
"rrsets",
])
}
fn item_url(&self, policy_id: Uuid) -> url::Url {
self.client.url(&[
"auth",
"tokens",
&self.token_id.to_string(),
"policies",
"rrsets",
&policy_id.to_string(),
])
}
pub async fn list(&self) -> Result<Vec<TokenPolicy>> {
let req = self
.client
.request(Method::GET, self.collection_url(), Self::scope());
self.client.send_json(req).await
}
pub async fn create(&self, policy: &NewTokenPolicy) -> Result<TokenPolicy> {
let req = self
.client
.request(Method::POST, self.collection_url(), Self::scope())
.json(policy)?;
self.client.send_json(req).await
}
pub async fn get(&self, policy_id: Uuid) -> Result<TokenPolicy> {
let req = self
.client
.request(Method::GET, self.item_url(policy_id), Self::scope());
self.client.send_json(req).await
}
pub async fn try_get(&self, policy_id: Uuid) -> Result<Option<TokenPolicy>> {
let req = self
.client
.request(Method::GET, self.item_url(policy_id), Self::scope());
self.client.send_json_opt(req).await
}
pub async fn patch(&self, policy_id: Uuid, patch: &TokenPolicyPatch) -> Result<TokenPolicy> {
let req = self
.client
.request(Method::PATCH, self.item_url(policy_id), Self::scope())
.json(patch)?;
self.client.send_json(req).await
}
pub async fn replace(&self, policy_id: Uuid, policy: &NewTokenPolicy) -> Result<TokenPolicy> {
let req = self
.client
.request(Method::PUT, self.item_url(policy_id), Self::scope())
.json(policy)?;
self.client.send_json(req).await
}
pub async fn delete(&self, policy_id: Uuid) -> Result<()> {
let req = self
.client
.request(Method::DELETE, self.item_url(policy_id), Self::scope());
self.client.send_empty(req).await
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn json<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializes")
}
#[test]
fn create_can_express_domain_permissions() {
let update = TokenUpdate::new()
.name("provisioning")
.perm_create_domain(true)
.perm_delete_domain(true);
assert_eq!(
json(&update),
r#"{"name":"provisioning","perm_create_domain":true,"perm_delete_domain":true}"#
);
}
#[test]
fn an_empty_update_sends_an_empty_object() {
assert_eq!(json(&TokenUpdate::new()), "{}");
}
#[test]
fn durations_distinguish_leaving_alone_from_clearing() {
assert_eq!(json(&TokenUpdate::new()), "{}");
assert_eq!(
json(&TokenUpdate::new().max_age(DjangoDuration::days(7))),
r#"{"max_age":"7 00:00:00"}"#
);
assert_eq!(
json(&TokenUpdate::new().clear_max_age()),
r#"{"max_age":null}"#
);
assert_eq!(
json(&TokenUpdate::new().clear_max_unused_period()),
r#"{"max_unused_period":null}"#
);
}
#[test]
fn revoking_write_permission_sends_false() {
assert_eq!(
json(&TokenPolicyPatch::new().perm_write(false)),
r#"{"perm_write":false}"#
);
}
#[test]
fn a_policy_patch_touching_only_the_domain_leaves_perm_write_alone() {
let patch = TokenPolicyPatch::new().domain("example.com");
assert_eq!(json(&patch), r#"{"domain":"example.com"}"#);
assert!(!json(&patch).contains("perm_write"));
}
#[test]
fn widening_a_policy_selector_sends_null() {
assert_eq!(
json(&TokenPolicyPatch::new().any_domain()),
r#"{"domain":null}"#
);
}
#[test]
fn the_default_policy_sends_three_nulls() {
assert_eq!(
json(&NewTokenPolicy::default_policy(true)),
r#"{"domain":null,"subname":null,"type":null,"perm_write":true}"#
);
}
#[test]
fn a_domain_scoped_policy_keeps_the_wildcard_selectors() {
let policy = NewTokenPolicy::for_domain("example.com", true).record_type(RecordType::TXT);
assert_eq!(
json(&policy),
r#"{"domain":"example.com","subname":null,"type":"TXT","perm_write":true}"#
);
}
#[test]
fn recognizes_the_default_policy_in_a_response() {
let body = r#"{
"id": "7aed3f71-bc81-4f7e-90ae-8f0df0d1c211",
"domain": null,
"subname": null,
"type": null,
"perm_write": true
}"#;
let policy: TokenPolicy = serde_json::from_str(body).expect("valid policy");
assert!(policy.is_default());
}
#[test]
fn deserializes_a_login_token_including_its_secret() {
let body = r#"{
"id": "f7ab039b-07b8-493d-ac61-4ddcf903d4de",
"created": "2022-09-06T16:23:24.585329Z",
"last_used": null,
"owner": "you@example.com",
"user_override": null,
"mfa": false,
"max_age": "7 00:00:00",
"max_unused_period": "01:00:00",
"name": "",
"perm_create_domain": true,
"perm_delete_domain": true,
"perm_manage_tokens": true,
"allowed_subnets": ["0.0.0.0/0", "::/0"],
"auto_policy": false,
"is_valid": true,
"token": "i-T3b1h_OI-H9ab8tRS98stGtURe"
}"#;
let token: Token = serde_json::from_str(body).expect("valid token");
assert_eq!(token.mfa, Some(false));
assert_eq!(token.max_age, Some(DjangoDuration::days(7)));
assert_eq!(token.max_unused_period, Some(DjangoDuration::hours(1)));
assert_eq!(
token.token.as_ref().map(Secret::expose),
Some("i-T3b1h_OI-H9ab8tRS98stGtURe")
);
assert!(!format!("{token:?}").contains("i-T3b1h"));
}
#[test]
fn deserializes_an_api_token_without_a_secret() {
let body = r#"{
"id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3",
"created": "2018-09-06T09:08:43.762697Z",
"last_used": null,
"owner": "you@example.com",
"user_override": null,
"mfa": null,
"max_age": null,
"max_unused_period": null,
"name": "my token",
"perm_create_domain": false,
"perm_delete_domain": false,
"perm_manage_tokens": false,
"allowed_subnets": ["0.0.0.0/0", "::/0"],
"auto_policy": false,
"is_valid": true
}"#;
let token: Token = serde_json::from_str(body).expect("valid token");
assert!(token.token.is_none());
assert_eq!(token.mfa, None, "null mfa marks an API token");
}
}