use hyper::body::Incoming;
use hyper::{Request, StatusCode};
use super::Api;
use super::reply::HttpError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Actor {
pub(super) tenant: Option<String>,
pub(super) deploy: bool,
}
const TENANT_HEADER: &str = "x-zygo-tenant";
impl Actor {
pub(super) fn tenant(&self) -> Option<&str> {
self.tenant.as_deref()
}
pub(super) fn is_operator(&self) -> bool {
self.tenant.is_none()
}
pub(super) fn operator_only(&self, what: &str) -> Result<(), HttpError> {
match &self.tenant {
None => Ok(()),
Some(id) => Err(HttpError::new(
StatusCode::FORBIDDEN,
format!("{what} is the operator's, and this request acts for tenant `{id}`"),
)),
}
}
pub(super) fn may_deploy(&self) -> Result<(), HttpError> {
if self.deploy {
return self.operator_only("deploying");
}
Err(HttpError::new(
StatusCode::FORBIDDEN,
match &self.tenant {
Some(id) => format!(
"serving, stopping and running are the operator's, and this \
request acts for tenant `{id}`\n \
→ a tenant token registers scripts and calls; it does not \
name images, mounts or commands"
),
None => "this API may only call functions that are already served\n \
→ start it with `zygo api --allow-deploy`, or present an \
operator token minted with `zygo token mint`, to let callers \
serve, stop and run — which is running arbitrary code as the \
user it runs as"
.to_string(),
},
))
}
}
pub(super) fn authorise(req: &Request<Incoming>, api: &Api) -> Result<Actor, HttpError> {
let header = || -> Result<Option<String>, HttpError> {
let Some(value) = req.headers().get(TENANT_HEADER) else {
return Ok(None);
};
let id = value
.to_str()
.map_err(|_| {
HttpError::closing(
StatusCode::BAD_REQUEST,
"X-Zygo-Tenant must be ASCII: it is an id, not a name",
)
})?
.trim();
zygo_core::tenants::valid_id(id)
.map_err(|e| HttpError::closing(StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Some(id.to_string()))
};
let Some(bootstrap) = &api.token else {
return Ok(Actor {
tenant: header()?,
deploy: api.deploy,
});
};
let presented = req
.headers()
.get(hyper::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.ok_or_else(|| HttpError::closing(StatusCode::UNAUTHORIZED, "missing bearer token"))?;
if constant_time_eq(presented.as_bytes(), bootstrap.as_bytes()) {
return Ok(Actor {
tenant: header()?,
deploy: api.deploy,
});
}
let token = zygo_core::tokens::Tokens::new(&api.paths)
.resolve(presented)
.map_err(|e| HttpError::closing(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?
.ok_or_else(|| {
HttpError::closing(
StatusCode::UNAUTHORIZED,
"wrong or revoked bearer token".to_string(),
)
})?;
match token.tenant() {
None => Ok(Actor {
tenant: header()?,
deploy: true,
}),
Some(id) => {
if let Some(named) = header()?
&& named != id
{
return Err(HttpError::closing(
StatusCode::FORBIDDEN,
format!(
"this token is tenant `{id}`'s and the request names `{named}`\n \
→ drop the X-Zygo-Tenant header; only an operator token \
may act for another tenant"
),
));
}
Ok(Actor {
tenant: Some(id.to_string()),
deploy: false,
})
}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
const ROUTES_THAT_NAME_A_HOST_PATH: &[&str] = &[
"PUT /fn/<name>", "POST /runtimes", "POST /run", ];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_comparison_does_not_depend_on_where_the_difference_is() {
assert!(constant_time_eq(b"secret", b"secret"));
assert!(!constant_time_eq(b"secret", b"secrex"));
assert!(!constant_time_eq(b"secret", b"xecret"));
assert!(!constant_time_eq(b"secret", b"secre"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn deploy_is_off_until_it_is_asked_for() {
let operator = |deploy| Actor {
tenant: None,
deploy,
};
assert!(operator(true).may_deploy().is_ok());
let refused = operator(false)
.may_deploy()
.expect_err("a call-only API refuses");
assert_eq!(refused.status, StatusCode::FORBIDDEN);
assert!(
refused.body["error"]
.as_str()
.expect("a message")
.contains("--allow-deploy"),
"the refusal has to name the flag: {:?}",
refused.body
);
}
#[test]
fn nothing_that_names_a_host_path_is_reachable_by_a_tenant() {
let tenant = Actor {
tenant: Some("acme".into()),
deploy: true,
};
for route in ROUTES_THAT_NAME_A_HOST_PATH {
let refused = tenant
.may_deploy()
.expect_err(&format!("{route} was allowed"));
assert_eq!(refused.status, StatusCode::FORBIDDEN, "{route}");
}
}
#[test]
fn a_tenant_never_deploys_however_the_api_was_started() {
for deploy in [true, false] {
let refused = Actor {
tenant: Some("acme".into()),
deploy,
}
.may_deploy()
.expect_err("a tenant cannot deploy");
assert_eq!(refused.status, StatusCode::FORBIDDEN);
let message = refused.body["error"].as_str().expect("a message");
assert!(message.contains("acme"), "{message}");
assert!(
!message.contains("--allow-deploy"),
"a tenant cannot act on that advice: {message}"
);
}
}
}