1use clap::{Args, Parser, Subcommand};
11use mkit_attest::grant::{
12 EpochStatement, EpochTransition, MAX_EPOCH_STEP, Namespace, epoch_transition,
13};
14use std::io::Write as _;
15
16use crate::clap_shim;
17use crate::commands::{error, usage_error};
18use crate::exit;
19use crate::format::{JsonObject, json_string_array};
20use crate::grants::cli::{Ctx, finish_wait, parse_namespace, parse_timeout, print_statement};
21use crate::grants::owner::{Kind, OwnerArgs, Produced, produce};
22use crate::grants::remote::{Driven, check_audiences, drive, interruptible_sleep, resolve_target};
23use crate::grants::spec::{build_epoch, canonical_audiences, statement_lifetime_ms};
24use crate::grants::store::{GrantStore, StoredGrant};
25use crate::grants::{now_ms, scope_text};
26
27#[derive(Debug, Parser)]
28#[command(
29 name = "mkit epoch",
30 about = "Show or advance a namespace's grant epoch on a remote."
31)]
32struct EpochOpts {
33 #[command(subcommand)]
34 command: EpochCommand,
35}
36
37#[derive(Debug, Subcommand)]
38enum EpochCommand {
39 Show(ShowOpts),
41 Bump(BumpOpts),
43}
44
45#[derive(Debug, Args)]
46struct ShowOpts {
47 remote: Option<String>,
49 #[arg(long, value_name = "NS")]
51 namespace: Option<String>,
52 #[arg(long)]
54 json: bool,
55}
56
57#[derive(Debug, Clone, Args)]
59pub(crate) struct BumpOpts {
60 pub remote: Option<String>,
62 #[arg(long, value_name = "NS")]
64 pub namespace: Option<String>,
65 #[arg(long, value_name = "N", default_value_t = 1)]
67 pub by: u64,
68 #[arg(long, value_name = "ORIGIN")]
71 pub audience: Vec<String>,
72 #[arg(long, value_name = "DURATION", default_value = "5m")]
74 pub timeout: String,
75 #[arg(long)]
77 pub json: bool,
78 #[command(flatten)]
79 pub owner: OwnerArgs,
80}
81
82#[must_use]
83pub fn run(args: &[String]) -> u8 {
84 let opts = match clap_shim::parse::<EpochOpts>("mkit epoch", args) {
85 Ok(opts) => opts,
86 Err(code) => return code,
87 };
88 match opts.command {
89 EpochCommand::Show(opts) => show(&opts),
90 EpochCommand::Bump(opts) => bump(&opts, None),
91 }
92}
93
94fn hint_namespace(
96 flag: Option<&str>,
97 target: &crate::grants::remote::Target,
98) -> Result<Option<Namespace>, u8> {
99 Ok(parse_namespace(flag)?.or_else(|| target.namespace()))
100}
101
102fn show(opts: &ShowOpts) -> u8 {
103 let ctx = match Ctx::load() {
104 Ok(ctx) => ctx,
105 Err(code) => return code,
106 };
107 let target = match resolve_target(&ctx.layered, opts.remote.as_deref()) {
108 Ok(target) => target,
109 Err(e) => return usage_error(&e),
110 };
111 let namespace = match hint_namespace(opts.namespace.as_deref(), &target) {
112 Ok(Some(ns)) => ns,
113 Ok(None) => {
114 let plan = ctx.plan(&OwnerArgs::default(), None);
116 match plan {
117 Ok(crate::grants::owner::Plan::Native(owner)) => *owner.namespace(),
118 _ => {
119 return usage_error(
120 "no namespace: pass --namespace, or a remote URL that names <namespace>/<repo>",
121 );
122 }
123 }
124 }
125 Err(code) => return code,
126 };
127 let tx = match ctx.open_unsigned(&target) {
128 Ok(tx) => tx,
129 Err(e) => return error(&e, exit::UNAVAILABLE),
130 };
131 let epoch = match Ctx::read_epoch(&tx, &namespace) {
132 Ok(epoch) => epoch,
133 Err(e) => return error(&e, exit::UNAVAILABLE),
134 };
135 let mut stdout = std::io::stdout().lock();
136 if opts.json {
137 let mut object = JsonObject::new();
138 object
139 .field_str("namespace", &namespace.to_string())
140 .field_str("audience", tx.origin())
141 .field_u64("epoch", epoch);
142 let _ = writeln!(stdout, "{}", object.finish());
143 } else {
144 let _ = writeln!(stdout, "namespace {namespace}");
145 let _ = writeln!(stdout, "audience {}", tx.origin());
146 let _ = writeln!(stdout, "epoch {epoch}");
147 }
148 exit::OK
149}
150
151pub(crate) struct RevokeExtras {
153 pub store: GrantStore,
154 pub prune: bool,
155}
156
157struct Invalidated<'a> {
160 dead: Vec<&'a StoredGrant>,
162 partial: Vec<&'a StoredGrant>,
164}
165
166fn invalidated<'a>(
169 stored: &'a [StoredGrant],
170 namespace: &Namespace,
171 audiences: &[String],
172 new_epoch: u64,
173) -> Invalidated<'a> {
174 let mut out = Invalidated {
175 dead: Vec::new(),
176 partial: Vec::new(),
177 };
178 for g in stored.iter().filter(|g| {
179 g.grant.namespace == *namespace
180 && g.grant.epoch < new_epoch
181 && g.grant.audiences.iter().any(|a| audiences.contains(a))
182 }) {
183 if g.grant.audiences.iter().all(|a| audiences.contains(a)) {
184 out.dead.push(g);
185 } else {
186 out.partial.push(g);
187 }
188 }
189 out
190}
191
192fn grant_line(g: &StoredGrant) -> String {
193 format!(
194 "{} {} epoch {} {}",
195 mkit_core::hash::to_hex_bytes(&g.id),
196 g.grant.capabilities.token(),
197 g.grant.epoch,
198 scope_text(&g.grant)
199 )
200}
201
202#[allow(clippy::too_many_lines)] pub(crate) fn bump(opts: &BumpOpts, revoke: Option<&RevokeExtras>) -> u8 {
204 let ctx = match Ctx::load() {
205 Ok(ctx) => ctx,
206 Err(code) => return code,
207 };
208 let timeout = match parse_timeout(&opts.timeout) {
209 Ok(t) => t,
210 Err(e) => return usage_error(&format!("--timeout: {e}")),
211 };
212 if opts.by == 0 || opts.by > MAX_EPOCH_STEP {
213 return usage_error(&format!(
214 "--by {} is outside 1..={MAX_EPOCH_STEP}, the largest epoch step a server accepts (SPEC-WRITE-GRANTS §5.2); bump in several steps",
215 opts.by
216 ));
217 }
218 let Some(remote) = opts.remote.as_deref() else {
219 return usage_error("mkit epoch bump needs a remote (name or mkit+https:// URL)");
220 };
221 let target = match resolve_target(&ctx.layered, Some(remote)) {
222 Ok(target) => target,
223 Err(e) => return usage_error(&e),
224 };
225 let tx = match ctx.open_unsigned(&target) {
226 Ok(tx) => tx,
227 Err(e) => return error(&e, exit::UNAVAILABLE),
228 };
229 let audiences = if opts.audience.is_empty() {
230 vec![tx.origin().to_owned()]
231 } else {
232 match canonical_audiences(&opts.audience) {
233 Ok(a) => a,
234 Err(e) => return usage_error(&e),
235 }
236 };
237 if let Err(e) = check_audiences(&audiences, Some(&target)) {
238 return error(&e, exit::USAGE);
239 }
240 let hint = match hint_namespace(opts.namespace.as_deref(), &target) {
241 Ok(hint) => hint,
242 Err(code) => return code,
243 };
244 let plan = match ctx.plan(&opts.owner, hint) {
245 Ok(plan) => plan,
246 Err(e) => return error(&e, exit::USAGE),
247 };
248
249 let mut read_current: Option<u64> = None;
251 let now = now_ms();
252 let lifetime_ms = statement_lifetime_ms(timeout);
253 let produced = produce(
254 plan,
255 |ns| {
256 let current = Ctx::read_epoch(&tx, ns)?;
257 read_current = Some(current);
258 let new = current
259 .checked_add(opts.by)
260 .ok_or("the epoch would overflow")?;
261 build_epoch(ns, new, &audiences, now, lifetime_ms)?
262 .encode()
263 .map_err(|e| format!("invalid statement: {e}"))
264 },
265 Kind::Epoch,
266 &ctx.relying_parties,
267 now,
268 );
269 let signed = match produced {
270 Ok(Produced::Signed(signed)) => signed,
271 Ok(Produced::Print {
272 statement,
273 namespace,
274 }) => return print_statement(&statement, &namespace),
275 Err(e) => return error(&e, exit::DATAERR),
276 };
277 let statement = match EpochStatement::parse(&signed.statement) {
278 Ok(s) => s,
279 Err(e) => return error(&format!("invalid statement: {e}"), exit::DATAERR),
280 };
281 if let Err(e) = check_audiences(&statement.audiences, Some(&target)) {
284 return error(&e, exit::USAGE);
285 }
286 if let Some(hint) = hint
287 && hint != statement.namespace
288 {
289 return error(
290 &format!(
291 "the statement is for namespace {}, but the command asks for {hint}",
292 statement.namespace
293 ),
294 exit::DATAERR,
295 );
296 }
297 let current = match read_current {
298 Some(c) => c,
299 None => match Ctx::read_epoch(&tx, &statement.namespace) {
300 Ok(c) => c,
301 Err(e) => return error(&e, exit::UNAVAILABLE),
302 },
303 };
304 match epoch_transition(current, statement.new_epoch) {
305 EpochTransition::Advance | EpochTransition::Retry => {}
306 EpochTransition::Reject if statement.new_epoch > current => {
307 return error(
308 &format!(
309 "the statement raises epoch {current} to {}: a step of {} is over the maximum of {MAX_EPOCH_STEP}",
310 statement.new_epoch,
311 statement.new_epoch - current
312 ),
313 exit::DATAERR,
314 );
315 }
316 EpochTransition::Reject => {
317 return error(
318 &format!(
319 "the statement sets epoch {} but {} is already stored; an epoch never decreases",
320 statement.new_epoch, current
321 ),
322 exit::DATAERR,
323 );
324 }
325 }
326
327 let stored = revoke.map(|r| r.store.load(&ctx.relying_parties));
328 if let Some(report) = &stored {
329 for warning in &report.warnings {
330 eprintln!("warning: {warning}");
331 }
332 let affected = invalidated(
333 &report.grants,
334 &statement.namespace,
335 &statement.audiences,
336 statement.new_epoch,
337 );
338 eprintln!(
339 "revoking: {} local grant(s) stop working when {} reaches epoch {}",
340 affected.dead.len() + affected.partial.len(),
341 statement.namespace,
342 statement.new_epoch
343 );
344 for g in &affected.dead {
345 eprintln!(" {}", grant_line(g));
346 }
347 for g in &affected.partial {
348 eprintln!(
349 " {} (still valid at {})",
350 grant_line(g),
351 g.grant
352 .audiences
353 .iter()
354 .filter(|a| !statement.audiences.contains(a))
355 .cloned()
356 .collect::<Vec<_>>()
357 .join(", ")
358 );
359 }
360 }
361
362 let outcome = drive(
363 || tx.set_grant_epoch(&signed.header),
364 timeout,
365 interruptible_sleep,
366 );
367 let outcome = match outcome {
368 Ok(outcome) => outcome,
369 Err(e) => return set_epoch_error(&e),
370 };
371 if let Some(code) = finish_wait(&outcome, "the epoch bump") {
372 return code;
373 }
374 let Driven::Done(stored_epoch) = outcome else {
375 return exit::GENERAL_ERROR;
376 };
377 report_bump(
378 opts,
379 &statement,
380 current,
381 stored_epoch,
382 revoke,
383 stored.as_ref().map(|r| r.grants.as_slice()),
384 )
385}
386
387#[allow(clippy::too_many_arguments)]
388fn report_bump(
389 opts: &BumpOpts,
390 statement: &EpochStatement,
391 previous: u64,
392 stored_epoch: u64,
393 revoke: Option<&RevokeExtras>,
394 grants: Option<&[StoredGrant]>,
395) -> u8 {
396 let mut pruned = 0usize;
397 if let (Some(extras), Some(grants)) = (revoke, grants)
398 && extras.prune
399 {
400 for g in invalidated(
401 grants,
402 &statement.namespace,
403 &statement.audiences,
404 stored_epoch,
405 )
406 .dead
407 {
408 match extras.store.remove(&g.id) {
409 Ok(true) => pruned += 1,
410 Ok(false) => {}
411 Err(e) => eprintln!("warning: could not remove grant: {e}"),
412 }
413 }
414 }
415 let mut stdout = std::io::stdout().lock();
416 if opts.json {
417 let mut object = JsonObject::new();
418 object
419 .field_str("namespace", &statement.namespace.to_string())
420 .field_raw("audiences", &json_string_array(&statement.audiences))
421 .field_u64("previous", previous)
422 .field_u64("epoch", stored_epoch);
423 if revoke.is_some() {
424 object.field_u64("pruned", pruned as u64);
425 }
426 let _ = writeln!(stdout, "{}", object.finish());
427 } else {
428 let _ = writeln!(stdout, "namespace {}", statement.namespace);
429 let _ = writeln!(stdout, "audience {}", statement.audiences.join(", "));
430 let _ = writeln!(stdout, "epoch {stored_epoch} (was {previous})");
431 }
432 if revoke.is_some() {
433 eprintln!(
434 "grants issued at epoch {previous} or lower no longer work. Issue replacements with `mkit grant create --epoch {stored_epoch} ...`{}",
435 if pruned > 0 {
436 format!("; removed {pruned} local grant(s)")
437 } else {
438 String::new()
439 }
440 );
441 }
442 exit::OK
443}
444
445fn set_epoch_error(error_value: &mkit_core::protocol::TransportError) -> u8 {
446 use mkit_core::protocol::TransportError;
447 let hint = match error_value {
448 TransportError::AccessDenied => {
449 " (the server rejected the statement: a step over 1024, a decrease, a wrong audience, an expired statement, a bad signature, or a namespace this deployment doesn't serve)"
450 }
451 _ => "",
452 };
453 error(&format!("SetGrantEpoch: {error_value}{hint}"), exit::NOPERM)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::grants::store::GrantStore;
460 use crate::grants::testutil::signed_grant;
461
462 fn stored(dir: &std::path::Path, nonce: u8, epoch: u64, audiences: &[&str]) -> StoredGrant {
463 let header = signed_grant(1, nonce, epoch, audiences[0]);
466 let store = GrantStore::at(dir.to_path_buf());
467 store.add(&header, &[]).unwrap();
468 let mut g = store
469 .load(&[])
470 .grants
471 .into_iter()
472 .find(|g| g.grant.epoch == epoch && g.header == header)
473 .unwrap();
474 g.grant.audiences = audiences.iter().map(|a| (*a).to_owned()).collect();
475 g
476 }
477
478 #[test]
479 fn a_bump_only_kills_grants_it_covers_in_full() {
480 let tmp = tempfile::tempdir().unwrap();
481 let a = "https://a.example";
482 let b = "https://b.example";
483 let all = vec![
484 stored(tmp.path(), 1, 0, &[a]),
485 stored(tmp.path(), 2, 1, &[a, b]),
486 stored(tmp.path(), 3, 2, &[b]),
487 stored(tmp.path(), 4, 5, &[a]),
488 ];
489 let namespace = all[0].grant.namespace;
490 let hit = invalidated(&all, &namespace, &[a.to_owned()], 3);
491 assert_eq!(hit.dead.len(), 1);
492 assert_eq!(hit.dead[0].grant.epoch, 0);
493 assert_eq!(hit.partial.len(), 1);
495 assert_eq!(hit.partial[0].grant.epoch, 1);
496 let hit = invalidated(&all, &namespace, &[a.to_owned(), b.to_owned()], 3);
498 assert_eq!(hit.dead.len(), 3);
499 assert!(hit.partial.is_empty());
500 }
501}