server/cli/src/token_cli.rs
//! **`holger-server token …` — repository tokens, from a shell** (lane P3).
//!
//! The operator's view of `holger.token.v1.TokenService`, with the operator's own credential:
//! the box's static bearer lists every token and revokes any; a person's passkey session token
//! lists and revokes their own, and mints (a mint is a person's act, stepped up, as in the
//! console). Holger decides, exactly as it does for the console. The secret of a mint is
//! printed ONCE, on its own line, and nowhere else.
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use server_lib::grpc::holger_token_proto::{MintTokenRequest, TokenRow, TokenScope};
use server_lib::repo_token::client;
#[derive(Args, Debug)]
pub struct TokenArgs {
/// The server's gRPC door, `host:port` (or a full `http(s)://` URL).
#[arg(long, default_value = "127.0.0.1:50051")]
server: String,
/// A file holding the credential to act with: the box's static bearer, or a passkey
/// session token. Else `$HOLGER_TOKEN`.
#[arg(long)]
token_file: Option<PathBuf>,
#[command(subcommand)]
verb: TokenVerb,
}
#[derive(Subcommand, Debug)]
enum TokenVerb {
/// Every token the credential may see: its own, or for an admin every token.
List,
/// Revoke a token by its id (from `list`); it is refused at once and stays listed.
Revoke {
#[arg(long)]
id: String,
},
/// Mint a token (a passkey session only). Prints the secret ONCE.
Mint {
/// What you call it.
#[arg(long, default_value = "")]
label: String,
/// A repository it is for; repeat for more.
#[arg(long = "repository", required = true)]
repositories: Vec<String>,
/// read, or read-write.
#[arg(long, default_value = "read")]
scope: String,
/// Days until it expires (1 to 366; 0 is the default of 90).
#[arg(long, default_value_t = 0)]
days: u32,
},
}
/// Pure: a scope word as the wire's enum.
fn scope_of(word: &str) -> Result<TokenScope> {
Ok(
match word.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"read" => TokenScope::Read,
"read-write" | "readwrite" | "write" => TokenScope::ReadWrite,
other => bail!("`{other}` is not a scope (read or read-write)"),
},
)
}
/// Pure: one listing line. Never a secret: the server holds none.
fn line(t: &TokenRow) -> String {
let scope = if t.scope == TokenScope::ReadWrite as i32 {
"read-write"
} else {
"read"
};
let used = if t.last_used == 0 {
"never used".to_string()
} else {
format!("last used {}", t.last_used)
};
format!(
"{} {:8} {:10} {} [{}] owner {} expires {} {used}",
t.id,
t.state,
scope,
t.label,
t.repositories.join(","),
t.owner_display,
t.expires
)
}
pub async fn run(a: TokenArgs) -> Result<()> {
let bearer = match &a.token_file {
Some(f) => std::fs::read_to_string(f)
.with_context(|| format!("reading {}", f.display()))?
.trim()
.to_owned(),
None => std::env::var("HOLGER_TOKEN")
.unwrap_or_default()
.trim()
.to_owned(),
};
if bearer.is_empty() {
bail!("no credential: --token-file FILE (the box's static bearer or a passkey session token) or $HOLGER_TOKEN");
}
match a.verb {
TokenVerb::List => {
let r = client::list(&a.server, &bearer)
.await
.map_err(anyhow::Error::msg)?;
if r.tokens.is_empty() {
println!("no repository tokens");
}
for t in &r.tokens {
println!("{}", line(t));
}
}
TokenVerb::Revoke { id } => {
let r = client::revoke(&a.server, &bearer, &id)
.await
.map_err(anyhow::Error::msg)?;
if let Some(t) = r.token {
println!("revoked: {}", line(&t));
}
}
TokenVerb::Mint {
label,
repositories,
scope,
days,
} => {
let req = MintTokenRequest {
label,
repositories,
scope: scope_of(&scope)? as i32,
days,
};
let r = client::mint(&a.server, &bearer, req)
.await
.map_err(anyhow::Error::msg)?;
if let Some(t) = &r.token {
eprintln!("minted: {}", line(t));
}
eprintln!("{}", r.words);
println!("{}", r.secret);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_scope_words_are_read_and_read_write() {
assert_eq!(scope_of("read").unwrap(), TokenScope::Read);
for w in ["read-write", "READ_WRITE", "write"] {
assert_eq!(scope_of(w).unwrap(), TokenScope::ReadWrite, "{w}");
}
assert!(scope_of("admin").is_err());
}
#[test]
fn a_listing_line_names_the_token_and_its_last_use() {
let t = TokenRow {
id: "abc".into(),
label: "ci".into(),
owner_display: "anna".into(),
repositories: vec!["crates".into()],
scope: TokenScope::ReadWrite as i32,
state: "live".into(),
expires: 9,
..Default::default()
};
let l = line(&t);
assert!(
l.contains("abc")
&& l.contains("read-write")
&& l.contains("[crates]")
&& l.contains("never used"),
"{l}"
);
}
}