use std::fmt;
use async_trait::async_trait;
use axum::http::HeaderMap;
use axum::http::header::AUTHORIZATION;
use secrecy::{ExposeSecret, SecretString};
use crate::desired_state::{Actor, MutationKind, ResourceScope};
use crate::principals::constant_time_eq;
pub const INFERENCE_TOKEN_PREFIX: &str = "axt1.";
pub const INFERENCE_KEY_HEADER: &str = "x-api-key";
pub const BREAKGLASS_OPERATOR_HEADER: &str = "x-axond-breakglass-operator";
pub const BREAKGLASS_REASON_HEADER: &str = "x-axond-breakglass-reason";
#[derive(Clone)]
pub struct AdminCredential(SecretString);
impl fmt::Debug for AdminCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("AdminCredential(redacted)")
}
}
impl AdminCredential {
pub fn from_headers(headers: &HeaderMap) -> Result<Self, AdminAuthError> {
let bearer = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty());
match bearer {
Some(value) if value.starts_with(INFERENCE_TOKEN_PREFIX) => {
Err(AdminAuthError::InferenceCredential)
}
Some(value) => Ok(Self(SecretString::from(value.to_owned()))),
None if headers.contains_key(INFERENCE_KEY_HEADER) => {
Err(AdminAuthError::InferenceCredential)
}
None => Err(AdminAuthError::MissingCredential),
}
}
pub fn new(material: impl Into<String>) -> Self {
Self(SecretString::from(material.into()))
}
pub fn matches(&self, expected: &SecretString) -> bool {
constant_time_eq(
self.0.expose_secret().as_bytes(),
expected.expose_secret().as_bytes(),
)
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BreakglassAttribution {
operator: String,
reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum InvalidAttribution {
#[error("breakglass use must name an operator and a reason")]
Missing,
#[error("breakglass attribution is over the {max}-character limit")]
TooLong { max: usize },
#[error("breakglass attribution must be printable ASCII")]
Unprintable,
}
impl BreakglassAttribution {
pub const MAX_LEN: usize = 200;
pub fn parse(operator: &str, reason: &str) -> Result<Self, InvalidAttribution> {
let operator = operator.trim();
let reason = reason.trim();
if operator.is_empty() || reason.is_empty() {
return Err(InvalidAttribution::Missing);
}
for field in [operator, reason] {
if field.len() > Self::MAX_LEN {
return Err(InvalidAttribution::TooLong { max: Self::MAX_LEN });
}
if !field
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err(InvalidAttribution::Unprintable);
}
}
Ok(Self {
operator: operator.to_owned(),
reason: reason.to_owned(),
})
}
pub fn operator(&self) -> &str {
&self.operator
}
pub fn reason(&self) -> &str {
&self.reason
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminIdentity {
Human { issuer: String, subject: String },
Breakglass {
attribution: BreakglassAttribution,
credential: String,
},
}
impl AdminIdentity {
pub fn actor(&self) -> Actor {
match self {
Self::Human { issuer, subject } => Actor::Human {
issuer: issuer.clone(),
subject: subject.clone(),
},
Self::Breakglass { .. } => Actor::Breakglass,
}
}
pub fn audit_summary(&self, summary: &str) -> String {
match self {
Self::Human { .. } => summary.to_owned(),
Self::Breakglass {
attribution,
credential,
} => format!(
"breakglass {} as `{}` ({}): {summary}",
attribution.operator(),
credential,
attribution.reason()
),
}
}
pub const fn is_breakglass(&self) -> bool {
matches!(self, Self::Breakglass { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AdminAction {
ReadState,
ReadHistory,
ReadAudit,
ReadConvergence,
Publish,
Rollback,
}
impl AdminAction {
pub const ALL: &'static [Self] = &[
Self::ReadState,
Self::ReadHistory,
Self::ReadAudit,
Self::ReadConvergence,
Self::Publish,
Self::Rollback,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::ReadState => "read_state",
Self::ReadHistory => "read_history",
Self::ReadAudit => "read_audit",
Self::ReadConvergence => "read_convergence",
Self::Publish => "publish",
Self::Rollback => "rollback",
}
}
pub const fn mutates(self) -> bool {
matches!(self, Self::Publish | Self::Rollback)
}
pub const fn for_mutation(kind: MutationKind) -> Self {
match kind {
MutationKind::Rollback => Self::Rollback,
MutationKind::Create
| MutationKind::Update
| MutationKind::Delete
| MutationKind::Rotate => Self::Publish,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminGrant {
identity: AdminIdentity,
action: AdminAction,
scope: ResourceScope,
}
impl AdminGrant {
pub fn granted(identity: AdminIdentity, action: AdminAction, scope: ResourceScope) -> Self {
Self {
identity,
action,
scope,
}
}
pub const fn identity(&self) -> &AdminIdentity {
&self.identity
}
pub const fn action(&self) -> AdminAction {
self.action
}
pub const fn scope(&self) -> &ResourceScope {
&self.scope
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AdminAuthError {
#[error("no administrative credential was presented")]
MissingCredential,
#[error("an inference credential carries no administrative authority")]
InferenceCredential,
#[error("the presented administrative credential was not recognized")]
UnknownCredential,
#[error("the presented OIDC token was not accepted")]
TokenRejected,
#[error("issuer `{issuer}` is not trusted for administration")]
UntrustedIssuer { issuer: String },
#[error("the identity provider could not be consulted")]
IdentityProviderUnavailable,
#[error(transparent)]
Attribution(#[from] InvalidAttribution),
#[error("this identity may not {}", action.as_str())]
ActionNotPermitted { action: AdminAction },
#[error("this identity may not act on that scope")]
ScopeNotPermitted,
}
impl AdminAuthError {
pub const fn is_authorization(&self) -> bool {
matches!(
self,
Self::ActionNotPermitted { .. } | Self::ScopeNotPermitted
)
}
pub const fn is_unavailable(&self) -> bool {
matches!(self, Self::IdentityProviderUnavailable)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PresentedAttribution {
Absent,
Invalid(InvalidAttribution),
Present(BreakglassAttribution),
}
impl PresentedAttribution {
pub fn from_headers(headers: &HeaderMap) -> Self {
let field = |name: &str| headers.get(name).map(|value| value.to_str().ok());
match (
field(BREAKGLASS_OPERATOR_HEADER),
field(BREAKGLASS_REASON_HEADER),
) {
(None, None) => Self::Absent,
(Some(None), _) | (_, Some(None)) => Self::Invalid(InvalidAttribution::Unprintable),
(operator, reason) => {
fn text(value: Option<Option<&str>>) -> &str {
value.flatten().unwrap_or("")
}
match BreakglassAttribution::parse(text(operator), text(reason)) {
Ok(attribution) => Self::Present(attribution),
Err(error) => Self::Invalid(error),
}
}
}
}
pub fn require(&self) -> Result<BreakglassAttribution, AdminAuthError> {
match self {
Self::Present(attribution) => Ok(attribution.clone()),
Self::Invalid(error) => Err(AdminAuthError::Attribution(*error)),
Self::Absent => Err(AdminAuthError::Attribution(InvalidAttribution::Missing)),
}
}
}
#[derive(Debug, Clone)]
pub struct AdminPresented {
pub credential: AdminCredential,
pub attribution: PresentedAttribution,
}
impl AdminPresented {
pub fn from_headers(headers: &HeaderMap) -> Result<Self, AdminAuthError> {
Ok(Self {
credential: AdminCredential::from_headers(headers)?,
attribution: PresentedAttribution::from_headers(headers),
})
}
}
#[async_trait]
pub trait AdminAuthenticator: Send + Sync {
fn name(&self) -> &'static str;
async fn authenticate(
&self,
presented: &AdminPresented,
) -> Result<AdminIdentity, AdminAuthError>;
}
pub trait AdminAuthorizer: Send + Sync {
fn name(&self) -> &'static str;
fn authorize(
&self,
identity: &AdminIdentity,
action: AdminAction,
scope: &ResourceScope,
) -> Result<AdminGrant, AdminAuthError>;
}