cob: Implement action authorization

Add `authorization` functions to `Patch` and `Issue` COBs.

Co-authored-by: Arastoo Bozorgi <ab1502@mun.ca>
This commit is contained in:
cloudhead 2023-09-07 11:44:48 +02:00
parent f760b1153f
commit c903a958e7
No known key found for this signature in database
10 changed files with 641 additions and 262 deletions

View File

@ -26,51 +26,11 @@ $ rad issue list
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
```
Great! Now we've documented the issue for ourselves and others.
Just like with other project management systems, the issue can be assigned to
others to work on. This is to ensure work is not duplicated.
Let's assign this issue to ourself.
```
$ rad assign 2f6eb49efac492327f71437b6bfc01b49afa0981 --to did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
```
It will now show in the list of issues assigned to us.
```
$ rad issue list --assigned
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ● ID Title Author Labels Assignees Opened │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ● 2f6eb49 flux capacitor underpowered bob (you) bob [ .. ] │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
```
In addition, you can see that when you run `rad issue show` you are listed under the `Assignees`.
```
$ rad issue show 2f6eb49
╭─────────────────────────────────────────────────────────╮
│ Title flux capacitor underpowered │
│ Issue 2f6eb49efac492327f71437b6bfc01b49afa0981 │
│ Author bob (you) │
│ Assignees z6Mkt67…v4N1tRk │
│ Status open │
│ │
│ Flux capacitor power requirements exceed current supply │
╰─────────────────────────────────────────────────────────╯
```
Note: this can always be undone with the `unassign` subcommand.
Great, now we have communicated to the world about our car's defect.
But wait! We've found an important detail about the car's power requirements.
It will help whoever works on a fix.
Great! Now we've documented the issue for ourselves and others. But wait, we've
found an important detail about the car's power requirements. It will help
whoever works on a fix.
```
$ rad comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts'
24ab347afda760e77d565f9cb013c6db560f44fd
d2b50873009b93680698aef4f57f43f7e850b651
```

View File

@ -60,12 +60,11 @@ impl History {
/// accumulator value of type `A`. However, unlike `fold` the function `f`
/// may prune branches from the dependency graph by returning
/// `ControlFlow::Break`.
pub fn traverse<F, A>(&self, init: A, mut f: F) -> A
pub fn traverse<F, A>(&self, init: A, roots: &[EntryId], mut f: F) -> A
where
F: for<'r> FnMut(A, &'r EntryId, &'r Entry) -> ControlFlow<A, A>,
{
self.graph
.fold(&[self.root], init, |acc, k, v| f(acc, k, v))
self.graph.fold(roots, init, |acc, k, v| f(acc, k, v))
}
/// Return a topologically-sorted list of history entries.
@ -114,4 +113,12 @@ impl History {
.get(&self.root)
.expect("History::root: the root entry must be present in the graph")
}
/// Get the children of the given entry.
pub fn children_of(&self, id: &EntryId) -> Vec<EntryId> {
self.graph
.get(id)
.map(|n| n.dependents.iter().cloned().collect())
.unwrap_or_default()
}
}

View File

@ -211,21 +211,26 @@ fn traverse_cobs() {
)
.unwrap();
let root = object.history.root().id;
// traverse over the history and filter by changes that were only authorized by terry
let contents = object.history().traverse(Vec::new(), |mut acc, _, entry| {
if entry.author() == terry_signer.public_key() {
acc.push(entry.contents().head.clone());
}
ControlFlow::Continue(acc)
});
let contents = object
.history()
.traverse(Vec::new(), &[root], |mut acc, _, entry| {
if entry.author() == terry_signer.public_key() {
acc.push(entry.contents().head.clone());
}
ControlFlow::Continue(acc)
});
assert_eq!(contents, vec![b"issue 1".to_vec()]);
// traverse over the history and filter by changes that were only authorized by neil
let contents = object.history().traverse(Vec::new(), |mut acc, _, entry| {
acc.push(entry.contents().head.clone());
ControlFlow::Continue(acc)
});
let contents = object
.history()
.traverse(Vec::new(), &[root], |mut acc, _, entry| {
acc.push(entry.contents().head.clone());
ControlFlow::Continue(acc)
});
assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]);
}

View File

@ -24,6 +24,10 @@ impl Author {
pub fn id(&self) -> &Did {
&self.id
}
pub fn public_key(&self) -> &PublicKey {
self.id.as_key()
}
}
impl From<PublicKey> for Author {
@ -278,6 +282,27 @@ impl std::str::FromStr for Uri {
}
}
/// The result of an authorization check on an COB action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Authorization {
/// Action is allowed.
Allow,
/// Action is denied.
Deny,
/// Authorization cannot be determined due to missing object, eg. due to redaction.
Unknown,
}
impl From<bool> for Authorization {
fn from(value: bool) -> Self {
if value {
Self::Allow
} else {
Self::Deny
}
}
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -323,11 +323,10 @@ impl store::FromHistory for Proposal {
&TYPENAME
}
fn validate(&self) -> Result<(), Self::Error> {
if self.revisions.is_empty() {
return Err(ApplyError::Validate("no revisions found"));
}
Ok(())
fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let mut identity = Self::default();
identity.apply(op, repo)?;
Ok(identity)
}
fn apply<R: ReadRepository>(&mut self, op: Op, _repo: &R) -> Result<(), Self::Error> {

View File

@ -7,15 +7,16 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::cob;
use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri};
use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp, Uri};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread};
use crate::cob::thread::{Comment, CommentId, Thread};
use crate::cob::{store, ActorId, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::git;
use crate::prelude::{Did, ReadRepository};
use crate::identity::doc::{Doc, DocError};
use crate::prelude::{Did, ReadRepository, Verified};
use crate::storage::WriteRepository;
/// Issue operation.
@ -31,16 +32,23 @@ pub type IssueId = ObjectId;
/// Error updating or creating issues.
#[derive(Error, Debug)]
pub enum Error {
#[error("apply failed")]
Apply,
#[error("validation failed: {0}")]
Validate(&'static str),
/// Error loading the identity document.
#[error("identity doc failed to load: {0}")]
Doc(#[from] DocError),
#[error("description missing")]
DescriptionMissing,
#[error("thread apply failed: {0}")]
Thread(#[from] thread::Error),
#[error("store: {0}")]
Store(#[from] store::Error),
#[error("history: {0}")]
History(Box<dyn std::error::Error + Sync + Send + 'static>),
/// Action not authorized.
#[error("{0} not authorized to apply {1:?}")]
NotAuthorized(ActorId, Action),
/// General error initializing an issue.
#[error("initialization failed: {0}")]
Init(&'static str),
}
/// Reason why an issue was closed.
@ -91,7 +99,7 @@ impl State {
}
/// Issue state. Accumulates [`Action`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
/// Actors assigned to this issue.
pub(super) assignees: BTreeSet<Did>,
@ -113,25 +121,52 @@ impl store::FromHistory for Issue {
&TYPENAME
}
fn validate(&self) -> Result<(), Self::Error> {
if self.title.is_empty() {
return Err(Error::Validate("title is empty"));
fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let mut actions = op.actions.into_iter();
let Some(Action::Comment { body, reply_to: None, embeds }) = actions.next() else {
return Err(Error::Init("the first action must be of type `comment`"));
};
let comment = Comment::new(op.author, body, None, None, embeds, op.timestamp);
let thread = Thread::new(op.id, comment);
let mut issue = Issue::new(thread);
for action in actions {
issue.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
}
if self.thread.validate().is_err() {
return Err(Error::Validate("invalid thread"));
}
Ok(())
Ok(issue)
}
fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
let doc = repo.identity_doc_at(op.identity)?.verified()?;
for action in op.actions {
self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
match self.authorization(&action, &op.author, &doc) {
Authorization::Allow => {
self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
}
Authorization::Deny => {
return Err(Error::NotAuthorized(op.author, action));
}
Authorization::Unknown => {
continue;
}
}
}
Ok(())
}
}
impl Issue {
/// Construct a new issue.
pub fn new(thread: Thread) -> Self {
Self {
assignees: BTreeSet::default(),
title: String::default(),
state: State::default(),
labels: BTreeSet::default(),
thread,
}
}
pub fn assigned(&self) -> impl Iterator<Item = &Did> + '_ {
self.assignees.iter()
}
@ -180,6 +215,46 @@ impl Issue {
pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
self.thread.comments()
}
/// Apply authorization rules on issue actions.
pub fn authorization(
&self,
action: &Action,
actor: &ActorId,
doc: &Doc<Verified>,
) -> Authorization {
if doc.is_delegate(actor) {
// A delegate is authorized to do all actions.
return Authorization::Allow;
}
let author: ActorId = *self.author().id().as_key();
match action {
// Only delegate can assign someone to an issue.
Action::Assign { .. } => Authorization::Deny,
// Issue authors can edit their own issues.
Action::Edit { .. } => Authorization::from(*actor == author),
// Issue authors can close or re-open their own issue.
Action::Lifecycle { state } => Authorization::from(match state {
State::Closed { .. } => *actor == author,
State::Open => *actor == author,
}),
// Only delegate can label an issue.
Action::Label { .. } => Authorization::Deny,
// All roles can comment on an issues
Action::Comment { .. } => Authorization::Allow,
// All roles can edit or redact their own comments.
Action::CommentEdit { id, .. } | Action::CommentRedact { id, .. } => {
if let Some(comment) = self.comment(id) {
Authorization::from(*actor == comment.author())
} else {
Authorization::Unknown
}
}
// All roles can react to a comment on an issue.
Action::CommentReact { .. } => Authorization::Allow,
}
}
}
impl Issue {

View File

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::cob;
use crate::cob::common::{Author, Label, Reaction, Timestamp};
use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
@ -105,18 +105,20 @@ pub enum Error {
/// Error loading the document payload.
#[error("payload failed to load: {0}")]
Payload(#[from] PayloadError),
/// The merge operation is invalid.
#[error("invalid merge operation in {0}")]
InvalidMerge(EntryId),
/// Git error.
#[error("git: {0}")]
Git(#[from] git::ext::Error),
/// Validation error.
#[error("validation failed: {0}")]
Validate(&'static str),
/// Store error.
#[error("store: {0}")]
Store(#[from] store::Error),
#[error("history error: {0}")]
History(Box<dyn std::error::Error + Sync + Send + 'static>),
/// Action not authorized by the author
#[error("{0} not authorized to apply {1:?}")]
NotAuthorized(ActorId, Action),
/// Initialization failed.
#[error("initialization failed: {0}")]
Init(&'static str),
}
/// Patch operation.
@ -341,7 +343,7 @@ impl MergeTarget {
}
/// Patch state.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
/// Title of the patch.
pub(super) title: String,
@ -374,6 +376,24 @@ pub struct Patch {
}
impl Patch {
/// Create a valid patch
pub fn new(
title: String,
target: MergeTarget,
(revision_id, revision): (RevisionId, Revision),
) -> Self {
Self {
title,
state: State::default(),
target,
labels: BTreeSet::default(),
merges: BTreeMap::default(),
revisions: BTreeMap::from([(revision_id, Some(revision))]),
assignees: BTreeSet::default(),
timeline: vec![revision_id.into_inner()],
reviews: BTreeMap::default(),
}
}
/// Title of the patch.
pub fn title(&self) -> &str {
self.title.as_str()
@ -523,6 +543,118 @@ impl Patch {
pub fn is_draft(&self) -> bool {
matches!(self.state(), State::Draft)
}
/// Apply authorization rules on patch actions.
pub fn authorization(
&self,
action: &Action,
actor: &ActorId,
doc: &Doc<Verified>,
) -> Result<Authorization, Error> {
if doc.is_delegate(actor) {
// A delegate is authorized to do all actions.
return Ok(Authorization::Allow);
}
let author = self.author().id().as_key();
let outcome = match action {
// The patch author can edit the patch and change its state.
Action::Edit { .. } => Authorization::from(actor == author),
Action::Lifecycle { state } => Authorization::from(match state {
Lifecycle::Open { .. } => actor == author,
Lifecycle::Draft { .. } => actor == author,
Lifecycle::Archived { .. } => actor == author,
}),
// Only delegates can carry out these actions.
Action::Label { .. } => Authorization::Deny,
Action::Assign { .. } => Authorization::Deny,
Action::Merge { .. } => match self.target() {
MergeTarget::Delegates => Authorization::Deny,
},
// Anyone can submit a review.
Action::Review { .. } => Authorization::Allow,
// The review author can edit a review.
Action::ReviewEdit { review, .. } => {
if let Some((_, review)) = lookup::review(self, review)? {
Authorization::from(actor == review.author.public_key())
} else {
// Redacted.
Authorization::Unknown
}
}
Action::ReviewRedact { review, .. } => {
if let Some((_, review)) = lookup::review(self, review)? {
Authorization::from(actor == review.author.public_key())
} else {
// Redacted.
Authorization::Unknown
}
}
// Anyone can comment on a review.
Action::ReviewComment { .. } => Authorization::Allow,
// The comment author can edit and redact their own comment.
Action::ReviewCommentEdit {
review, comment, ..
}
| Action::ReviewCommentRedact { review, comment } => {
if let Some((_, review)) = lookup::review(self, review)? {
if let Some(comment) = review.comments.comment(comment) {
return Ok(Authorization::from(*actor == comment.author()));
}
}
// Redacted.
Authorization::Unknown
}
// Anyone can react to a review comment.
Action::ReviewCommentReact { .. } => Authorization::Allow,
// The reviewer, commenter or revision author can resolve and unresolve review comments.
Action::ReviewCommentResolve { review, comment }
| Action::ReviewCommentUnresolve { review, comment } => {
if let Some((revision, review)) = lookup::review(self, review)? {
if let Some(comment) = review.comments.comment(comment) {
return Ok(Authorization::from(
actor == &comment.author()
|| actor == review.author.public_key()
|| actor == revision.author.public_key(),
));
}
}
// Redacted.
Authorization::Unknown
}
// Only patch authors can propose revisions.
Action::Revision { .. } => Authorization::from(actor == author),
// Only the revision author can edit or redact their revision.
Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
if let Some(revision) = lookup::revision(self, revision)? {
Authorization::from(actor == revision.author.public_key())
} else {
// Redacted.
Authorization::Unknown
}
}
// Anyone can react to or comment on a revision.
Action::RevisionReact { .. } => Authorization::Allow,
Action::RevisionComment { .. } => Authorization::Allow,
// Only the comment author can edit or redact their comment.
Action::RevisionCommentEdit {
revision, comment, ..
}
| Action::RevisionCommentRedact {
revision, comment, ..
} => {
if let Some(revision) = lookup::revision(self, revision)? {
if let Some(comment) = revision.discussion.comment(comment) {
return Ok(Authorization::from(actor == &comment.author()));
}
}
// Redacted.
Authorization::Unknown
}
// Anyone can react to a revision.
Action::RevisionCommentReact { .. } => Authorization::Allow,
};
Ok(outcome)
}
}
impl Patch {
@ -533,7 +665,7 @@ impl Patch {
entry: EntryId,
author: ActorId,
timestamp: Timestamp,
identity: git::Oid,
identity: &Doc<Verified>,
repo: &R,
) -> Result<(), Error> {
match action {
@ -576,19 +708,18 @@ impl Patch {
summary,
verdict,
} => {
let Some(Some((revision, author))) =
self.reviews.get(&review) else {
return Err(Error::Missing(review.into_inner()));
};
let Some(Some((revision, author))) = self.reviews.get(&review) else {
return Err(Error::Missing(review.into_inner()));
};
let Some(rev) = self.revisions.get_mut(revision) else {
return Err(Error::Missing(revision.into_inner()));
};
return Err(Error::Missing(revision.into_inner()));
};
// If the revision was redacted concurrently, there's nothing to do.
// Likewise, if the review was redacted concurrently, there's nothing to do.
if let Some(rev) = rev {
let Some(review) = rev.reviews.get_mut(author) else {
return Err(Error::Missing(review.into_inner()));
};
return Err(Error::Missing(review.into_inner()));
};
if let Some(review) = review {
review.summary = summary;
review.verdict = verdict;
@ -621,7 +752,7 @@ impl Patch {
active,
location,
} => {
if let Some(revision) = lookup::revision(self, &revision)? {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
let key = (author, reaction);
let reactions = revision.reactions.entry(location).or_default();
@ -652,14 +783,20 @@ impl Patch {
labels,
} => {
let Some(rev) = self.revisions.get_mut(&revision) else {
return Err(Error::Missing(revision.into_inner()));
};
return Err(Error::Missing(revision.into_inner()));
};
if let Some(rev) = rev {
// Nb. Applying two reviews by the same author is not allowed and
// results in the review being redacted.
rev.reviews.insert(
author,
Some(Review::new(verdict, summary.to_owned(), labels, timestamp)),
Some(Review::new(
Author::new(author),
verdict,
summary.to_owned(),
labels,
timestamp,
)),
);
// Update reviews index.
self.reviews
@ -672,7 +809,7 @@ impl Patch {
reaction,
active,
} => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::react(
&mut review.comments,
entry,
@ -684,7 +821,7 @@ impl Patch {
}
}
Action::ReviewCommentRedact { review, comment } => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::redact(&mut review.comments, entry, comment)?;
}
}
@ -693,7 +830,7 @@ impl Patch {
comment,
body,
} => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::edit(
&mut review.comments,
entry,
@ -705,12 +842,12 @@ impl Patch {
}
}
Action::ReviewCommentResolve { review, comment } => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::resolve(&mut review.comments, entry, comment)?;
}
}
Action::ReviewCommentUnresolve { review, comment } => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::unresolve(&mut review.comments, entry, comment)?;
}
}
@ -720,7 +857,7 @@ impl Patch {
location,
reply_to,
} => {
if let Some(review) = lookup::review(self, &review)? {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::comment(
&mut review.comments,
entry,
@ -736,41 +873,36 @@ impl Patch {
Action::ReviewRedact { review } => {
// Redactions must have observed a review to be valid.
let Some(locator) = self.reviews.get_mut(&review) else {
return Err(Error::Missing(review.into_inner()));
};
return Err(Error::Missing(review.into_inner()));
};
// If the review is already redacted, do nothing.
let Some((revision, reviewer)) = locator else {
return Ok(());
};
return Ok(());
};
// The revision must have existed at some point.
let Some(redactable) = self.revisions.get_mut(revision) else {
return Err(Error::Missing(revision.into_inner()));
};
return Err(Error::Missing(revision.into_inner()));
};
// But it could be redacted.
let Some(revision) = redactable else {
return Ok(());
};
return Ok(());
};
// The review must have existed as well.
let Some(review) = revision.reviews.get_mut(reviewer) else {
return Err(Error::Missing(review.into_inner()));
};
return Err(Error::Missing(review.into_inner()));
};
// Set both the review and the locator in the review index to redacted.
*review = None;
*locator = None;
}
Action::Merge { revision, commit } => {
// If the revision was redacted before the merge, ignore the merge.
if lookup::revision(self, &revision)?.is_none() {
if lookup::revision_mut(self, &revision)?.is_none() {
return Ok(());
};
let doc = repo.identity_doc_at(identity)?.verified()?;
match self.target() {
MergeTarget::Delegates => {
if !doc.is_delegate(&author) {
return Err(Error::InvalidMerge(entry));
}
let proj = doc.project()?;
let proj = identity.project()?;
let branch = git::refs::branch(proj.default_branch());
// Nb. We don't return an error in case the merge commit is not an
@ -804,7 +936,7 @@ impl Patch {
},
);
// Discard revisions that weren't merged by a threshold of delegates.
merges.retain(|_, count| *count >= doc.threshold);
merges.retain(|_, count| *count >= identity.threshold);
match merges.into_keys().collect::<Vec<_>>().as_slice() {
[] => {
@ -832,7 +964,7 @@ impl Patch {
reply_to,
..
} => {
if let Some(revision) = lookup::revision(self, &revision)? {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::comment(
&mut revision.discussion,
entry,
@ -850,7 +982,7 @@ impl Patch {
comment,
body,
} => {
if let Some(revision) = lookup::revision(self, &revision)? {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::edit(
&mut revision.discussion,
entry,
@ -862,7 +994,7 @@ impl Patch {
}
}
Action::RevisionCommentRedact { revision, comment } => {
if let Some(revision) = lookup::revision(self, &revision)? {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::redact(&mut revision.discussion, entry, comment)?;
}
}
@ -872,7 +1004,7 @@ impl Patch {
reaction,
active,
} => {
if let Some(revision) = lookup::revision(self, &revision)? {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::react(
&mut revision.discussion,
entry,
@ -896,22 +1028,53 @@ impl store::FromHistory for Patch {
&TYPENAME
}
fn validate(&self) -> Result<(), Self::Error> {
if self.revisions.is_empty() {
return Err(Error::Validate("no revisions found"));
fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let mut actions = op.actions.into_iter();
let Some(Action::Revision { description, base, oid, resolves }) = actions.next() else {
return Err(Error::Init("the first action must be of type `revision`"));
};
let Some(Action::Edit { title, target }) = actions.next() else {
return Err(Error::Init("the second action must be of type `edit`"));
};
let revision = Revision::new(
op.author.into(),
description,
base,
oid,
op.timestamp,
resolves,
);
let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
let doc = repo.identity_doc_at(op.identity)?.verified()?;
for action in actions {
patch.action(action, op.id, op.author, op.timestamp, &doc, repo)?;
}
if self.title().is_empty() {
return Err(Error::Validate("empty title"));
}
Ok(())
Ok(patch)
}
fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
debug_assert!(!self.timeline.contains(&op.id));
self.timeline.push(op.id);
let doc = repo.identity_doc_at(op.identity)?.verified()?;
for action in op.actions {
self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
match self.authorization(&action, &op.author, &doc)? {
Authorization::Allow => {
self.action(action, op.id, op.author, op.timestamp, &doc, repo)?;
}
Authorization::Deny => {
return Err(Error::NotAuthorized(op.author, action));
}
Authorization::Unknown => {
// In this case, since there is not enough information to determine
// whether the action is authorized or not, we simply ignore it.
// It's likely that the target object was redacted, and we can't
// verify whether the action would have been allowed or not.
continue;
}
}
}
Ok(())
}
@ -921,6 +1084,19 @@ mod lookup {
use super::*;
pub fn revision<'a>(
patch: &'a Patch,
revision: &RevisionId,
) -> Result<Option<&'a Revision>, Error> {
match patch.revisions.get(revision) {
Some(Some(revision)) => Ok(Some(revision)),
// Redacted.
Some(None) => Ok(None),
// Missing. Causal error.
None => Err(Error::Missing(revision.into_inner())),
}
}
pub fn revision_mut<'a>(
patch: &'a mut Patch,
revision: &RevisionId,
) -> Result<Option<&'a mut Revision>, Error> {
@ -934,6 +1110,35 @@ mod lookup {
}
pub fn review<'a>(
patch: &'a Patch,
review: &ReviewId,
) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
match patch.reviews.get(review) {
Some(Some((revision, author))) => {
match patch.revisions.get(revision) {
Some(Some(r)) => {
let Some(review) = r.reviews.get(author) else {
return Err(Error::Missing(review.into_inner()));
};
Ok(review.as_ref().map(|review| (r, review)))
}
Some(None) => {
// If the revision was redacted concurrently, there's nothing to do.
// Likewise, if the review was redacted concurrently, there's nothing to do.
Ok(None)
}
None => Err(Error::Missing(revision.into_inner())),
}
}
Some(None) => {
// Redacted.
Ok(None)
}
None => Err(Error::Missing(review.into_inner())),
}
}
pub fn review_mut<'a>(
patch: &'a mut Patch,
review: &ReviewId,
) -> Result<Option<&'a mut Review>, Error> {
@ -942,8 +1147,8 @@ mod lookup {
match patch.revisions.get_mut(revision) {
Some(Some(r)) => {
let Some(review) = r.reviews.get_mut(author) else {
return Err(Error::Missing(review.into_inner()));
};
return Err(Error::Missing(review.into_inner()));
};
Ok(review.as_mut())
}
Some(None) => {
@ -1180,8 +1385,10 @@ pub struct CodeLocation {
}
/// A patch review on a revision.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Review {
/// Review author.
pub(super) author: Author,
/// Review verdict.
///
/// The verdict cannot be changed, since revisions are immutable.
@ -1218,12 +1425,14 @@ impl Serialize for Review {
impl Review {
pub fn new(
author: Author,
verdict: Option<Verdict>,
summary: Option<String>,
labels: Vec<Label>,
timestamp: Timestamp,
) -> Self {
Self {
author,
verdict,
summary,
comments: Thread::default(),
@ -2192,37 +2401,57 @@ mod test {
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let mut alice = Actor::new(MockSigner::default());
let mut patch = Patch::default();
let repo = gen::<MockRepository>(1);
let rid = gen::<Id>(1);
let doc = Doc::new(
gen::<Project>(1),
nonempty::NonEmpty::new(alice.did()),
1,
identity::Visibility::Public,
)
.verified()
.unwrap();
let repo = MockRepository::new(rid, doc);
let a1 = alice.op::<Patch>(Action::Revision {
description: String::new(),
let a1 = alice.op::<Patch>([
Action::Revision {
description: String::new(),
base,
oid,
resolves: Default::default(),
},
Action::Edit {
title: String::from("My patch"),
target: MergeTarget::Delegates,
},
]);
let a2 = alice.op::<Patch>([Action::Revision {
description: String::from("Second revision"),
base,
oid,
resolves: Default::default(),
});
let a2 = alice.op::<Patch>(Action::RevisionRedact {
revision: RevisionId(a1.id()),
});
let a3 = alice.op::<Patch>(Action::Review {
revision: RevisionId(a1.id()),
}]);
let a3 = alice.op::<Patch>([Action::RevisionRedact {
revision: RevisionId(a2.id()),
}]);
let a4 = alice.op::<Patch>([Action::Review {
revision: RevisionId(a2.id()),
summary: None,
verdict: Some(Verdict::Accept),
labels: vec![],
});
let a4 = alice.op::<Patch>(Action::Merge {
revision: RevisionId(a1.id()),
}]);
let a5 = alice.op::<Patch>([Action::Merge {
revision: RevisionId(a2.id()),
commit: oid,
});
}]);
patch.apply(a1, &repo).unwrap();
assert!(patch.revisions().next().is_some());
patch.apply(a2, &repo).unwrap();
assert!(patch.revisions().next().is_none());
let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
assert_eq!(patch.revisions().count(), 2);
patch.apply(a3, &repo).unwrap();
assert_eq!(patch.revisions().count(), 1);
patch.apply(a4, &repo).unwrap();
patch.apply(a5, &repo).unwrap();
}
#[test]
@ -2234,22 +2463,32 @@ mod test {
let alice = MockSigner::default();
let bob = MockSigner::default();
let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
&Action::Revision {
description: String::from("Original"),
base,
oid,
resolves: Default::default(),
},
&[
Action::Revision {
description: String::from("Original"),
base,
oid,
resolves: Default::default(),
},
Action::Edit {
title: String::from("Some patch"),
target: MergeTarget::Delegates,
},
],
time,
&alice,
);
h0.commit(
&Action::Edit {
title: String::from("Some patch"),
target: MergeTarget::Delegates,
&Action::Revision {
description: String::from("New"),
base,
oid,
resolves: Default::default(),
},
&alice,
);
let patch = Patch::from_history(&h0, &repo).unwrap();
assert_eq!(patch.revisions().count(), 2);
let mut h1 = h0.clone();
h1.commit(
@ -2272,7 +2511,7 @@ mod test {
h0.merge(h2);
let patch = Patch::from_history(&h0, &repo).unwrap();
assert_eq!(patch.revisions().count(), 0);
assert_eq!(patch.revisions().count(), 1);
}
#[test]
@ -2283,18 +2522,24 @@ mod test {
let repo = gen::<MockRepository>(1);
let reaction = Reaction::new('👍').expect("failed to create a reaction");
let a1 = alice.op::<Patch>(Action::Revision {
description: String::new(),
base,
oid,
resolves: Default::default(),
});
let a2 = alice.op::<Patch>(Action::RevisionReact {
let a1 = alice.op::<Patch>([
Action::Revision {
description: String::new(),
base,
oid,
resolves: Default::default(),
},
Action::Edit {
title: String::from("My patch"),
target: MergeTarget::Delegates,
},
]);
let a2 = alice.op::<Patch>([Action::RevisionReact {
revision: RevisionId(a1.id()),
location: None,
reaction,
active: true,
});
}]);
let patch = Patch::from_ops([a1, a2], &repo).unwrap();
let (_, r1) = patch.revisions().next().unwrap();

View File

@ -1,9 +1,9 @@
//! Generic COB storage.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::type_complexity)]
use std::fmt::Debug;
use std::marker::PhantomData;
use std::ops::ControlFlow;
use std::sync::Arc;
use nonempty::NonEmpty;
use serde::{Deserialize, Serialize};
@ -19,7 +19,15 @@ use crate::storage::git as storage;
use crate::storage::SignRepository;
use crate::{cob, identity};
pub trait HistoryAction: std::fmt::Debug {
#[derive(Debug, thiserror::Error)]
pub enum HistoryError<T: FromHistory> {
#[error("apply: {0}")]
Apply(T::Error),
#[error("operation decoding failed: {0}")]
Op(#[from] cob::op::OpEncodingError),
}
pub trait HistoryAction: Debug {
/// Parent objects this action depends on. For example, patch revisions
/// have the commit objects as their parent.
fn parents(&self) -> Vec<git::Oid> {
@ -29,7 +37,7 @@ pub trait HistoryAction: std::fmt::Debug {
/// A type that can be materialized from an event history.
/// All collaborative objects implement this trait.
pub trait FromHistory: Sized + Default + PartialEq {
pub trait FromHistory: Sized + PartialEq + Debug {
/// The underlying action composing each operation.
type Action: HistoryAction + for<'de> Deserialize<'de> + Serialize;
/// Error returned by `apply` function.
@ -38,6 +46,9 @@ pub trait FromHistory: Sized + Default + PartialEq {
/// The object type name.
fn type_name() -> &'static TypeName;
/// Initialize a collarorative object
fn init<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error>;
/// Apply a list of operations to the state.
fn apply<R: ReadRepository>(
&mut self,
@ -45,21 +56,26 @@ pub trait FromHistory: Sized + Default + PartialEq {
repo: &R,
) -> Result<(), Self::Error>;
/// Validate the object. Returns an error if the object is invalid.
fn validate(&self) -> Result<(), Self::Error>;
/// Create an object from a history.
fn from_history<R: ReadRepository>(history: &History, repo: &R) -> Result<Self, Self::Error> {
fn from_history<R: ReadRepository>(
history: &History,
repo: &R,
) -> Result<Self, HistoryError<Self>> {
self::from_history::<R, Self>(history, repo)
}
#[cfg(test)]
/// Create an object from individual operations.
/// Returns an error if any of the operations fails to apply.
fn from_ops<R: ReadRepository>(
ops: impl IntoIterator<Item = Op<Self::Action>>,
repo: &R,
) -> Result<Self, Self::Error> {
let mut state = Self::default();
let mut ops = ops.into_iter();
let Some(init) = ops.next() else {
panic!("FromHistory::from_ops: operations list is empty");
};
let mut state = Self::init(init, repo)?;
for op in ops {
state.apply(op, repo)?;
}
@ -72,8 +88,12 @@ pub trait FromHistory: Sized + Default + PartialEq {
pub fn from_history<R: ReadRepository, T: FromHistory>(
history: &History,
repo: &R,
) -> Result<T, T::Error> {
let obj = history.traverse(T::default(), |mut acc, _, entry| {
) -> Result<T, HistoryError<T>> {
let root = history.root();
let children = history.children_of(root.id());
let op = Op::try_from(root)?;
let initial = T::init(op, repo).map_err(HistoryError::Apply)?;
let obj = history.traverse(initial, &children, |mut acc, _, entry| {
match Op::try_from(entry) {
Ok(op) => {
if let Err(err) = acc.apply(op, repo) {
@ -89,8 +109,6 @@ pub fn from_history<R: ReadRepository, T: FromHistory>(
ControlFlow::Continue(acc)
});
obj.validate()?;
Ok(obj)
}
@ -111,8 +129,6 @@ pub enum Error {
Serialize(#[from] serde_json::Error),
#[error("object `{1}` of type `{0}` was not found")]
NotFound(TypeName, ObjectId),
#[error("apply: {0}")]
Apply(Arc<dyn std::error::Error + Sync + Send + 'static>),
#[error("signed refs: {0}")]
SignRefs(#[from] storage::Error),
#[error("failed to find reference '{name}': {err}")]
@ -121,11 +137,13 @@ pub enum Error {
#[source]
err: git::Error,
},
#[error("history: {0}")]
History(Box<dyn std::error::Error + Sync + Send + 'static>),
}
impl Error {
fn apply(e: impl std::error::Error + Sync + Send + 'static) -> Self {
Self::Apply(Arc::new(e))
fn history(e: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::History(Box::new(e))
}
}
@ -158,7 +176,7 @@ impl<'a, T, R: ReadRepository> Store<'a, T, R> {
impl<'a, T, R> Store<'a, T, R>
where
R: ReadRepository + SignRepository + cob::Store,
T: FromHistory,
T: FromHistory + 'static,
T::Action: Serialize,
{
/// Update an object.
@ -218,7 +236,7 @@ where
contents,
},
)?;
let object = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
let object = T::from_history(cob.history(), self.repo).map_err(Error::history)?;
self.repo.sign_refs(signer).map_err(Error::SignRefs)?;
@ -250,7 +268,7 @@ where
impl<'a, T, R> Store<'a, T, R>
where
R: ReadRepository + cob::Store,
T: FromHistory,
T: FromHistory + 'static,
T::Action: Serialize,
{
/// Get an object.
@ -258,7 +276,7 @@ where
let cob = cob::get(self.repo, T::type_name(), id)?;
if let Some(cob) = cob {
let obj = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
let obj = T::from_history(cob.history(), self.repo).map_err(Error::history)?;
Ok(Some(obj))
} else {
@ -271,7 +289,7 @@ where
let raw = cob::list(self.repo, T::type_name())?;
Ok(raw.into_iter().map(|o| {
let obj = T::from_history(o.history(), self.repo).map_err(Error::apply)?;
let obj = T::from_history(o.history(), self.repo).map_err(Error::history)?;
Ok((*o.id(), obj))
}))
}
@ -297,7 +315,7 @@ pub struct Transaction<T: FromHistory> {
embeds: Vec<Embed>,
}
impl<T: FromHistory> Transaction<T> {
impl<T: FromHistory + 'static> Transaction<T> {
/// Create a new transaction.
pub fn new(actor: ActorId) -> Self {
Self {
@ -395,10 +413,13 @@ pub fn ops<R: cob::Store>(
let cob = cob::get(repo, type_name, id)?;
if let Some(cob) = cob {
let ops = cob.history().traverse(Vec::new(), |mut ops, _, entry| {
ops.push(Op::from(entry.clone()));
ControlFlow::Continue(ops)
});
let root = cob.history().root();
let ops = cob
.history()
.traverse(Vec::new(), &[root.id], |mut ops, _, entry| {
ops.push(Op::from(entry.clone()));
ControlFlow::Continue(ops)
});
Ok(ops)
} else {
Err(Error::NotFound(type_name.clone(), *id))

View File

@ -57,18 +57,23 @@ impl<T: FromHistory> HistoryBuilder<T>
where
T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
{
pub fn new<G: Signer>(action: &T::Action, time: Timestamp, signer: &G) -> HistoryBuilder<T> {
pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
let resource = arbitrary::oid();
let revision = arbitrary::oid();
let (data, root) = encoded::<T, _>(action, time, [], signer);
let (contents, oids): (Vec<Vec<u8>>, Vec<Oid>) = actions
.iter()
.map(|a| encoded::<T, _>(a, time, [], signer))
.unzip();
let contents = NonEmpty::from_vec(contents).unwrap();
let root = oids.first().unwrap();
let manifest = Manifest::new(T::type_name().clone(), Version::default());
let signature = signer.sign(data.as_slice());
let signature = signer.sign(&[0]);
let signature = ExtendedSignature::new(*signer.public_key(), signature);
let change = Entry {
id: root,
id: *root,
signature,
resource,
contents: NonEmpty::new(data),
contents,
timestamp: time.as_secs(),
revision,
parents: vec![],
@ -125,14 +130,14 @@ impl<A> Deref for HistoryBuilder<A> {
/// Create a new test history.
pub fn history<T: FromHistory, G: Signer>(
action: &T::Action,
actions: &[T::Action],
time: Timestamp,
signer: &G,
) -> HistoryBuilder<T>
where
T::Action: Serialize + Eq + 'static,
{
HistoryBuilder::new(action, time, signer)
HistoryBuilder::new(actions, time, signer)
}
/// An object that can be used to create and sign operations.
@ -156,22 +161,23 @@ impl<G: Signer> Actor<G> {
/// Create a new operation.
pub fn op_with<T: FromHistory>(
&mut self,
action: T::Action,
actions: impl IntoIterator<Item = T::Action>,
identity: Oid,
timestamp: Timestamp,
) -> Op<T::Action>
where
T::Action: Clone + Serialize,
{
let actions = actions.into_iter().collect::<Vec<_>>();
let data = encoding::encode(serde_json::json!({
"action": action,
"action": actions,
"nonce": fastrand::u64(..),
}))
.unwrap();
let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
let id = oid.into();
let author = *self.signer.public_key();
let actions = NonEmpty::new(action);
let actions = NonEmpty::from_vec(actions).unwrap();
let manifest = Manifest::new(T::type_name().clone(), Version::default());
let parents = vec![];
@ -187,14 +193,17 @@ impl<G: Signer> Actor<G> {
}
/// Create a new operation.
pub fn op<T: FromHistory>(&mut self, action: T::Action) -> Op<T::Action>
pub fn op<T: FromHistory>(
&mut self,
actions: impl IntoIterator<Item = T::Action>,
) -> Op<T::Action>
where
T::Action: Clone + Serialize,
{
let identity = arbitrary::oid();
let timestamp = Timestamp::now();
self.op_with::<T>(action, identity, timestamp)
self.op_with::<T>(actions, identity, timestamp)
}
/// Get the actor's DID.
@ -213,19 +222,19 @@ impl<G: Signer> Actor<G> {
oid: git::Oid,
repo: &R,
) -> Result<Patch, patch::Error> {
Patch::from_ops(
[
self.op::<Patch>(patch::Action::Revision {
Patch::init(
self.op::<Patch>([
patch::Action::Revision {
description: description.to_string(),
base,
oid,
resolves: Default::default(),
}),
self.op::<Patch>(patch::Action::Edit {
},
patch::Action::Edit {
title: title.to_string(),
target: patch::MergeTarget::default(),
}),
],
},
]),
repo,
)
}

View File

@ -9,6 +9,7 @@ use thiserror::Error;
use crate::cob;
use crate::cob::common::{Reaction, Timestamp, Uri};
use crate::cob::{ActorId, Embed, EntryId, Op};
use crate::git;
use crate::prelude::ReadRepository;
/// Type name of a thread, as well as the domain for all thread operations.
@ -28,15 +29,15 @@ pub enum Error {
/// that hasn't happened yet.
#[error("causal dependency {0:?} missing")]
Missing(EntryId),
/// Validation error.
#[error("validation failed: {0}")]
Validate(&'static str),
/// Error with comment operation.
#[error("comment {0} is invalid")]
Comment(EntryId),
/// Error with edit operation.
#[error("edit {0} is invalid")]
Edit(EntryId),
/// Object initialization failed.
#[error("initialization failed: {0}")]
Init(&'static str),
}
/// Identifies a comment.
@ -258,7 +259,7 @@ impl<T> Thread<T> {
pub fn new(id: CommentId, comment: T) -> Self {
Self {
comments: BTreeMap::from_iter([(id, Some(comment))]),
timeline: Vec::default(),
timeline: vec![id],
}
}
@ -300,6 +301,39 @@ impl<T> Thread<T> {
}
}
impl Thread {
/// Apply a single action to the thread.
fn action<R: ReadRepository>(
&mut self,
action: Action,
entry: EntryId,
author: ActorId,
timestamp: Timestamp,
_identity: git::Oid,
_repo: &R,
) -> Result<(), Error> {
match action {
Action::Comment { body, reply_to } => {
comment(self, entry, author, timestamp, body, reply_to, None, vec![])?;
}
Action::Edit { id, body } => {
edit(self, entry, id, timestamp, body, vec![])?;
}
Action::Redact { id } => {
redact(self, entry, id)?;
}
Action::React {
to,
reaction,
active,
} => {
react(self, entry, author, to, reaction, active)?;
}
}
Ok(())
}
}
impl<L> Thread<Comment<L>> {
pub fn replies<'a>(
&'a self,
@ -324,37 +358,36 @@ impl cob::store::FromHistory for Thread {
&TYPENAME
}
fn validate(&self) -> Result<(), Self::Error> {
if self.comments.is_empty() {
return Err(Error::Validate("no comments found"));
fn init<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
let author = op.author;
let entry = op.id;
let timestamp = op.timestamp;
let mut actions = op.actions.into_iter();
let Some(Action::Comment { body, reply_to: None }) = actions.next() else {
return Err(Error::Init("missing initial comment"));
};
let mut thread = Thread::default();
comment(
&mut thread,
entry,
author,
timestamp,
body,
None,
None,
vec![],
)?;
for action in actions {
thread.action(action, entry, author, timestamp, op.identity, repo)?;
}
Ok(())
Ok(thread)
}
fn apply<R: ReadRepository>(&mut self, op: Op<Action>, _repo: &R) -> Result<(), Error> {
let id = op.id;
let author = op.author;
let timestamp = op.timestamp;
fn apply<R: ReadRepository>(&mut self, op: Op<Action>, repo: &R) -> Result<(), Error> {
for action in op.actions {
match action {
Action::Comment { body, reply_to } => {
comment(self, id, author, timestamp, body, reply_to, None, vec![])?;
}
Action::Edit { id, body } => {
edit(self, op.id, id, timestamp, body, vec![])?;
}
Action::Redact { id } => {
redact(self, op.id, id)?;
}
Action::React {
to,
reaction,
active,
} => {
react(self, op.id, author, to, reaction, active)?;
}
}
self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
}
Ok(())
}
@ -527,23 +560,23 @@ mod tests {
/// Create a new comment.
pub fn comment(&mut self, body: &str, reply_to: Option<CommentId>) -> Op<Action> {
self.op::<Thread>(Action::Comment {
self.op::<Thread>([Action::Comment {
body: String::from(body),
reply_to,
})
}])
}
/// Create a new redaction.
pub fn redact(&mut self, id: CommentId) -> Op<Action> {
self.op::<Thread>(Action::Redact { id })
self.op::<Thread>([Action::Redact { id }])
}
/// Edit a comment.
pub fn edit(&mut self, id: CommentId, body: &str) -> Op<Action> {
self.op::<Thread>(Action::Edit {
self.op::<Thread>([Action::Edit {
id,
body: body.to_owned(),
})
}])
}
}
@ -615,10 +648,10 @@ mod tests {
let time = Timestamp::now();
let mut a = test::history::<Thread, _>(
&Action::Comment {
&[Action::Comment {
body: "Thread root".to_owned(),
reply_to: None,
},
}],
time,
&alice,
);
@ -682,10 +715,10 @@ mod tests {
let time = Timestamp::now();
let mut a = test::history::<Thread, _>(
&Action::Comment {
&[Action::Comment {
body: "Thread root".to_owned(),
reply_to: None,
},
}],
time,
&alice,
);
@ -715,10 +748,10 @@ mod tests {
let timestamp = Timestamp::from_secs(timestamp);
let h0 = test::history::<Thread, _>(
&Action::Comment {
&[Action::Comment {
body: "Thread root".to_owned(),
reply_to: None,
},
}],
timestamp,
&alice,
);