Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}"
        );
    }
}