1use 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
29const 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(Box<CreateOpts>),
46 Add(AddOpts),
48 List(ListOpts),
50 Revoke(Box<RevokeOpts>),
52}
53
54#[derive(Debug, Args)]
55#[allow(clippy::struct_excessive_bools)]
56struct CreateOpts {
57 #[arg(long, value_name = "NS")]
59 namespace: Option<String>,
60 #[arg(long, value_name = "NAME", conflicts_with = "all")]
62 repo: Option<String>,
63 #[arg(long)]
65 all: bool,
66 #[arg(long, value_name = "ORIGIN")]
69 audience: Vec<String>,
70 #[arg(long, value_name = "PATTERN=FLAGS")]
73 refs: Vec<String>,
74 #[arg(long, value_name = "CAP")]
77 cap: Option<String>,
78 #[arg(long, value_name = "HEX")]
80 grantee: Option<String>,
81 #[arg(long, value_name = "DURATION", default_value = DEFAULT_GRANT_TTL)]
83 ttl: String,
84 #[arg(long, value_name = "N", conflicts_with = "offline")]
87 epoch: Option<u64>,
88 #[arg(long)]
90 offline: bool,
91 #[arg(long, value_name = "REMOTE")]
94 remote: Option<String>,
95 #[arg(long)]
97 store: bool,
98 #[command(flatten)]
99 owner: OwnerArgs,
100}
101
102#[derive(Debug, Args)]
103struct AddOpts {
104 file: String,
106 #[arg(long, value_name = "REMOTE")]
109 remote: Option<String>,
110 #[arg(long)]
112 offline: bool,
113}
114
115#[derive(Debug, Args)]
116struct ListOpts {
117 #[arg(long)]
120 check: bool,
121 #[arg(long, value_name = "REMOTE")]
124 remote: Option<String>,
125 #[arg(long)]
127 json: bool,
128}
129
130#[derive(Debug, Args)]
131struct RevokeOpts {
132 remote: Option<String>,
134 #[arg(long, value_name = "NS")]
136 namespace: Option<String>,
137 #[arg(long, value_name = "ORIGIN")]
140 audience: Vec<String>,
141 #[arg(long, value_name = "DURATION", default_value = "5m")]
143 timeout: String,
144 #[arg(long)]
146 prune: bool,
147 #[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)] fn 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
404const ADD_EPOCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
406
407fn 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 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)] fn 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 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}