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;
|
pub mod rad_checkout;
|
||||||
#[path = "commands/clone.rs"]
|
#[path = "commands/clone.rs"]
|
||||||
pub mod rad_clone;
|
pub mod rad_clone;
|
||||||
|
#[path = "commands/delegate.rs"]
|
||||||
|
pub mod rad_delegate;
|
||||||
#[path = "commands/edit.rs"]
|
#[path = "commands/edit.rs"]
|
||||||
pub mod rad_edit;
|
pub mod rad_edit;
|
||||||
#[path = "commands/help.rs"]
|
#[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(),
|
args.to_vec(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"delegate" => {
|
||||||
|
term::run_command_args::<rad_delegate::Options, _>(
|
||||||
|
rad_delegate::HELP,
|
||||||
|
"Delegate",
|
||||||
|
rad_delegate::run,
|
||||||
|
args.to_vec(),
|
||||||
|
);
|
||||||
|
}
|
||||||
"edit" => {
|
"edit" => {
|
||||||
term::run_command_args::<rad_edit::Options, _>(
|
term::run_command_args::<rad_edit::Options, _>(
|
||||||
rad_edit::HELP,
|
rad_edit::HELP,
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,18 @@ fn rad_init() {
|
||||||
|
|
||||||
test("examples/rad-init.md", Some(&profile)).unwrap();
|
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();
|
.unwrap();
|
||||||
|
|
||||||
// Add Bob as a delegate, and sign it.
|
// Add Bob as a delegate, and sign it.
|
||||||
doc.delegate(*bob.public_key());
|
doc.delegate(bob.public_key());
|
||||||
doc.threshold = 2;
|
doc.threshold = 2;
|
||||||
doc.sign(&alice)
|
doc.sign(&alice)
|
||||||
.and_then(|(_, sig)| {
|
.and_then(|(_, sig)| {
|
||||||
|
|
@ -227,7 +227,7 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Add Eve as a delegate, and sign it.
|
// Add Eve as a delegate, and sign it.
|
||||||
doc.delegate(*eve.public_key());
|
doc.delegate(eve.public_key());
|
||||||
doc.sign(&alice)
|
doc.sign(&alice)
|
||||||
.and_then(|(_, alice_sig)| {
|
.and_then(|(_, alice_sig)| {
|
||||||
doc.sign(&bob).and_then(|(_, bob_sig)| {
|
doc.sign(&bob).and_then(|(_, bob_sig)| {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ pub enum DidError {
|
||||||
PublicKey(#[from] crypto::PublicKeyError),
|
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")]
|
#[serde(into = "String", try_from = "String")]
|
||||||
pub struct Did(crypto::PublicKey);
|
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 {
|
impl From<crypto::PublicKey> for Did {
|
||||||
fn from(key: crypto::PublicKey) -> Self {
|
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;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::ops::Deref;
|
use std::ops::{Deref, Not};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
|
|
@ -150,6 +150,10 @@ impl<V> Doc<V> {
|
||||||
repo.blob_at(commit, Path::new(&*PATH))
|
repo.blob_at(commit, Path::new(&*PATH))
|
||||||
.map_err(DocError::from)
|
.map_err(DocError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool {
|
||||||
|
self.delegates.contains(&key.into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Doc<Verified> {
|
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.
|
/// 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);
|
let delegate = Did::from(key);
|
||||||
if self.delegates.iter().all(|id| id != &delegate) {
|
if self.delegates.iter().all(|id| id != &delegate) {
|
||||||
self.delegates.push(delegate);
|
self.delegates.push(delegate);
|
||||||
|
|
@ -174,6 +178,24 @@ impl Doc<Verified> {
|
||||||
false
|
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.
|
/// Get the project payload, if it exists and is valid, out of this document.
|
||||||
pub fn project(&self) -> Result<Project, PayloadError> {
|
pub fn project(&self) -> Result<Project, PayloadError> {
|
||||||
let value = self
|
let value = self
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue