cli: add delegate cli

Adds a `rad delegate` CLI command.

The command consists of three subcommands:
* `add` -- add a new delegate to the set of delegates of a project
* `remove` -- remove an existing delegate from the set of delegates of a
  project
* `list` -- list the delegates of a project

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
This commit is contained in:
Fintan Halpenny 2022-11-30 14:09:07 +00:00 committed by Alexis Sellier
parent d7170ff9e7
commit aa05f676f7
No known key found for this signature in database
11 changed files with 367 additions and 6 deletions

View File

@ -0,0 +1,46 @@
Delegates are the authorized keys that can manage a project's
metadata, including adding a new delegate.
Let's list the current set of delegates for a project.
```
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
[
"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
]
```
We want to add a new maintainer to the project to help out with the
work.
```
$ rad delegate add z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'
ok Update successful!
```
Let's convince ourselves that there's another delegate.
```
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
[
"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
"did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
]
```
And finally, we no longer want to be part of the project so we pass on
the torch and remove ourselves from the delegate set.
```
$ rad delegate remove z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
ok Update successful!
```
```
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
[
"did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
]
```

View File

@ -6,6 +6,8 @@ pub mod rad_auth;
pub mod rad_checkout;
#[path = "commands/clone.rs"]
pub mod rad_clone;
#[path = "commands/delegate.rs"]
pub mod rad_delegate;
#[path = "commands/edit.rs"]
pub mod rad_edit;
#[path = "commands/help.rs"]

View File

@ -0,0 +1,141 @@
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::{anyhow, Context as _};
use radicle::identity::Id;
use radicle_crypto::PublicKey;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
#[path = "delegate/add.rs"]
mod add;
#[path = "delegate/list.rs"]
mod list;
#[path = "delegate/remove.rs"]
mod remove;
pub const HELP: Help = Help {
name: "delegate",
description: "Manage the delegates of an identity",
version: env!("CARGO_PKG_VERSION"),
usage: r#"
Usage
rad delegate (add|remove) <public key> [--to <id>]
rad delegate list [<id>]
The `add` and `remove` commands are limited to managing delegates
where the `threshold` for the quorum is exactly `1`. Otherwise,
the verification of the document will not be able to gather enough
signatures to pass the quorum.
Options
--help Print help
"#,
};
#[derive(Debug, Default, PartialEq, Eq)]
pub enum OperationName {
Add,
Remove,
#[default]
List,
}
#[derive(Debug, Eq, PartialEq)]
pub enum Operation {
Add { id: Option<Id>, key: PublicKey },
Remove { id: Option<Id>, key: PublicKey },
List { id: Option<Id> },
}
#[derive(Debug, Eq, PartialEq)]
pub struct Options {
pub op: Operation,
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_args(args);
let mut id: Option<Id> = None;
let mut op: Option<OperationName> = None;
let mut key: Option<PublicKey> = None;
while let Some(arg) = parser.next()? {
match arg {
Long("help") => {
return Err(Error::Help.into());
}
Long("to") => {
id = Some(parser.value()?.parse::<Id>()?);
}
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"a" | "add" => op = Some(OperationName::Add),
"r" | "remove" => op = Some(OperationName::Remove),
"l" | "list" => op = Some(OperationName::List),
unknown => anyhow::bail!("unknown operation '{}'", unknown),
},
Value(val) if op.is_some() => {
let val = val.to_string_lossy();
match op {
Some(OperationName::Add) | Some(OperationName::Remove) => {
if let Ok(val) = PublicKey::from_str(&val) {
key = Some(val);
} else {
return Err(anyhow!("invalid Public Key '{}'", val));
}
}
Some(OperationName::List) => {
if let Ok(val) = Id::from_str(&val) {
id = Some(val);
} else {
return Err(anyhow!("invalid Project ID '{}'", val));
}
}
None => continue,
}
}
_ => return Err(anyhow!(arg.unexpected())),
}
}
let op = match op.unwrap_or_default() {
OperationName::List => Operation::List { id },
OperationName::Add => Operation::Add {
id,
key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?,
},
OperationName::Remove => Operation::Remove {
id,
key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?,
},
};
Ok((Options { op }, vec![]))
}
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let storage = &profile.storage;
match options.op {
Operation::Add { id, key } => add::run(&profile, storage, get_id(id)?, key)?,
Operation::Remove { id, key } => remove::run(&profile, storage, get_id(id)?, &key)?,
Operation::List { id } => list::run(&profile, storage, get_id(id)?)?,
}
Ok(())
}
fn get_id(id: Option<Id>) -> anyhow::Result<Id> {
id.or_else(|| radicle::rad::cwd().ok().map(|(_, id)| id))
.context("Couldn't get ID from either command line or cwd")
}

View File

@ -0,0 +1,51 @@
use anyhow::Context as _;
use radicle::{
prelude::{Did, Id},
storage::{WriteRepository as _, WriteStorage},
Profile,
};
use radicle_crypto::PublicKey;
use crate::terminal as term;
pub fn run<S>(profile: &Profile, storage: &S, id: Id, key: PublicKey) -> anyhow::Result<()>
where
S: WriteStorage,
{
let signer = term::signer(profile)?;
let me = signer.public_key();
let mut project = storage
.get(&profile.public_key, id)?
.context("No project with such ID exists")?;
let repo = storage.repository(id)?;
if !project.is_delegate(me) {
return Err(anyhow::anyhow!(
"'{}' is not a delegate of the project, only a delegate may add this key",
me
));
}
if project.threshold > 1 {
return Err(anyhow::anyhow!("project threshold > 1"));
}
if project.delegate(&key) {
project.sign(&signer).and_then(|(_, sig)| {
project.update(
signer.public_key(),
"Updated payload",
&[(signer.public_key(), sig)],
repo.raw(),
)
})?;
term::info!("Added delegate '{}'", Did::from(key));
term::success!("Update successful!");
Ok(())
} else {
term::info!("the delegate for '{}' already exists", key);
Ok(())
}
}

