use serde::de::{SeqAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::sync::Arc;
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Read,
Write,
Admin,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum CollectionScope {
#[default]
All,
Patterns(Vec<String>),
}
impl CollectionScope {
fn from_patterns(patterns: Vec<String>) -> Self {
if patterns.is_empty() || patterns.iter().any(|p| p == "*") {
CollectionScope::All
} else {
CollectionScope::Patterns(patterns)
}
}
#[must_use]
pub fn matches(&self, collection: &str) -> bool {
match self {
CollectionScope::All => true,
CollectionScope::Patterns(patterns) => {
patterns.iter().any(|p| pattern_matches(p, collection))
}
}
}
}
fn pattern_matches(pattern: &str, name: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => pattern == name,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ApiKey {
pub secret: String,
pub role: Action,
pub collections: CollectionScope,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}
impl ApiKey {
#[must_use]
pub fn admin(secret: impl Into<String>) -> Self {
Self {
secret: secret.into(),
role: Action::Admin,
collections: CollectionScope::All,
id: None,
}
}
pub(crate) fn actor_id(&self) -> String {
match &self.id {
Some(id) => id.clone(),
None => format!("key:{}", secret_fingerprint(&self.secret)),
}
}
}
fn secret_fingerprint(secret: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(secret.as_bytes());
digest[..8].iter().map(|b| format!("{b:02x}")).collect()
}
impl From<&str> for ApiKey {
fn from(secret: &str) -> Self {
ApiKey::admin(secret)
}
}
impl From<String> for ApiKey {
fn from(secret: String) -> Self {
ApiKey::admin(secret)
}
}
#[derive(Debug, Clone)]
pub(crate) struct Principal {
role: Action,
collections: CollectionScope,
actor: Arc<str>,
}
impl Principal {
pub(crate) fn insecure() -> Self {
Self {
role: Action::Admin,
collections: CollectionScope::All,
actor: Arc::from("insecure"),
}
}
fn from_key(key: &ApiKey) -> Self {
Self {
role: key.role,
collections: key.collections.clone(),
actor: Arc::from(key.actor_id()),
}
}
pub(crate) fn actor(&self) -> &str {
&self.actor
}
pub(crate) fn require(&self, action: Action, collection: Option<&str>) -> Result<(), Error> {
let role_ok = self.role >= action;
let scope_ok = collection.is_none_or(|c| self.collections.matches(c));
if role_ok && scope_ok {
Ok(())
} else {
Err(Error::Forbidden(
"the API key's scope does not permit this operation".to_owned(),
))
}
}
pub(crate) fn can_see(&self, collection: &str) -> bool {
self.collections.matches(collection)
}
}
pub(crate) fn authenticate(keys: &[ApiKey], presented: Option<&str>) -> Option<Principal> {
if keys.is_empty() {
return Some(Principal::insecure());
}
let token = presented?;
let mut matched: Option<&ApiKey> = None;
for key in keys {
if constant_time_eq(key.secret.as_bytes(), token.as_bytes()) {
matched = Some(key);
}
}
matched.map(Principal::from_key)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
impl<'de> Deserialize<'de> for CollectionScope {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let patterns = Vec::<String>::deserialize(deserializer)?;
Ok(CollectionScope::from_patterns(patterns))
}
}
impl Serialize for CollectionScope {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
CollectionScope::All => ["*"].serialize(serializer),
CollectionScope::Patterns(patterns) => patterns.serialize(serializer),
}
}
}
#[derive(Deserialize)]
struct KeySpec {
secret: String,
role: Action,
#[serde(default)]
collections: CollectionScope,
#[serde(default)]
id: Option<String>,
}
pub(crate) fn de_api_keys<'de, D>(deserializer: D) -> Result<Vec<ApiKey>, D::Error>
where
D: Deserializer<'de>,
{
struct KeysVisitor;
impl<'de> Visitor<'de> for KeysVisitor {
type Value = Vec<ApiKey>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a comma-separated string of secrets, or a list of secrets/key tables")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ApiKey::admin)
.collect())
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Entry {
Plain(String),
Structured(KeySpec),
}
let mut keys = Vec::new();
while let Some(entry) = seq.next_element::<Entry>()? {
keys.push(match entry {
Entry::Plain(secret) => ApiKey::admin(secret),
Entry::Structured(spec) => ApiKey {
secret: spec.secret,
role: spec.role,
collections: spec.collections,
id: spec.id,
},
});
}
Ok(keys)
}
}
deserializer.deserialize_any(KeysVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_ordering_implies_lesser_privileges() {
assert!(Action::Admin > Action::Write);
assert!(Action::Write > Action::Read);
}
#[test]
fn collection_scope_matches_exact_prefix_and_all() {
let all = CollectionScope::from_patterns(vec!["*".to_owned()]);
assert!(matches!(all, CollectionScope::All));
assert!(all.matches("anything"));
let scoped = CollectionScope::from_patterns(vec!["acme.*".to_owned(), "shared".to_owned()]);
assert!(scoped.matches("acme.orders"));
assert!(scoped.matches("shared"));
assert!(!scoped.matches("beta.orders"));
assert!(!scoped.matches("acme")); assert!(!scoped.matches("shared2"));
}
#[test]
fn require_enforces_role_and_scope() {
let reader = Principal {
role: Action::Read,
collections: CollectionScope::Patterns(vec!["acme.*".to_owned()]),
actor: Arc::from("reader"),
};
assert!(reader.require(Action::Read, Some("acme.orders")).is_ok());
assert!(reader.require(Action::Write, Some("acme.orders")).is_err());
assert!(reader.require(Action::Read, Some("beta.orders")).is_err());
assert!(reader.require(Action::Read, None).is_ok());
assert!(reader.can_see("acme.orders"));
assert!(!reader.can_see("beta.orders"));
}
#[test]
fn insecure_principal_is_admin_over_all() {
let p = Principal::insecure();
assert!(p.require(Action::Admin, Some("anything")).is_ok());
assert!(p.can_see("anything"));
assert_eq!(p.actor(), "insecure");
}
#[test]
fn actor_id_uses_a_label_or_a_fingerprint_but_never_the_secret() {
let labeled = ApiKey {
id: Some("ci-admin".to_owned()),
..ApiKey::admin("super-secret")
};
assert_eq!(labeled.actor_id(), "ci-admin");
assert_eq!(Principal::from_key(&labeled).actor(), "ci-admin");
let bare = ApiKey::admin("super-secret");
let id = bare.actor_id();
assert!(id.starts_with("key:"));
assert!(
!id.contains("super-secret"),
"the fingerprint must not contain the secret"
);
assert_eq!(id, ApiKey::admin("super-secret").actor_id());
assert_ne!(id, ApiKey::admin("other-secret").actor_id());
}
#[test]
fn authenticate_matches_secret_and_denies_others() {
let keys = vec![
ApiKey::admin("admin-secret"),
ApiKey {
secret: "reader-secret".to_owned(),
role: Action::Read,
collections: CollectionScope::Patterns(vec!["acme.*".to_owned()]),
id: None,
},
];
assert!(authenticate(&[], None).is_some());
let reader = authenticate(&keys, Some("reader-secret")).expect("reader authenticates");
assert!(reader.require(Action::Write, Some("acme.x")).is_err());
assert!(authenticate(&keys, Some("nope")).is_none());
assert!(authenticate(&keys, None).is_none());
}
#[test]
fn de_api_keys_parses_csv_strings_and_structured_tables() {
#[derive(Deserialize)]
struct Wrap {
#[serde(deserialize_with = "de_api_keys")]
api_keys: Vec<ApiKey>,
}
let csv: Wrap = serde_json::from_str(r#"{"api_keys":"a, b ,c"}"#).unwrap();
assert_eq!(csv.api_keys.len(), 3);
assert!(csv.api_keys.iter().all(|k| k.role == Action::Admin));
assert_eq!(csv.api_keys[1].secret, "b");
let mixed: Wrap = serde_json::from_str(
r#"{"api_keys":["root",{"secret":"ro","role":"read","collections":["acme.*"]}]}"#,
)
.unwrap();
assert_eq!(mixed.api_keys[0].role, Action::Admin);
assert!(matches!(
mixed.api_keys[0].collections,
CollectionScope::All
));
assert_eq!(mixed.api_keys[1].role, Action::Read);
assert!(mixed.api_keys[1].collections.matches("acme.x"));
assert!(!mixed.api_keys[1].collections.matches("beta.x"));
let defaulted: Wrap =
serde_json::from_str(r#"{"api_keys":[{"secret":"w","role":"write"}]}"#).unwrap();
assert!(matches!(
defaulted.api_keys[0].collections,
CollectionScope::All
));
}
}