use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use jsonwebtoken::{encode, EncodingKey, Header};
use zlayer_types::jwt::Claims;
use zlayer_types::storage::{PermissionLevel, StoredAccessToken, TokenScope};
#[async_trait]
pub trait ContainerTokenSink: Send + Sync + std::fmt::Debug {
async fn persist(&self, record: StoredAccessToken) -> bool;
async fn revoke(&self, jti: &str);
}
#[async_trait]
pub trait DockerSocketSpawner: Send + Sync + std::fmt::Debug {
async fn spawn(&self, container_key: &str, token: String) -> Option<String>;
async fn teardown(&self, container_key: &str);
}
#[async_trait]
pub trait DeploymentDigestSink: Send + Sync + std::fmt::Debug {
async fn record(&self, deployment: &str, service: &str, digest: &str);
}
pub const LABEL_DAEMON_SOCKET: &str = "zlayer.io/daemon-socket";
pub const LABEL_API_TOKEN_TTL: &str = "zlayer.io/api-token-ttl";
pub const LABEL_API_SCOPES: &str = "zlayer.io/api-scopes";
pub const DEFAULT_CONTAINER_TOKEN_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone)]
pub struct ContainerApiAccess {
pub scopes: Vec<TokenScope>,
pub ttl: Duration,
pub mount_socket: bool,
}
fn parse_scope(s: &str) -> Option<TokenScope> {
let parts: Vec<&str> = s.split(':').collect();
let (kind, id, level) = match parts.as_slice() {
[kind, level] => (*kind, None, *level),
[kind, id, level] => (
*kind,
if *id == "*" || id.is_empty() {
None
} else {
Some((*id).to_string())
},
*level,
),
_ => return None,
};
if kind.is_empty() {
return None;
}
let level = match level.to_ascii_lowercase().as_str() {
"none" => PermissionLevel::None,
"read" => PermissionLevel::Read,
"execute" => PermissionLevel::Execute,
"write" => PermissionLevel::Write,
_ => return None,
};
Some(TokenScope::new(kind, id, level))
}
#[must_use]
pub fn resolve_container_api_access<S: std::hash::BuildHasher>(
deployment: &str,
labels: &HashMap<String, String, S>,
) -> ContainerApiAccess {
let scopes = match labels.get(LABEL_API_SCOPES) {
Some(raw) if !raw.trim().is_empty() => {
let parsed: Vec<TokenScope> = raw
.split(',')
.filter_map(|s| parse_scope(s.trim()))
.collect();
if parsed.is_empty() {
default_scopes_for(deployment)
} else {
parsed
}
}
_ => default_scopes_for(deployment),
};
let ttl = labels
.get(LABEL_API_TOKEN_TTL)
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|secs| *secs > 0)
.unwrap_or(DEFAULT_CONTAINER_TOKEN_TTL_SECS);
let mount_socket = labels
.get(LABEL_DAEMON_SOCKET)
.is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
ContainerApiAccess {
scopes,
ttl: Duration::from_secs(ttl),
mount_socket,
}
}
#[must_use]
pub fn default_scopes_for(deployment: &str) -> Vec<TokenScope> {
let mut scopes = default_container_scopes(deployment);
scopes.push(TokenScope::new("container", None, PermissionLevel::Write));
scopes.push(TokenScope::new("image", None, PermissionLevel::Write));
scopes
}
#[must_use]
pub fn default_container_scopes(deployment: &str) -> Vec<TokenScope> {
vec![TokenScope::new(
"deployment",
Some(deployment.to_string()),
PermissionLevel::Read,
)]
}
pub fn mint_container_token(
secret: &str,
service_name: &str,
container_id: &str,
scopes: Vec<TokenScope>,
ttl: Duration,
jti: Option<String>,
) -> Result<String, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?;
let claims = Claims {
sub: format!("container:{service_name}:{container_id}"),
iat: now.as_secs(),
exp: (now + ttl).as_secs(),
iss: "zlayer".to_string(),
roles: vec!["container".to_string()],
email: None,
node_id: None,
scopes,
jti,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_access_includes_docker_scopes() {
let access = resolve_container_api_access("myapp", &HashMap::new());
assert!(access.scopes.iter().any(|s| s.resource_kind == "deployment"
&& s.resource_id.as_deref() == Some("myapp")
&& s.level == PermissionLevel::Read));
assert!(access.scopes.iter().any(|s| s.resource_kind == "container"
&& s.resource_id.is_none()
&& s.level == PermissionLevel::Write));
assert!(access.scopes.iter().any(|s| s.resource_kind == "image"
&& s.resource_id.is_none()
&& s.level == PermissionLevel::Write));
assert_eq!(
access.ttl,
Duration::from_secs(DEFAULT_CONTAINER_TOKEN_TTL_SECS)
);
assert!(!access.mount_socket);
}
#[test]
fn explicit_scopes_replace_defaults() {
let mut labels = HashMap::new();
labels.insert(
LABEL_API_SCOPES.to_string(),
"deployment:foo:read".to_string(),
);
let access = resolve_container_api_access("foo", &labels);
assert_eq!(access.scopes.len(), 1);
assert_eq!(access.scopes[0].resource_kind, "deployment");
}
#[test]
fn labels_override_scopes_ttl_and_socket() {
let mut labels = HashMap::new();
labels.insert(
LABEL_API_SCOPES.to_string(),
"deployment:foo:write, job:build:execute, container:*:read".to_string(),
);
labels.insert(LABEL_API_TOKEN_TTL.to_string(), "3600".to_string());
labels.insert(LABEL_DAEMON_SOCKET.to_string(), "true".to_string());
let access = resolve_container_api_access("foo", &labels);
assert_eq!(access.scopes.len(), 3);
assert_eq!(access.scopes[0].level, PermissionLevel::Write);
assert_eq!(access.scopes[1].resource_kind, "job");
assert_eq!(access.scopes[1].resource_id.as_deref(), Some("build"));
assert_eq!(access.scopes[2].resource_id, None); assert_eq!(access.ttl, Duration::from_secs(3600));
assert!(access.mount_socket);
}
#[test]
fn malformed_scopes_fall_back_to_default() {
let mut labels = HashMap::new();
labels.insert(LABEL_API_SCOPES.to_string(), "garbage::::".to_string());
let access = resolve_container_api_access("d", &labels);
assert_eq!(access.scopes, default_scopes_for("d"));
}
#[test]
fn invalid_ttl_falls_back_to_default() {
let mut labels = HashMap::new();
labels.insert(LABEL_API_TOKEN_TTL.to_string(), "notanumber".to_string());
let access = resolve_container_api_access("d", &labels);
assert_eq!(
access.ttl,
Duration::from_secs(DEFAULT_CONTAINER_TOKEN_TTL_SECS)
);
}
}