View File

@ -0,0 +1,16 @@
use anyhow::Context as _;
use radicle::{prelude::Id, storage::ReadStorage, Profile};
use crate::terminal as term;
pub fn run<S>(profile: &Profile, storage: &S, id: Id) -> anyhow::Result<()>
where
S: ReadStorage,
{
let project = storage
.get(&profile.public_key, id)?
.context("No project with such ID exists")?;
term::info!("{}", serde_json::to_string_pretty(&project.delegates)?);
Ok(())
}

View File

@ -0,0 +1,54 @@
use anyhow::Context as _;
use radicle::{
prelude::Id,
storage::{WriteRepository as _, WriteStorage},
Profile,
};
use radicle_crypto::PublicKey;
use crate::terminal as term;
pub fn run<S>(profile: &Profile, storage: &S, id: Id, key: &PublicKey) -> anyhow::Result<()>
where
S: WriteStorage,
{
let signer = term::signer(profile)?;
let me = signer.public_key();
let mut project = storage
.get(&profile.public_key, id)?
.context("No project with such ID exists")?;
let repo = storage.repository(id)?;
if !project.is_delegate(me) {
return Err(anyhow::anyhow!(
"'{}' is not a delegate of the project, only a delegate may remove this key",
me
));
}
if project.threshold > 1 {
return Err(anyhow::anyhow!("project threshold > 1"));
}
match project.rescind(key)? {
Some(delegate) => {
project.sign(&signer).and_then(|(_, sig)| {
project.update(
signer.public_key(),
"Updated payload",
&[(signer.public_key(), sig)],
repo.raw(),
)
})?;
term::info!("Removed delegate '{}'", delegate);
term::success!("Update successful!");
Ok(())
}
None => {
term::info!("the delegate for '{}' did not exist", key);
Ok(())
}
}
}

View File

@ -134,6 +134,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
args.to_vec(),
);
}
"delegate" => {
term::run_command_args::<rad_delegate::Options, _>(
rad_delegate::HELP,
"Delegate",
rad_delegate::run,
args.to_vec(),
);
}
"edit" => {
term::run_command_args::<rad_edit::Options, _>(
rad_edit::HELP,

View File

@ -72,3 +72,18 @@ fn rad_init() {
test("examples/rad-init.md", Some(&profile)).unwrap();
}
#[test]
fn rad_delegate() {
let home = tempfile::tempdir().unwrap();
let working = tempfile::tempdir().unwrap();
let profile = profile(home.path());
// Setup a test repository.
fixtures::repository(working.path());
// Navigate to repository.
env::set_current_dir(working.path()).unwrap();
test("examples/rad-init.md", Some(&profile)).unwrap();
test("examples/rad-delegate.md", Some(&profile)).unwrap();
}

View File

@ -213,7 +213,7 @@ mod test {
.unwrap();
// Add Bob as a delegate, and sign it.
doc.delegate(*bob.public_key());
doc.delegate(bob.public_key());
doc.threshold = 2;
doc.sign(&alice)
.and_then(|(_, sig)| {
@ -227,7 +227,7 @@ mod test {
.unwrap();
// Add Eve as a delegate, and sign it.
doc.delegate(*eve.public_key());
doc.delegate(eve.public_key());
doc.sign(&alice)
.and_then(|(_, alice_sig)| {
doc.sign(&bob).and_then(|(_, bob_sig)| {

View File

@ -14,7 +14,7 @@ pub enum DidError {
PublicKey(#[from] crypto::PublicKeyError),
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy)]
#[serde(into = "String", try_from = "String")]
pub struct Did(crypto::PublicKey);
@ -34,9 +34,15 @@ impl Did {
}
}
impl From<&crypto::PublicKey> for Did {
fn from(key: &crypto::PublicKey) -> Self {
Self(*key)
}
}
impl From<crypto::PublicKey> for Did {
fn from(key: crypto::PublicKey) -> Self {
Self(key)
(&key).into()
}
}

View File

@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::fmt::Write as _;
use std::marker::PhantomData;
use std::ops::Deref;
use std::ops::{Deref, Not};
use std::path::Path;
use nonempty::NonEmpty;
@ -150,6 +150,10 @@ impl<V> Doc<V> {
repo.blob_at(commit, Path::new(&*PATH))
.map_err(DocError::from)
}
pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool {
self.delegates.contains(&key.into())
}
}
impl Doc<Verified> {
@ -165,7 +169,7 @@ impl Doc<Verified> {
}
/// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before.
pub fn delegate(&mut self, key: crypto::PublicKey) -> bool {
pub fn delegate(&mut self, key: &crypto::PublicKey) -> bool {
let delegate = Did::from(key);
if self.delegates.iter().all(|id| id != &delegate) {
self.delegates.push(delegate);
@ -174,6 +178,24 @@ impl Doc<Verified> {
false
}
pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result<Option<Did>, DocError> {
let delegate = Did::from(key);
let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate);
match NonEmpty::from_vec(delegates) {
Some(delegates) => {
self.delegates = delegates;
if self.threshold > self.delegates.len() {
return Err(DocError::Threshold(
self.threshold,
"the thresholds exceeds the new delegate count after removal",
));
}
Ok(matches.is_empty().not().then_some(delegate))
}
None => Err(DocError::Delegates("cannot remove the last delegate")),
}
}
/// Get the project payload, if it exists and is valid, out of this document.
pub fn project(&self) -> Result<Project, PayloadError> {
let value = self