use axum::body::Body;
use axum::extract::{MatchedPath, Request, State};
use axum::http::{Method, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use kanade_shared::feature::Feature;
use kanade_shared::secrets;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::env;
use std::sync::OnceLock;
use tracing::{error, warn};
use crate::http_error::ApiError;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Operator,
Admin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Operator => "operator",
Role::Admin => "admin",
}
}
pub fn parse(s: &str) -> Option<Role> {
match s {
"viewer" => Some(Role::Viewer),
"operator" => Some(Role::Operator),
"admin" => Some(Role::Admin),
_ => None,
}
}
pub fn allows(self, required: Role) -> bool {
self >= required
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Claims {
pub sub: String,
pub exp: i64,
#[serde(default)]
pub aud: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_features: Option<Vec<Feature>>,
}
impl Claims {
pub fn role(&self) -> Role {
self.roles
.iter()
.filter_map(|r| Role::parse(r))
.max()
.unwrap_or(Role::Viewer)
}
fn service(sub: &str) -> Self {
Claims {
sub: sub.to_string(),
exp: 4_102_444_800, aud: Some(EXPECTED_AUDIENCE.to_string()),
roles: vec![Role::Admin.as_str().to_string()],
allowed_features: None,
}
}
}
const ENV_DISABLE: &str = "KANADE_AUTH_DISABLE";
const ENV_STATIC_TOKEN: &str = "KANADE_AUTH_STATIC_TOKEN";
const ENV_SECRET: &str = "KANADE_JWT_SECRET";
const REG_SUBKEY: &str = r"SOFTWARE\kanade\backend";
const REG_STATIC_TOKEN: &str = "StaticToken";
const REG_JWT_SECRET: &str = "JwtSecret";
pub const EXPECTED_AUDIENCE: &str = "kanade";
fn resolve_static_token() -> Option<&'static str> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if let Some(t) = secrets::read_hklm_value(REG_SUBKEY, REG_STATIC_TOKEN) {
return Some(t);
}
match env::var(ENV_STATIC_TOKEN) {
Ok(t) if !t.is_empty() => Some(t),
_ => None,
}
})
.as_deref()
}
fn resolve_jwt_secret() -> Option<String> {
if let Some(s) = secrets::read_hklm_value(REG_SUBKEY, REG_JWT_SECRET) {
return Some(s);
}
match env::var(ENV_SECRET) {
Ok(s) if !s.is_empty() => Some(s),
_ => None,
}
}
pub fn signing_secret() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE.get_or_init(|| {
resolve_jwt_secret().unwrap_or_else(|| {
warn!(
"no JwtSecret registry value and no $KANADE_JWT_SECRET — using a hard-coded dev fallback (NEVER in production)"
);
"dev-secret-please-override".to_string()
})
})
}
struct UserAuth {
role: Role,
disabled: bool,
allowed_features: Option<Vec<Feature>>,
}
async fn lookup_user(pool: &SqlitePool, username: &str) -> Result<Option<UserAuth>, sqlx::Error> {
let row = sqlx::query_as::<_, (String, i64, Option<String>, Option<String>, Option<String>)>(
"SELECT u.role, u.disabled, u.allowed_features, g.features, u.permission_group \
FROM users u \
LEFT JOIN permission_groups g ON u.permission_group = g.name \
WHERE u.username = ?",
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(row.and_then(
|(role, disabled, allowed, group_features, permission_group)| {
Role::parse(&role).map(|role| {
let allowed_features = if permission_group.is_some() {
parse_allowed_features(group_features.as_deref())
.or_else(|| parse_allowed_features(allowed.as_deref()))
.or_else(|| Some(Vec::new()))
} else {
parse_allowed_features(allowed.as_deref())
};
UserAuth {
role,
disabled: disabled != 0,
allowed_features,
}
})
},
))
}
fn parse_allowed_features(raw: Option<&str>) -> Option<Vec<Feature>> {
let raw = raw?;
match serde_json::from_str::<Vec<String>>(raw) {
Ok(keys) => Some(keys.iter().filter_map(|k| Feature::parse(k)).collect()),
Err(e) => {
warn!(error = %e, "malformed allowed_features JSON; treating as unrestricted");
None
}
}
}
fn is_remote_ws_path(path: &str) -> bool {
path.strip_prefix("/api/remote/")
.and_then(|rest| rest.strip_suffix("/ws"))
.is_some_and(|pc_id| !pc_id.is_empty() && !pc_id.contains('/'))
}
pub async fn verify_bearer(pool: &SqlitePool, token: Option<&str>) -> Result<Claims, String> {
if env::var(ENV_DISABLE).is_ok() {
return Ok(Claims::service("auth-disabled"));
}
verify_token(pool, token).await
}
async fn verify_token(pool: &SqlitePool, token: Option<&str>) -> Result<Claims, String> {
let Some(token) = token.map(str::trim).filter(|t| !t.is_empty()) else {
return Err("missing bearer token".to_string());
};
if let Some(expected) = resolve_static_token()
&& constant_time_eq(token.as_bytes(), expected.as_bytes())
{
return Ok(Claims::service("service-token"));
}
let secret = signing_secret();
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience(&[EXPECTED_AUDIENCE]);
let claims = match decode::<Claims>(token, &key, &validation) {
Ok(data) => data.claims,
Err(e) => return Err(format!("invalid token: {e}")),
};
match lookup_user(pool, &claims.sub).await {
Ok(Some(user)) => {
if user.disabled {
return Err("account disabled".to_string());
}
let mut claims = claims;
claims.roles = vec![user.role.as_str().to_string()];
claims.allowed_features = user.allowed_features;
Ok(claims)
}
Ok(None) => Err("unknown account".to_string()),
Err(e) => {
error!(error = %e, sub = %claims.sub, "user lookup failed");
Err("auth backend unavailable".to_string())
}
}
}
pub async fn verify(
State(pool): State<SqlitePool>,
req: Request,
next: Next,
) -> Result<Response, ApiError> {
if env::var(ENV_DISABLE).is_ok() {
let mut req = req;
req.extensions_mut()
.insert(Claims::service("auth-disabled"));
return Ok(next.run(req).await);
}
let path = req.uri().path();
if !path.starts_with("/api/") {
return Ok(next.run(req).await);
}
if path == "/api/auth/login"
|| path == "/api/version"
|| path == "/api/auth/forgot-password"
|| path.starts_with("/api/auth/password-setup/")
{
return Ok(next.run(req).await);
}
if is_remote_ws_path(path) {
return Ok(next.run(req).await);
}
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
match verify_bearer(&pool, token).await {
Ok(claims) => {
let mut req = req;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
Err(reason) => {
warn!(path, reason, "auth rejected");
Err(unauth(&reason).into())
}
}
}
fn gate(req: &Request, required: Role) -> Option<Response> {
let Some(claims) = req.extensions().get::<Claims>().cloned() else {
return Some(forbidden("no authenticated identity"));
};
if claims.role().allows(required) {
None
} else {
Some(forbidden(&format!(
"{} role required (caller is {})",
required.as_str(),
claims.role().as_str()
)))
}
}
pub async fn require_operator(req: Request, next: Next) -> Result<Response, ApiError> {
if let Some(rejection) = gate(&req, Role::Operator) {
return Err(rejection.into());
}
Ok(next.run(req).await)
}
pub async fn require_admin(req: Request, next: Next) -> Result<Response, ApiError> {
if let Some(rejection) = gate(&req, Role::Admin) {
return Err(rejection.into());
}
Ok(next.run(req).await)
}
pub async fn require_features(req: Request, next: Next) -> Result<Response, ApiError> {
let denied: Option<String> = {
let ext = req.extensions();
feature_denial(
ext.get::<Claims>()
.and_then(|c| c.allowed_features.as_deref()),
req.method(),
ext.get::<MatchedPath>().map(|m| m.as_str()),
)
};
match denied {
None => Ok(next.run(req).await),
Some(msg) => Err(forbidden(&msg).into()),
}
}
const RESTRICTED_COMMONS: [&str; 7] = [
"/api/version",
"/api/command-signing",
"/api/auth/me",
"/api/auth/change-password",
"/api/auth/mfa/init",
"/api/auth/mfa/verify",
"/api/auth/mfa/disable",
];
pub(crate) fn feature_denial(
allowed: Option<&[Feature]>,
method: &Method,
matched_path: Option<&str>,
) -> Option<String> {
let allowed = allowed?;
let path = matched_path?;
if RESTRICTED_COMMONS.contains(&path) {
return None;
}
if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
if let Some(features) = crate::api::shared_owner_features(path) {
return if features.iter().any(|f| allowed.contains(f)) {
None
} else {
Some("account not permitted to access this route".to_string())
};
}
}
match crate::api::feature_for_path(path) {
Some(feature) if allowed.contains(&feature) => None,
Some(feature) => Some(format!(
"account not permitted to access this page (requires {})",
feature.as_str()
)),
None => Some("account not permitted to access this route".to_string()),
}
}
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.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn unauth(msg: &str) -> Response {
(StatusCode::UNAUTHORIZED, Body::from(msg.to_owned())).into_response()
}
fn forbidden(msg: &str) -> Response {
(StatusCode::FORBIDDEN, Body::from(msg.to_owned())).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn denied_read(allowed: Option<&[Feature]>, path: Option<&str>) -> Option<String> {
feature_denial(allowed, &Method::GET, path)
}
#[test]
fn role_hierarchy() {
assert!(Role::Admin.allows(Role::Operator));
assert!(Role::Admin.allows(Role::Viewer));
assert!(Role::Operator.allows(Role::Viewer));
assert!(!Role::Operator.allows(Role::Admin));
assert!(!Role::Viewer.allows(Role::Operator));
assert!(Role::Viewer.allows(Role::Viewer));
}
#[test]
fn role_roundtrip() {
for r in [Role::Viewer, Role::Operator, Role::Admin] {
assert_eq!(Role::parse(r.as_str()), Some(r));
}
assert_eq!(Role::parse("root"), None);
}
#[test]
fn jwt_hs256_roundtrip_does_not_panic() {
use jsonwebtoken::{EncodingKey, Header, encode};
let claims = Claims {
sub: "alice".into(),
exp: 4_102_444_800,
aud: Some(EXPECTED_AUDIENCE.to_string()),
roles: vec![Role::Admin.as_str().to_string()],
allowed_features: None,
};
let key = b"regression-secret";
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(key),
)
.expect("HS256 encode must not panic — pin a jsonwebtoken CryptoProvider feature");
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience(&[EXPECTED_AUDIENCE]);
let decoded = decode::<Claims>(&token, &DecodingKey::from_secret(key), &validation)
.expect("HS256 decode of our own token");
assert_eq!(decoded.claims.sub, "alice");
assert_eq!(decoded.claims.role(), Role::Admin);
}
#[test]
fn allowed_features_parse() {
assert!(parse_allowed_features(None).is_none());
let got = parse_allowed_features(Some(r#"["compliance","inventory","bogus"]"#)).unwrap();
assert_eq!(got, vec![Feature::Compliance, Feature::Inventory]);
assert_eq!(parse_allowed_features(Some("[]")), Some(vec![]));
assert!(parse_allowed_features(Some("not json")).is_none());
assert!(parse_allowed_features(Some(r#"{"x":1}"#)).is_none());
}
#[tokio::test]
async fn effective_features_group_precedence() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
sqlx::query(
"INSERT INTO permission_groups (name, features) VALUES ('sec', '[\"compliance\"]')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO permission_groups (name, features) VALUES ('locked', '[]')")
.execute(&pool)
.await
.unwrap();
let eff = |name: &'static str| {
let pool = pool.clone();
async move {
lookup_user(&pool, name)
.await
.unwrap()
.unwrap()
.allowed_features
}
};
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features, permission_group) VALUES ('g', 'x', 'viewer', '[\"audit\"]', 'sec')")
.execute(&pool).await.unwrap();
assert_eq!(eff("g").await, Some(vec![Feature::Compliance]));
sqlx::query("INSERT INTO users (username, password_hash, role, permission_group) VALUES ('l', 'x', 'viewer', 'locked')")
.execute(&pool).await.unwrap();
assert_eq!(eff("l").await, Some(vec![]));
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features) VALUES ('p', 'x', 'viewer', '[\"audit\"]')")
.execute(&pool).await.unwrap();
assert_eq!(eff("p").await, Some(vec![Feature::Audit]));
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features, permission_group) VALUES ('d', 'x', 'viewer', '[\"logs\"]', 'ghost')")
.execute(&pool).await.unwrap();
assert_eq!(eff("d").await, Some(vec![Feature::Logs]));
sqlx::query("INSERT INTO users (username, password_hash, role, permission_group) VALUES ('x', 'x', 'admin', 'ghost')")
.execute(&pool).await.unwrap();
assert_eq!(eff("x").await, Some(vec![]));
sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('u', 'x', 'admin')")
.execute(&pool)
.await
.unwrap();
assert_eq!(eff("u").await, None);
}
fn mint(sub: &str, roles: &[&str]) -> String {
use jsonwebtoken::{EncodingKey, Header, encode};
let claims = Claims {
sub: sub.into(),
exp: 4_102_444_800,
aud: Some(EXPECTED_AUDIENCE.to_string()),
roles: roles.iter().map(|r| r.to_string()).collect(),
allowed_features: None,
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(signing_secret().as_bytes()),
)
.expect("mint")
}
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn verify_token_rejects_absent_and_blank_credentials() {
let pool = test_pool().await;
for token in [None, Some(""), Some(" ")] {
assert_eq!(
verify_token(&pool, token).await.unwrap_err(),
"missing bearer token"
);
}
}
#[tokio::test]
async fn verify_token_rejects_a_bad_signature() {
let pool = test_pool().await;
let err = verify_token(&pool, Some("not.a.jwt")).await.unwrap_err();
assert!(err.starts_with("invalid token: "), "{err}");
}
#[tokio::test]
async fn verify_token_lets_the_db_override_the_token() {
let pool = test_pool().await;
sqlx::query(
"INSERT INTO users (username, password_hash, role, allowed_features) \
VALUES ('alice', 'x', 'viewer', '[\"audit\"]')",
)
.execute(&pool)
.await
.unwrap();
let claims = verify_token(&pool, Some(&mint("alice", &["admin"])))
.await
.expect("accepted");
assert_eq!(claims.role(), Role::Viewer);
assert_eq!(claims.allowed_features, Some(vec![Feature::Audit]));
assert_eq!(claims.sub, "alice");
}
#[tokio::test]
async fn verify_token_rejects_disabled_and_unknown_accounts() {
let pool = test_pool().await;
sqlx::query(
"INSERT INTO users (username, password_hash, role, disabled) \
VALUES ('bob', 'x', 'admin', 1)",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
verify_token(&pool, Some(&mint("bob", &["admin"])))
.await
.unwrap_err(),
"account disabled"
);
assert_eq!(
verify_token(&pool, Some(&mint("ghost", &["admin"])))
.await
.unwrap_err(),
"unknown account"
);
}
#[tokio::test]
async fn verify_token_trims_surrounding_whitespace() {
let pool = test_pool().await;
sqlx::query(
"INSERT INTO users (username, password_hash, role) VALUES ('carol', 'x', 'operator')",
)
.execute(&pool)
.await
.unwrap();
let padded = format!(" {}\t", mint("carol", &["operator"]));
let claims = verify_token(&pool, Some(&padded)).await.expect("accepted");
assert_eq!(claims.role(), Role::Operator);
}
#[test]
fn remote_ws_allow_list_is_exactly_one_segment() {
assert!(is_remote_ws_path("/api/remote/PC1234/ws"));
assert!(is_remote_ws_path("/api/remote/minipc/ws"));
assert!(!is_remote_ws_path("/api/remote//ws"));
assert!(!is_remote_ws_path("/api/remote/PC1/frames"));
assert!(!is_remote_ws_path("/api/remote/PC1/ws/extra"));
assert!(!is_remote_ws_path("/api/agents"));
assert!(!is_remote_ws_path("/api/remote/../accounts/ws"));
assert!(!is_remote_ws_path("/api/remote/a/b/ws"));
}
#[test]
fn claims_role_picks_highest() {
let c = Claims {
sub: "x".into(),
exp: 0,
aud: None,
roles: vec!["viewer".into(), "admin".into()],
allowed_features: None,
};
assert_eq!(c.role(), Role::Admin);
let none = Claims {
sub: "x".into(),
exp: 0,
aud: None,
roles: vec![],
allowed_features: None,
};
assert_eq!(none.role(), Role::Viewer);
}
#[test]
fn restricted_accounts_lose_commons_except_infrastructure() {
let download_user = [Feature::AgentInstall];
let restricted = Some(&download_user[..]);
assert!(denied_read(restricted, Some("/api/agents")).is_some());
assert!(denied_read(restricted, Some("/api/perf/fleet")).is_some());
assert!(denied_read(restricted, Some("/api/config/defaults")).is_some());
assert!(denied_read(restricted, Some("/api/something-new")).is_some());
for path in [
"/api/version",
"/api/command-signing",
"/api/auth/me",
"/api/auth/change-password",
"/api/auth/mfa/init",
"/api/auth/mfa/verify",
"/api/auth/mfa/disable",
] {
assert_eq!(denied_read(restricted, Some(path)), None, "{path}");
}
}
#[test]
fn restricted_accounts_keep_only_their_features() {
let download_user = [Feature::AgentInstall];
let restricted = Some(&download_user[..]);
assert_eq!(denied_read(restricted, Some("/api/agents/installer")), None);
let denied = denied_read(restricted, Some("/api/audit")).expect("denied");
assert!(denied.contains("audit"), "{denied}");
assert!(denied_read(restricted, Some("/api/agents/releases")).is_some());
assert!(denied_read(Some(&[]), Some("/api/agents")).is_some());
assert_eq!(denied_read(Some(&[]), Some("/api/auth/me")), None);
}
#[test]
fn any_embedding_page_feature_unlocks_the_shared_routes() {
let shared: &[(&str, &[Feature])] = &[
(
"/api/agents",
&[
Feature::Run,
Feature::Exec,
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Logs,
Feature::Analytics,
Feature::Notifications,
Feature::Rollout,
Feature::Config,
],
),
(
"/api/groups",
&[
Feature::Groups,
Feature::Config,
Feature::Exec,
Feature::Rollout,
],
),
("/api/jobs", &[Feature::Jobs, Feature::Exec]),
(
"/api/agents/meta-keys",
&[
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Compliance,
Feature::Collect,
],
),
(
"/api/agents/meta",
&[
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Compliance,
Feature::Collect,
],
),
];
for (path, owners) in shared {
for feature in *owners {
let only = [*feature];
assert_eq!(
denied_read(Some(&only[..]), Some(path)),
None,
"{path} should be open to {feature:?}-only"
);
}
}
let events_only = [Feature::Events];
assert_eq!(
denied_read(Some(&events_only[..]), Some("/api/obs_events")),
None
);
for feature in [Feature::Audit, Feature::Jetstream, Feature::Settings] {
let only = [feature];
let restricted = Some(&only[..]);
for (path, _) in shared {
assert!(
denied_read(restricted, Some(path)).is_some(),
"{path} should stay closed to {feature:?}-only"
);
}
}
let config_only = [Feature::Config];
assert!(denied_read(Some(&config_only[..]), Some("/api/agents/meta-keys")).is_some());
let compliance_only = [Feature::Compliance];
assert!(denied_read(Some(&compliance_only[..]), Some("/api/agents")).is_some());
}
#[test]
fn a_shared_route_widens_reads_but_never_a_write() {
let exec_only = [Feature::Exec];
let exec = Some(&exec_only[..]);
assert_eq!(denied_read(exec, Some("/api/jobs")), None);
assert!(
feature_denial(exec, &Method::POST, Some("/api/jobs")).is_some(),
"a write through a shared route still needs the owning page"
);
let jobs_only = [Feature::Jobs];
let jobs = Some(&jobs_only[..]);
assert_eq!(denied_read(jobs, Some("/api/jobs")), None);
assert_eq!(feature_denial(jobs, &Method::POST, Some("/api/jobs")), None);
let audit_only = [Feature::Audit];
let audit = Some(&audit_only[..]);
assert!(feature_denial(audit, &Method::POST, Some("/api/jobs")).is_some());
assert!(feature_denial(audit, &Method::PUT, Some("/api/groups")).is_some());
}
#[test]
fn routes_a_page_fetches_itself_are_reachable_for_that_page() {
let cases: &[(Feature, &str)] = &[
(Feature::Schedules, "/api/schedules/coverage"),
(Feature::Settings, "/api/server-settings/defaults"),
(Feature::Config, "/api/config/defaults"),
(Feature::Config, "/api/pcs/{pc_id}/config/inherited"),
(Feature::Config, "/api/groups/{name}/config/inherited"),
(Feature::Config, "/api/agents/{pc_id}/effective_config"),
];
for (feature, path) in cases {
let only = [*feature];
assert_eq!(
denied_read(Some(&only[..]), Some(path)),
None,
"{path} should be open to {feature:?}-only"
);
}
let audit_only = [Feature::Audit];
for (_, path) in cases {
assert!(
denied_read(Some(&audit_only[..]), Some(path)).is_some(),
"{path} should stay closed to Audit-only"
);
}
}
#[test]
fn unrestricted_callers_pass_commons_and_gated_alike() {
assert_eq!(denied_read(None, Some("/api/agents")), None);
assert_eq!(denied_read(None, Some("/api/audit")), None);
assert_eq!(denied_read(None, Some("/api/auth/me")), None);
assert_eq!(denied_read(Some(&[]), None), None);
}
}