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:
parent
d7170ff9e7
commit
aa05f676f7
|
|
@ -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"
|
||||
]
|
||||
```
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)| {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue