use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct Token {
pub label: String,
pub secret: String,
pub scopes: ScopeMatcher,
}
impl Token {
pub fn allows(&self, op_scope: &str) -> bool {
if self.scopes.patterns.is_empty() {
return true;
}
self.scopes.matches(op_scope)
}
pub fn granted_scopes(&self) -> Vec<String> {
if self.scopes.patterns.is_empty() {
vec!["*".to_string()]
} else {
self.scopes.patterns.clone()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ScopeMatcher {
pub patterns: Vec<String>,
}
impl ScopeMatcher {
pub fn from_strings<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
patterns: iter.into_iter().map(Into::into).collect(),
}
}
pub fn matches(&self, op_scope: &str) -> bool {
for p in &self.patterns {
if p == "*" || p == op_scope {
return true;
}
if let Some(prefix) = p.strip_suffix(".*") {
if let Some(rest) = op_scope.strip_prefix(prefix) {
if rest.starts_with('.') {
return true;
}
}
}
if let Some(suffix) = p.strip_prefix("*.") {
if let Some(rest) = op_scope.rsplit_once('.') {
if rest.1 == suffix {
return true;
}
}
}
}
false
}
}
#[derive(Debug, Clone, Default)]
pub struct TokenRegistry {
tokens: Arc<Vec<Token>>,
}
impl TokenRegistry {
pub fn new(tokens: Vec<Token>) -> Self {
Self {
tokens: Arc::new(tokens),
}
}
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
pub fn len(&self) -> usize {
self.tokens.len()
}
pub fn lookup(&self, secret: &str) -> Option<&Token> {
self.tokens.iter().find(|t| t.secret == secret)
}
}
pub fn op_scope(op: &str) -> &'static str {
match op {
"cache_get" | "cache_list" | "cache_stats" => "cache.read",
"cache_put" | "cache_del" => "cache.write",
"job_list" | "job_output" | "job_status" | "job_wait" => "job.read",
"job_submit" => "job.write",
"job_cancel" | "job_kill" => "job.control",
"lock_list" => "lock.read",
"lock_acquire" | "lock_release" | "lock_try_acquire" => "lock.write",
"definitions_query"
| "definitions_kinds"
| "definitions_diff"
| "definitions_subscribe"
| "definitions_unsubscribe" => "defs.read",
"definitions_emit" => "defs.write",
"snapshot_list" | "snapshot_diff" => "snapshot.read",
"snapshot_save" | "snapshot_load" => "snapshot.write",
"artifact_get" | "artifact_get_by_digest" | "artifact_list" => "artifact.read",
"artifact_put" => "artifact.write",
"artifact_gc" => "artifact.admin",
"schedule_list" => "schedule.read",
"schedule_add" | "schedule_add_once" | "schedule_remove" => "schedule.write",
"subscribe" | "unsubscribe" | "subscription_set_paused" => "event.read",
"publish" => "event.write",
"watch_list" | "watcher_stats" => "watch.read",
"watch_subscribe" | "watch_unsubscribe" | "fpath_changed" => "watch.write",
"list_shells" => "shell.read",
"send" | "notify" | "tag" | "untag" | "register" => "shell.write",
"recorder_ingest" => "recorder.write",
"history_query" => "history.read",
"history_append" => "history.write",
"ask_pending" | "ask_take" => "ask.read",
"ask_ask" | "ask_response" | "ask_dismiss" => "ask.write",
"export" | "export_all" | "export_catalog" | "export_shard" | "export_zcompdump"
| "view" => "export.read",
"import_all"
| "import_catalog"
| "import_history"
| "import_shard"
| "import_zcompdump"
| "import_zwc"
| "load_script"
| "push_canonical"
| "pull_canonical"
| "canonical_hydrate_view"
| "diff_canonical" => "import.write",
"info" | "ping" | "daemon" | "doctor" | "verify" | "metrics" | "log_stats"
| "config_get" | "config_list" | "keys" | "complete" | "suggest" | "highlight"
| "source_resolve" | "replay_log" | "cmd_result" | "cmd_started" | "subscribe_shard" => {
"meta.read"
}
"clean" | "compact" | "stats_flush" | "log_level" | "log_rotate" | "config_set" => {
"meta.admin"
}
_ => "meta.admin",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_scopes_grant_full_access() {
let t = Token {
label: "legacy".into(),
secret: "x".into(),
scopes: ScopeMatcher::default(),
};
assert!(t.allows("cache.read"));
assert!(t.allows("snapshot.write"));
assert!(t.allows("meta.admin"));
assert!(t.allows("brand.new.scope.never.seen"));
}
#[test]
fn exact_pattern_matches_only_that_scope() {
let m = ScopeMatcher::from_strings(["cache.put"]);
assert!(m.matches("cache.put"));
assert!(!m.matches("cache.get"));
assert!(!m.matches("snapshot.save"));
}
#[test]
fn area_wildcard_matches_every_verb_in_area() {
let m = ScopeMatcher::from_strings(["cache.*"]);
assert!(m.matches("cache.read"));
assert!(m.matches("cache.write"));
assert!(m.matches("cache.admin"));
assert!(!m.matches("snapshot.write"));
assert!(!m.matches("cache"));
}
#[test]
fn verb_wildcard_matches_every_area_with_that_verb() {
let m = ScopeMatcher::from_strings(["*.read"]);
assert!(m.matches("cache.read"));
assert!(m.matches("snapshot.read"));
assert!(m.matches("defs.read"));
assert!(!m.matches("cache.write"));
assert!(!m.matches("snapshot.save"));
}
#[test]
fn star_matches_everything() {
let m = ScopeMatcher::from_strings(["*"]);
assert!(m.matches("cache.read"));
assert!(m.matches("snapshot.write"));
assert!(m.matches("meta.admin"));
}
#[test]
fn multiple_patterns_or_together() {
let m = ScopeMatcher::from_strings(["cache.*", "snapshot.read"]);
assert!(m.matches("cache.write"));
assert!(m.matches("snapshot.read"));
assert!(!m.matches("snapshot.write"));
assert!(!m.matches("job.submit"));
}
#[test]
fn op_scope_table_covers_known_ops() {
assert_eq!(op_scope("cache_put"), "cache.write");
assert_eq!(op_scope("cache_get"), "cache.read");
assert_eq!(op_scope("definitions_emit"), "defs.write");
assert_eq!(op_scope("definitions_query"), "defs.read");
assert_eq!(op_scope("snapshot_save"), "snapshot.write");
assert_eq!(op_scope("watch_subscribe"), "watch.write");
assert_eq!(op_scope("recorder_ingest"), "recorder.write");
assert_eq!(op_scope("info"), "meta.read");
assert_eq!(op_scope("clean"), "meta.admin");
assert_eq!(op_scope("zzz_brand_new_op"), "meta.admin");
}
#[test]
fn registry_lookup_finds_by_secret() {
let reg = TokenRegistry::new(vec![
Token {
label: "ci".into(),
secret: "abc".into(),
scopes: ScopeMatcher::from_strings(["job.*"]),
},
Token {
label: "lsp".into(),
secret: "def".into(),
scopes: ScopeMatcher::from_strings(["defs.read"]),
},
]);
assert!(reg.lookup("abc").is_some());
assert_eq!(reg.lookup("abc").unwrap().label, "ci");
assert!(reg.lookup("def").is_some());
assert!(reg.lookup("missing").is_none());
}
}