Skip to main content

mkit_cli/commands/
grant.rs

1//! `mkit grant create|add|list|revoke` (WP-2.13, WP-2.14; R-155, R-156):
2//! issue, import, inspect and revoke SPEC-WRITE-GRANTS write and read grants.
3//!
4//! This is the *client* grant store, under the user config directory. It
5//! does not register grants on a server: it holds grants a person was given
6//! so `mkit push` and `mkit clone` present them.
7
8use std::io::Write as _;
9
10use clap::{Args, Parser, Subcommand};
11use mkit_attest::grant::{Capabilities, Namespace};
12use mkit_core::hash::{from_hex, hash, to_hex_bytes};
13
14use crate::clap_shim;
15use crate::commands::epoch::{BumpOpts, RevokeExtras, bump};
16use crate::commands::{error, usage_error};
17use crate::exit;
18use crate::format::{JsonObject, human_date_utc, json_string_array};
19use crate::grants::cli::{Ctx, parse_namespace, print_statement};
20use crate::grants::owner::{Kind, OwnerArgs, Produced, produce};
21use crate::grants::remote::{Target, check_audiences, resolve_target};
22use crate::grants::spec::{
23    DEFAULT_GRANT_TTL, GrantSpec, RepoSelector, build_grant, canonical_audiences,
24    canonical_capabilities, canonical_ref_scopes, check_ref_scopes, parse_ttl,
25};
26use crate::grants::store::{AddOutcome, GrantStore, StoredGrant};
27use crate::grants::{now_ms, scope_text};
28
29/// The clock lead a server tolerates on a grant's `created` (ยง1.1).
30const CLOCK_LEAD_MS: i64 = 30_000;
31
32#[derive(Debug, Parser)]
33#[command(
34    name = "mkit grant",
35    about = "Issue, import, list and revoke write and read grants."
36)]
37struct GrantOpts {
38    #[command(subcommand)]
39    command: GrantCommand,
40}
41
42#[derive(Debug, Subcommand)]
43enum GrantCommand {
44    /// Create an owner-signed grant for a grantee's key.
45    Create(Box<CreateOpts>),
46    /// Verify a grant header and add it to your grant store.
47    Add(AddOpts),
48    /// List the grants in your grant store.
49    List(ListOpts),
50    /// Revoke grants by advancing the namespace's epoch on a remote.
51    Revoke(Box<RevokeOpts>),
52}
53
54#[derive(Debug, Args)]
55#[allow(clippy::struct_excessive_bools)]
56struct CreateOpts {
57    /// Namespace that owns the repositories (default: the signing key's).
58    #[arg(long, value_name = "NS")]
59    namespace: Option<String>,
60    /// One repository in the namespace.
61    #[arg(long, value_name = "NAME", conflicts_with = "all")]
62    repo: Option<String>,
63    /// Every repository in the namespace, including ones not created yet.
64    #[arg(long)]
65    all: bool,
66    /// Audience the grant is valid for (repeatable, 1 to 8; default: the
67    /// trusted remote's origin).
68    #[arg(long, value_name = "ORIGIN")]
69    audience: Vec<String>,
70    /// A ref scope `pattern=flags` from `cufd` (repeatable): for example
71    /// `refs/heads/*=cuf`. Required with write, not allowed with read.
72    #[arg(long, value_name = "PATTERN=FLAGS")]
73    refs: Vec<String>,
74    /// `read`, `read,write` or `write` (`write,read` is accepted and
75    /// spelled canonically).
76    #[arg(long, value_name = "CAP")]
77    cap: Option<String>,
78    /// The grantee's Ed25519 public key, 64 hex digits.
79    #[arg(long, value_name = "HEX")]
80    grantee: Option<String>,
81    /// Lifetime, at most 30d (for example 12h, 7d).
82    #[arg(long, value_name = "DURATION", default_value = DEFAULT_GRANT_TTL)]
83    ttl: String,
84    /// The namespace epoch the grant is valid at (default: read from the
85    /// remote).
86    #[arg(long, value_name = "N", conflicts_with = "offline")]
87    epoch: Option<u64>,
88    /// Don't contact a remote; the epoch defaults to 0.
89    #[arg(long)]
90    offline: bool,
91    /// Remote (name or mkit+https:// URL) supplying the default audience and
92    /// epoch. Default: the trusted remote.
93    #[arg(long, value_name = "REMOTE")]
94    remote: Option<String>,
95    /// Also add the grant to your own grant store.
96    #[arg(long)]
97    store: bool,
98    #[command(flatten)]
99    owner: OwnerArgs,
100}
101
102#[derive(Debug, Args)]
103struct AddOpts {
104    /// File holding a grant header, or `-` for stdin.
105    file: String,
106    /// Remote (name or mkit+https:// URL) to compare the grant's epoch with.
107    /// Default: the trusted remote.
108    #[arg(long, value_name = "REMOTE")]
109    remote: Option<String>,
110    /// Don't contact a remote.
111    #[arg(long)]
112    offline: bool,
113}
114
115#[derive(Debug, Args)]
116struct ListOpts {
117    /// Ask the trusted remote (or --remote) for each namespace's epoch and
118    /// mark grants `stale epoch` or `future epoch`.
119    #[arg(long)]
120    check: bool,
121    /// Remote (name or mkit+https:// URL) for --check. Default: the trusted
122    /// remote.
123    #[arg(long, value_name = "REMOTE")]
124    remote: Option<String>,
125    /// Emit a JSON array.
126    #[arg(long)]
127    json: bool,
128}
129
130#[derive(Debug, Args)]
131struct RevokeOpts {
132    /// Remote name or mkit+https:// URL.
133    remote: Option<String>,
134    /// Namespace to advance (default: the remote URL's, else the signing key's).
135    #[arg(long, value_name = "NS")]
136    namespace: Option<String>,
137    /// Audience the statement is valid for (repeatable; default: the remote's
138    /// origin).
139    #[arg(long, value_name = "ORIGIN")]
140    audience: Vec<String>,
141    /// Longest to wait for revocation to complete (for example 5m).
142    #[arg(long, value_name = "DURATION", default_value = "5m")]
143    timeout: String,
144    /// Delete the local grants this revocation invalidates, once it succeeds.
145    #[arg(long)]
146    prune: bool,
147    /// Emit a JSON object.
148    #[arg(long)]
149    json: bool,
150    #[command(flatten)]
151    owner: OwnerArgs,
152}
153
154#[must_use]
155pub fn run(args: &[String]) -> u8 {
156    let opts = match clap_shim::parse::<GrantOpts>("mkit grant", args) {
157        Ok(opts) => opts,
158        Err(code) => return code,
159    };
160    match opts.command {
161        GrantCommand::Create(opts) => create(&opts),
162        GrantCommand::Add(opts) => add(&opts),
163        GrantCommand::List(opts) => list(&opts),
164        GrantCommand::Revoke(opts) => {
165            let opts = *opts;
166            let store = match GrantStore::open_default() {
167                Ok(store) => store,
168                Err(e) => return error(&e, exit::CONFIG_ERROR),
169            };
170            bump(
171                &BumpOpts {
172                    remote: opts.remote,
173                    namespace: opts.namespace,
174                    by: 1,
175                    audience: opts.audience,
176                    timeout: opts.timeout,
177                    json: opts.json,
178                    owner: opts.owner,
179                },
180                Some(&RevokeExtras {
181                    store,
182                    prune: opts.prune,
183                }),
184            )
185        }
186    }
187}
188
189fn optional_target(ctx: &Ctx, remote: Option<&str>) -> Result<Option<Target>, u8> {
190    match resolve_target(&ctx.layered, remote) {
191        Ok(target) => Ok(Some(target)),
192        Err(e) if remote.is_some() => Err(usage_error(&e)),
193        Err(_) => Ok(None),
194    }
195}
196
197fn build_spec(o: &CreateOpts, audiences: Vec<String>) -> Result<GrantSpec, u8> {
198    let repo = match (&o.repo, o.all) {
199        (Some(name), false) => RepoSelector::Name(name.clone()),
200        (None, true) => RepoSelector::All,
201        _ => return Err(usage_error("give exactly one of --repo NAME or --all")),
202    };
203    let capabilities = canonical_capabilities(
204        o.cap
205            .as_deref()
206            .ok_or_else(|| usage_error("--cap is required (read, read,write or write)"))?,
207    )
208    .map_err(|e| usage_error(&e))?;
209    let grantee_text = o
210        .grantee
211        .as_deref()
212        .ok_or_else(|| usage_error("--grantee <ed25519 public key hex> is required"))?;
213    let grantee = from_hex(grantee_text).map_err(|e| {
214        usage_error(&format!(
215            "--grantee must be a 64-digit lowercase hex Ed25519 public key: {e}"
216        ))
217    })?;
218    let ref_scopes = if o.refs.is_empty() {
219        None
220    } else {
221        Some(canonical_ref_scopes(&o.refs).map_err(|e| usage_error(&e))?)
222    };
223    check_ref_scopes(capabilities, ref_scopes.as_ref()).map_err(|e| usage_error(&e))?;
224    let ttl_ms = parse_ttl(&o.ttl).map_err(|e| usage_error(&format!("--ttl: {e}")))?;
225    Ok(GrantSpec {
226        repo,
227        grantee,
228        capabilities,
229        audiences,
230        ref_scopes,
231        epoch: 0,
232        ttl_ms,
233    })
234}
235
236#[allow(clippy::too_many_lines)] // linear flow: plan, audiences, epoch, sign, store
237fn create(o: &CreateOpts) -> u8 {
238    let ctx = match Ctx::load() {
239        Ok(ctx) => ctx,
240        Err(code) => return code,
241    };
242    let importing = o.owner.statement_file.is_some();
243    if importing
244        && (o.repo.is_some()
245            || o.all
246            || !o.audience.is_empty()
247            || !o.refs.is_empty()
248            || o.cap.is_some()
249            || o.grantee.is_some()
250            || o.epoch.is_some())
251    {
252        return usage_error(
253            "--statement-file already fixes the grant: don't combine it with --repo, --all, --audience, --refs, --cap, --grantee or --epoch",
254        );
255    }
256    let hint = match parse_namespace(o.namespace.as_deref()) {
257        Ok(hint) => hint,
258        Err(code) => return code,
259    };
260    let target = match optional_target(&ctx, o.remote.as_deref()) {
261        Ok(target) => target,
262        Err(code) => return code,
263    };
264    let audiences = if importing {
265        Vec::new()
266    } else if o.audience.is_empty() {
267        match target.as_ref().map(Target::audience) {
268            Some(Ok(audience)) => vec![audience],
269            Some(Err(e)) => return usage_error(&e),
270            None => {
271                return usage_error(
272                    "no --audience and no trusted remote to default to (set one with `mkit config trusted_remote_endpoint <url>`)",
273                );
274            }
275        }
276    } else {
277        match canonical_audiences(&o.audience) {
278            Ok(a) => a,
279            Err(e) => return usage_error(&e),
280        }
281    };
282    if !importing && let Err(e) = check_audiences(&audiences, target.as_ref()) {
283        return error(&e, exit::USAGE);
284    }
285    let spec = if importing {
286        None
287    } else {
288        match build_spec(o, audiences.clone()) {
289            Ok(spec) => Some(spec),
290            Err(code) => return code,
291        }
292    };
293    let plan = match ctx.plan(&o.owner, hint) {
294        Ok(plan) => plan,
295        Err(e) => return error(&e, exit::USAGE),
296    };
297
298    let now = now_ms();
299    let epoch_for = |ns: &Namespace| -> Result<u64, String> {
300        if let Some(epoch) = o.epoch {
301            return Ok(epoch);
302        }
303        if o.offline {
304            return Ok(0);
305        }
306        let target = target.as_ref().ok_or(
307            "no remote to read the current epoch from: pass --epoch N, --offline, --remote, or set a trusted remote",
308        )?;
309        let audience = target.audience()?;
310        if !audiences.contains(&audience) {
311            return Err(format!(
312                "the audiences don't include the remote's {audience}, so its epoch doesn't apply: pass --epoch N"
313            ));
314        }
315        Ctx::read_epoch(&ctx.open_unsigned(target)?, ns)
316    };
317    let produced = produce(
318        plan,
319        |ns| {
320            let mut spec = spec.clone().ok_or("no grant to build")?;
321            spec.epoch = epoch_for(ns)?;
322            build_grant(&spec, ns, now)?
323                .encode()
324                .map_err(crate::grants::spec::statement_error)
325        },
326        Kind::Grant,
327        &ctx.relying_parties,
328        now,
329    );
330    let signed = match produced {
331        Ok(Produced::Signed(signed)) => signed,
332        Ok(Produced::Print {
333            statement,
334            namespace,
335        }) => return print_statement(&statement, &namespace),
336        Err(e) => return error(&e, exit::DATAERR),
337    };
338    if o.store {
339        let store = match GrantStore::open_default() {
340            Ok(store) => store,
341            Err(e) => return error(&e, exit::CONFIG_ERROR),
342        };
343        match store.add(&signed.header, &ctx.relying_parties) {
344            Ok(AddOutcome::Added) => eprintln!("stored in {}", store.dir().display()),
345            Ok(AddOutcome::AlreadyStored) => eprintln!("already in your grant store"),
346            Err(e) => return error(&format!("store the grant: {e}"), exit::CANTCREAT),
347        }
348    }
349    let mut stdout = std::io::stdout().lock();
350    let _ = writeln!(stdout, "{}", signed.header);
351    let _ = stdout.flush();
352    eprintln!(
353        "grant {} ({}): give the header above to the grantee, who runs `mkit grant add`",
354        to_hex_bytes(&hash(&signed.statement)),
355        signed.scheme.token()
356    );
357    exit::OK
358}
359
360fn read_header(source: &str) -> Result<String, String> {
361    const LIMIT: u64 = 16 * 1024;
362    let read = if source == "-" {
363        crate::grants::read_bounded(std::io::stdin().lock(), LIMIT)
364    } else {
365        std::fs::File::open(source).and_then(|f| crate::grants::read_bounded(f, LIMIT))
366    };
367    let bytes = read.map_err(|e| format!("{source}: {e}"))?;
368    let text = String::from_utf8(bytes).map_err(|_| format!("{source}: not UTF-8"))?;
369    Ok(text.trim().to_owned())
370}
371
372fn add(o: &AddOpts) -> u8 {
373    let ctx = match Ctx::load() {
374        Ok(ctx) => ctx,
375        Err(code) => return code,
376    };
377    let header = match read_header(&o.file) {
378        Ok(header) => header,
379        Err(e) => return error(&e, exit::NOINPUT),
380    };
381    let verified = match crate::grants::verify_grant_header(&header, &ctx.relying_parties) {
382        Ok(verified) => verified,
383        Err(e) => return error(&format!("rejected: {e}"), exit::DATAERR),
384    };
385    let store = match GrantStore::open_default() {
386        Ok(store) => store,
387        Err(e) => return error(&e, exit::CONFIG_ERROR),
388    };
389    let outcome = match store.add(&header, &ctx.relying_parties) {
390        Ok(outcome) => outcome,
391        Err(e) => return error(&format!("rejected: {e}"), exit::DATAERR),
392    };
393    let id = to_hex_bytes(&verified.id);
394    match outcome {
395        AddOutcome::Added => println!("added grant {id}"),
396        AddOutcome::AlreadyStored => println!("grant {id} is already in your grant store"),
397    }
398    if !o.offline {
399        warn_if_future_epoch(&ctx, o.remote.as_deref(), &verified.grant);
400    }
401    exit::OK
402}
403
404/// Longest `grant add` waits to compare epochs.
405const ADD_EPOCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
406
407/// B6 caveat: a grant for an epoch above the remote's outranks the live
408/// grants until the owner bumps the epoch.
409fn warn_if_future_epoch(ctx: &Ctx, remote: Option<&str>, grant: &mkit_attest::grant::Grant) {
410    let Ok(target) = resolve_target(&ctx.layered, remote) else {
411        return;
412    };
413    let Ok(audience) = target.audience() else {
414        return;
415    };
416    if !grant.audiences.contains(&audience) {
417        return;
418    }
419    // Advisory: one short attempt, and a failure only warns.
420    let epoch = ctx.open_unsigned(&target).and_then(|tx| {
421        Ctx::read_epoch_once(&tx.with_unary_timeout(ADD_EPOCH_TIMEOUT), &grant.namespace)
422    });
423    match epoch {
424        Ok(current) if grant.epoch > current => eprintln!(
425            "warning: this grant is for epoch {} but {audience} is at epoch {current}. Until the owner raises the epoch it is refused there, and it outranks your other grants for {} at that audience",
426            grant.epoch, grant.namespace
427        ),
428        Ok(_) => {}
429        Err(e) => eprintln!("warning: couldn't compare the grant's epoch with {audience}: {e}"),
430    }
431}
432
433fn status_at(stored: &StoredGrant, now: i64) -> &'static str {
434    if stored.grant.created_ms > now.saturating_add(CLOCK_LEAD_MS) {
435        "not yet valid"
436    } else if now >= stored.grant.expiry_ms {
437        "expired"
438    } else {
439        "valid"
440    }
441}
442
443fn epoch_status(grant_epoch: u64, remote_epoch: u64) -> &'static str {
444    match grant_epoch.cmp(&remote_epoch) {
445        std::cmp::Ordering::Less => "stale epoch",
446        std::cmp::Ordering::Equal => "epoch current",
447        std::cmp::Ordering::Greater => "future epoch",
448    }
449}
450
451fn ref_scope_text(stored: &StoredGrant) -> String {
452    stored.grant.ref_scopes.as_ref().map_or_else(
453        || "-".to_owned(),
454        |scopes| {
455            scopes
456                .entries()
457                .iter()
458                .map(|(pattern, flags)| format!("{pattern}={flags}"))
459                .collect::<Vec<_>>()
460                .join(";")
461        },
462    )
463}
464
465fn date(ms: i64) -> String {
466    human_date_utc(u64::try_from(ms / 1000).unwrap_or(0))
467}
468
469#[allow(clippy::too_many_lines)] // load, optional epoch check, then JSON or text output
470fn list(o: &ListOpts) -> u8 {
471    let ctx = match Ctx::load() {
472        Ok(ctx) => ctx,
473        Err(code) => return code,
474    };
475    let store = match GrantStore::open_default() {
476        Ok(store) => store,
477        Err(e) => return error(&e, exit::CONFIG_ERROR),
478    };
479    let report = store.load(&ctx.relying_parties);
480    for warning in &report.warnings {
481        eprintln!("warning: {warning}");
482    }
483    let mut grants = report.grants;
484    grants.sort_by_key(|g| (g.grant.namespace.to_string(), g.grant.epoch, g.id));
485    let now = now_ms();
486
487    // --check asks only the trusted (or named) remote: a grant file is data
488    // from other people, and contacting an origin it names would send it your
489    // ambient credentials.
490    let mut checked: std::collections::BTreeMap<String, Result<u64, String>> =
491        std::collections::BTreeMap::new();
492    let mut check_audience = None;
493    let mut check_error = None;
494    if o.check {
495        match resolve_target(&ctx.layered, o.remote.as_deref())
496            .and_then(|t| ctx.open_unsigned(&t).map(|tx| (t, tx)))
497        {
498            Ok((target, tx)) => {
499                check_audience = target.audience().ok();
500                for g in &grants {
501                    if check_audience
502                        .as_ref()
503                        .is_some_and(|a| g.grant.audiences.contains(a))
504                    {
505                        checked
506                            .entry(g.grant.namespace.to_string())
507                            .or_insert_with(|| Ctx::read_epoch(&tx, &g.grant.namespace));
508                    }
509                }
510            }
511            Err(e) => check_error = Some(e),
512        }
513    }
514    let epoch_state = |g: &StoredGrant| -> Option<String> {
515        if !o.check {
516            return None;
517        }
518        if let Some(e) = &check_error {
519            return Some(format!("unchecked ({e})"));
520        }
521        let covered = check_audience
522            .as_ref()
523            .is_some_and(|a| g.grant.audiences.contains(a));
524        if !covered {
525            return Some("unchecked (audience is not the checked remote)".to_owned());
526        }
527        match checked.get(&g.grant.namespace.to_string()) {
528            Some(Ok(remote)) => Some(epoch_status(g.grant.epoch, *remote).to_owned()),
529            Some(Err(e)) => Some(format!("unchecked ({e})")),
530            None => Some("unchecked (audience is not the checked remote)".to_owned()),
531        }
532    };
533
534    let mut stdout = std::io::stdout().lock();
535    if o.json {
536        let items: Vec<String> = grants
537            .iter()
538            .map(|g| {
539                let mut object = JsonObject::new();
540                object
541                    .field_str("id", &to_hex_bytes(&g.id))
542                    .field_str("namespace", &g.grant.namespace.to_string())
543                    .field_str("scope", &scope_text(&g.grant))
544                    .field_str("grantee", &to_hex_bytes(&g.grant.grantee))
545                    .field_str("capabilities", g.grant.capabilities.token())
546                    .field_str("ref_scopes", &ref_scope_text(g))
547                    .field_raw("audiences", &json_string_array(&g.grant.audiences))
548                    .field_u64("epoch", g.grant.epoch)
549                    .field_u64("created_ms", u64::try_from(g.grant.created_ms).unwrap_or(0))
550                    .field_u64("expiry_ms", u64::try_from(g.grant.expiry_ms).unwrap_or(0))
551                    .field_str("scheme", g.scheme.token())
552                    .field_str("status", status_at(g, now));
553                if let Some(state) = epoch_state(g) {
554                    object.field_str("epoch_status", &state);
555                }
556                object.finish()
557            })
558            .collect();
559        let _ = writeln!(stdout, "[{}]", items.join(","));
560        return exit::OK;
561    }
562    if grants.is_empty() {
563        let _ = writeln!(stdout, "no grants in {}", store.dir().display());
564        return exit::OK;
565    }
566    for g in &grants {
567        let mut status = status_at(g, now).to_owned();
568        if let Some(state) = epoch_state(g) {
569            status = format!("{status}, {state}");
570        }
571        let _ = writeln!(
572            stdout,
573            "{}  {}  epoch {}  {}",
574            to_hex_bytes(&g.id),
575            g.grant.capabilities.token(),
576            g.grant.epoch,
577            status
578        );
579        let _ = writeln!(stdout, "  namespace  {}", g.grant.namespace);
580        let _ = writeln!(stdout, "  scope      {}", scope_text(&g.grant));
581        let _ = writeln!(stdout, "  grantee    {}", to_hex_bytes(&g.grant.grantee));
582        let _ = writeln!(stdout, "  audiences  {}", g.grant.audiences.join(", "));
583        if g.grant.capabilities != Capabilities::Read {
584            let _ = writeln!(stdout, "  refs       {}", ref_scope_text(g));
585        }
586        let _ = writeln!(stdout, "  expires    {}", date(g.grant.expiry_ms));
587    }
588    if o.check && check_audience.is_none() && check_error.is_none() {
589        eprintln!("note: --check needs a remote with an mkit+https:// URL");
590    }
591    exit::OK
592}