radicle-cli: more helpful error for non-delegate updates

When a non-delegate attempts to update the identity document, their
action will be rejected.

Provide a better error, along with a hint, to the non-delegate.
This commit is contained in:
Adrian Duke 2026-01-14 18:24:51 +00:00 committed by Fintan Halpenny
parent 60959f7e83
commit b937a93892
4 changed files with 113 additions and 2 deletions

View File

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`tags`, `notes`, `rad`, and `cobs`. The restriction is lifted, and the only `tags`, `notes`, `rad`, and `cobs`. The restriction is lifted, and the only
references filtered out are `refs/tmp/heads` used by `radicle-remote-helper` references filtered out are `refs/tmp/heads` used by `radicle-remote-helper`
to create temporary patches. to create temporary patches.
- The `rad id` command will provide a better error message when a non-delegate
attempts to modify the identity document.
## Fixed Bugs ## Fixed Bugs

View File

@ -0,0 +1,8 @@
Alice has created a new repository `rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji` with only herself as the sole delegate. After Bob has cloned it, let's ensure he can't add himself as a delegate too:
``` ~bob (fail)
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add myself!" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm
✗ Error: did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk is not a delegate, and only delegates are allowed to create a revision
✗ Hint: bob (you) is attempting to modify the identity document but is not a delegate!
```

View File

@ -19,6 +19,7 @@ use crate::git::unified_diff::Encode as _;
use crate::git::Rev; use crate::git::Rev;
use crate::terminal as term; use crate::terminal as term;
use crate::terminal::args::Error; use crate::terminal::args::Error;
use crate::terminal::format::Author;
use crate::terminal::patch::Message; use crate::terminal::patch::Message;
pub use args::Args; pub use args::Args;
@ -193,7 +194,14 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
return Ok(()); return Ok(());
} }
let signer = term::signer(&profile)?; let signer = term::signer(&profile)?;
let revision = update(title, description, proposal, &mut identity, &signer)?; let revision = update(
title,
description,
proposal,
&mut identity,
&signer,
&profile,
)?;
if revision.is_accepted() && revision.parent == Some(current.id) { if revision.is_accepted() && revision.parent == Some(current.id) {
// Update the canonical head to point to the latest accepted revision. // Update the canonical head to point to the latest accepted revision.
@ -420,13 +428,16 @@ fn update<R, G>(
doc: Doc, doc: Doc,
current: &mut IdentityMut<R>, current: &mut IdentityMut<R>,
signer: &Device<G>, signer: &Device<G>,
profile: &Profile,
) -> anyhow::Result<Revision> ) -> anyhow::Result<Revision>
where where
R: WriteRepository + cob::Store<Namespace = NodeId>, R: WriteRepository + cob::Store<Namespace = NodeId>,
G: crypto::signature::Signer<crypto::Signature>, G: crypto::signature::Signer<crypto::Signature>,
{ {
if let Some((title, description)) = edit_title_description(title, description)? { if let Some((title, description)) = edit_title_description(title, description)? {
let id = current.update(title, description, &doc, signer)?; let id = current
.update(title, description, &doc, signer)
.map_err(|e| on_identity_err(e, profile))?;
let revision = current let revision = current
.revision(&id) .revision(&id)
.ok_or(anyhow!("update failed: revision {id} is missing"))?; .ok_or(anyhow!("update failed: revision {id} is missing"))?;
@ -437,6 +448,46 @@ where
} }
} }
fn on_identity_err(e: identity::Error, profile: &Profile) -> anyhow::Error {
let e = anyhow::Error::from(e);
e.chain()
.find_map(|c| c.downcast_ref::<identity::ApplyError>())
.map(|e| on_apply_err(e, profile))
.unwrap_or(e)
}
fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
match e {
e @ identity::ApplyError::NonDelegateUnauthorized { author, .. } => {
let nid = NodeId::from(*author);
let labels = Author::new(&nid, profile, false).labels();
Error::with_hint(
anyhow!(e.to_string()),
format!(
"{} {} is attempting to modify the identity document but is not a delegate!",
labels.0, labels.1
),
)
.into()
}
e @ radicle::cob::identity::ApplyError::Missing(_)
| e @ radicle::cob::identity::ApplyError::Init(_)
| e @ radicle::cob::identity::ApplyError::InvalidSignature(..)
| e @ radicle::cob::identity::ApplyError::NotAuthorized
| e @ radicle::cob::identity::ApplyError::MissingParent
| e @ radicle::cob::identity::ApplyError::DuplicateVerdict
| e @ radicle::cob::identity::ApplyError::UnexpectedState
| e @ radicle::cob::identity::ApplyError::Redacted
| e @ radicle::cob::identity::ApplyError::DocUnchanged
| e @ radicle::cob::identity::ApplyError::Git(_)
| e @ radicle::cob::identity::ApplyError::Doc(_) => {
anyhow!(e.to_string())
}
}
}
fn print_diff( fn print_diff(
previous: Option<&RevisionId>, previous: Option<&RevisionId>,
current: &RevisionId, current: &RevisionId,

View File

@ -602,6 +602,56 @@ fn rad_id_multi_delegate() {
.unwrap(); .unwrap();
} }
#[test]
fn rad_id_unauthorized_delegate() {
let mut environment = Environment::new();
let alice = environment.node("alice");
let bob = environment.node("bob");
let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
environment.repository(&alice);
test(
"examples/rad-init.md",
environment.work(&alice),
Some(&alice.home),
[],
)
.unwrap();
let mut alice = alice.spawn();
let mut bob = bob.spawn();
// Alice sets up the seed
alice.handle.seed(acme, Scope::Followed).unwrap();
bob.connect(&alice).converge([&alice]);
bob.rad(
"clone",
&[acme.to_string().as_str()],
environment.work(&bob),
)
.unwrap();
formula(
&environment.tempdir(),
"examples/rad-id-unauthorized-delegate.md",
)
.unwrap()
.home(
"alice",
environment.work(&alice),
[("RAD_HOME", alice.home.path().display())],
)
.home(
"bob",
environment.work(&bob),
[("RAD_HOME", bob.home.path().display())],
)
.run()
.unwrap();
}
#[test] #[test]
#[ignore = "slow"] #[ignore = "slow"]
fn rad_id_collaboration() { fn rad_id_collaboration() {