397 lines
17 KiB
Rust
397 lines
17 KiB
Rust
//! trustin-access — the access core of trustinWallet, as libreDecision uses it
|
|
//! today.
|
|
//!
|
|
//! trustinWallet (formerly trustWallet, a name already taken) is the identity
|
|
//! and rights layer of the ecosystem. Until it runs as its own service, this
|
|
//! crate holds its rules so that libreDecision never decides access by itself:
|
|
//! the server asks `sees` / `opens` / `manages_access`, and stores grants.
|
|
//!
|
|
//! Rules (Yvv, 2026-10-21):
|
|
//! - anyone signed in with a Ğ1 key sees the groups that use the same key —
|
|
//! Monnaie libre, Axiom, Librodrome, the local group Vallée Drôme Crestois;
|
|
//! - a group opens only to its members; Monnaie libre's members are the
|
|
//! members of the Ğ1 web of trust, the others are granted one by one;
|
|
//! - everything is open to a member except access management, which belongs
|
|
//! to trustinWallet's administrators;
|
|
//! - where the electorate is the web of trust (Monnaie libre), only its members
|
|
//! vote (2026-10-21, independent review: one person, one vote) — through the
|
|
//! Ğ1 chain, one transaction per vote, for the identity that signs it, member
|
|
//! when the vote is confirmed (2026-10-30, ADR 0008);
|
|
//! - a typo fix to someone's version waits for that author; to an authorless
|
|
//! version (a seeded text), for an administrator — `answers_typo`;
|
|
//! - an objection is lifted by the person who raised it, or by an
|
|
//! administrator; it is reiterated by the person who raised it only —
|
|
//! `lifts_objection` / `reiterates_objection`;
|
|
//! - each person reads their own messages only — `reads_message`
|
|
//! (2026-10-31, lot F, ADR 0010);
|
|
//! - while the chain is closed, the vote is simulated: whoever may ask for a
|
|
//! ballot votes on the server, marked as simulated — `casts_simulated`
|
|
//! (2026-11-02, lot V, ADR 0012).
|
|
//! - the quorum of an article is set by the author of its first version — for a
|
|
//! text of the seed, by an administrator — and only before the first vote —
|
|
//! `sets_quorum` (2026-11-03, ADR 0013).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum IdentityKind {
|
|
/// Signed in through connect.monnaie-libre.fr: a Ğ1 identity (`idty:<index>`).
|
|
G1,
|
|
/// Signed in with an invitation link (tests, before Ğ1 sign-in is wired).
|
|
Invite,
|
|
/// Local development only.
|
|
Dev,
|
|
}
|
|
|
|
impl IdentityKind {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::G1 => "g1",
|
|
Self::Invite => "invite",
|
|
Self::Dev => "dev",
|
|
}
|
|
}
|
|
|
|
pub fn parse(raw: &str) -> Option<Self> {
|
|
[Self::G1, Self::Invite, Self::Dev].into_iter().find(|k| k.as_str() == raw)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Identity {
|
|
pub id: String,
|
|
pub kind: IdentityKind,
|
|
pub display_name: String,
|
|
/// Member of the Ğ1 web of trust at the last sign-in.
|
|
pub g1_member: bool,
|
|
/// Administrator of access (trustinWallet).
|
|
pub admin: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum GroupPolicy {
|
|
/// Open to every member of the Ğ1 web of trust, plus explicit grants.
|
|
G1Members,
|
|
/// Open to explicit grants only.
|
|
Managed,
|
|
}
|
|
|
|
impl GroupPolicy {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::G1Members => "g1-members",
|
|
Self::Managed => "managed",
|
|
}
|
|
}
|
|
|
|
pub fn parse(raw: &str) -> Option<Self> {
|
|
[Self::G1Members, Self::Managed].into_iter().find(|p| p.as_str() == raw)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Group {
|
|
pub slug: String,
|
|
pub name: String,
|
|
pub parent: Option<String>,
|
|
pub policy: GroupPolicy,
|
|
/// Hidden groups are seen by their members only (the test group).
|
|
pub hidden: bool,
|
|
}
|
|
|
|
/// Does this identity belong to the group? `granted` = an explicit grant exists.
|
|
pub fn is_member(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|
granted || (group.policy == GroupPolicy::G1Members && identity.kind == IdentityKind::G1 && identity.g1_member)
|
|
}
|
|
|
|
/// Is the group listed for this identity?
|
|
pub fn sees(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|
!group.hidden || is_member(identity, group, granted) || identity.admin
|
|
}
|
|
|
|
/// Can this identity open the group and use its tools (read, propose, vote)?
|
|
pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|
is_member(identity, group, granted)
|
|
}
|
|
|
|
/// How a group votes.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum VoteWay {
|
|
/// On the server: one vote per identity of the group (managed groups).
|
|
Server,
|
|
/// On the Ğ1 chain: one transaction per vote, signed by the person's wallet
|
|
/// (ADR 0008). The vote counts for the Ğ1 identity of the signer.
|
|
Chain,
|
|
}
|
|
|
|
/// Where the electorate is the Ğ1 web of trust (policy `G1Members`), votes go
|
|
/// through the chain (Yvv, 2026-10-30: « une transaction par vote »): one
|
|
/// person, one voice, proven by the identity that signs — not by the way one
|
|
/// signed in here. Elsewhere, on the server.
|
|
pub fn vote_way(group: &Group) -> VoteWay {
|
|
match group.policy {
|
|
GroupPolicy::G1Members => VoteWay::Chain,
|
|
GroupPolicy::Managed => VoteWay::Server,
|
|
}
|
|
}
|
|
|
|
/// Can this identity vote on the server in the group? In a managed group, every
|
|
/// member. Where the vote goes through the chain, nobody: an invitation identity
|
|
/// next to a Ğ1 identity would give one person two voices.
|
|
pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|
opens(identity, group, granted) && vote_way(group) == VoteWay::Server
|
|
}
|
|
|
|
/// Can this identity ask for a ballot to sign with a wallet? Whoever opens a
|
|
/// group that votes on the chain: who votes is decided by the chain itself, at
|
|
/// confirmation (`chain_vote_counts`).
|
|
pub fn asks_ballot(identity: &Identity, group: &Group, granted: bool) -> bool {
|
|
opens(identity, group, granted) && vote_way(group) == VoteWay::Chain
|
|
}
|
|
|
|
/// Does a vote signed on the chain count? Only for an identity that is a member
|
|
/// of the web of trust when the vote is confirmed (the electorate W is the web).
|
|
pub fn chain_vote_counts(member: bool) -> bool {
|
|
member
|
|
}
|
|
|
|
/// While the chain is closed (no vote account yet), a group that votes on the
|
|
/// chain votes in simulation (Yvv, 2026-11-02: « Simule le vote à tout endroit »,
|
|
/// ADR 0012): whoever may ask for a ballot casts a simulated vote, written by the
|
|
/// server, marked as such, for the identity of the session — there is no wallet.
|
|
/// One person signed in two ways may then hold two voices: accepted while the
|
|
/// simulation lasts — Yvv, 2026-11-02: « Il compte, puis s'efface »: every
|
|
/// simulated vote counts until an act « supprime » sets them all aside.
|
|
pub fn casts_simulated(identity: &Identity, group: &Group, granted: bool, chain_open: bool) -> bool {
|
|
!chain_open && asks_ballot(identity, group, granted)
|
|
}
|
|
|
|
/// Access management stays with trustinWallet's administrators.
|
|
pub fn manages_access(identity: &Identity) -> bool {
|
|
identity.admin
|
|
}
|
|
|
|
/// Who answers a typo fix someone else wrote on a version (2026-10-30, L13):
|
|
/// its author; for a text without author (a starting text), an administrator of
|
|
/// trustinWallet. Whoever answers for a version also applies their own fix at once.
|
|
pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool {
|
|
match version_author {
|
|
Some(author) => identity.id == author,
|
|
None => identity.admin,
|
|
}
|
|
}
|
|
|
|
/// Who lifts an objection (2026-10-30, L12): the person who raised it, or an
|
|
/// administrator of trustinWallet.
|
|
pub fn lifts_objection(identity: &Identity, objector: &str) -> bool {
|
|
identity.id == objector || identity.admin
|
|
}
|
|
|
|
/// Who reiterates an objection: only the person who raised it — an
|
|
/// administrator lifts, never speaks for someone else.
|
|
pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool {
|
|
identity.id == objector
|
|
}
|
|
|
|
/// A message of trustinWallet is read by its recipient only.
|
|
pub fn reads_message(identity: &Identity, recipient: &str) -> bool {
|
|
identity.id == recipient
|
|
}
|
|
|
|
/// Who sets the quorum of an article (Yvv, 2026-11-02: « L'auteur, avant le 1er vote »,
|
|
/// ADR 0013): the author of its first version; for an article without author (a text of
|
|
/// the seed), an administrator of trustinWallet — never one for an author. Once a vote
|
|
/// exists on the article (`voted`, the server's `quorum::a_vote_exists`), nobody: the
|
|
/// votes cast were judged on it.
|
|
pub fn sets_quorum(identity: &Identity, first_author: Option<&str>, voted: bool) -> bool {
|
|
if voted {
|
|
return false;
|
|
}
|
|
match first_author {
|
|
Some(author) => identity.id == author,
|
|
None => identity.admin,
|
|
}
|
|
}
|
|
|
|
/// The test group: hidden, the quorum 1 at every level — the one place one voice passes.
|
|
pub const TEST_GROUP: &str = "essai";
|
|
|
|
/// The groups a fresh deployment starts with. Ordered as displayed.
|
|
pub fn default_groups() -> Vec<Group> {
|
|
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
|
|
slug: slug.into(),
|
|
name: name.into(),
|
|
parent: parent.map(Into::into),
|
|
policy,
|
|
hidden,
|
|
};
|
|
vec![
|
|
g("monnaie-libre", "Monnaie libre", None, GroupPolicy::G1Members, false),
|
|
g("axiom", "Axiom", Some("monnaie-libre"), GroupPolicy::Managed, false),
|
|
g("librodrome", "Librodrome", Some("monnaie-libre"), GroupPolicy::Managed, false),
|
|
g(
|
|
"vallee-drome-crestois",
|
|
"Groupe local Vallée Drôme Crestois",
|
|
Some("monnaie-libre"),
|
|
GroupPolicy::Managed,
|
|
false,
|
|
),
|
|
g(TEST_GROUP, "Groupe d'essai", None, GroupPolicy::Managed, true),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn who(kind: IdentityKind, g1_member: bool, admin: bool) -> Identity {
|
|
Identity { id: "x".into(), kind, display_name: "x".into(), g1_member, admin }
|
|
}
|
|
|
|
fn group(slug: &str) -> Group {
|
|
default_groups().into_iter().find(|g| g.slug == slug).expect("default group")
|
|
}
|
|
|
|
#[test]
|
|
fn a_wot_member_sees_all_four_and_opens_monnaie_libre_only() {
|
|
let member = who(IdentityKind::G1, true, false);
|
|
for slug in ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois"] {
|
|
assert!(sees(&member, &group(slug), false), "{slug}");
|
|
}
|
|
assert!(opens(&member, &group("monnaie-libre"), false));
|
|
assert!(!opens(&member, &group("axiom"), false));
|
|
assert!(opens(&member, &group("axiom"), true));
|
|
assert!(!manages_access(&member));
|
|
}
|
|
|
|
#[test]
|
|
fn the_test_group_is_hidden_from_non_members() {
|
|
let member = who(IdentityKind::G1, true, false);
|
|
assert!(!sees(&member, &group("essai"), false));
|
|
assert!(sees(&member, &group("essai"), true));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_member_key_or_an_invitation_does_not_open_monnaie_libre_by_itself() {
|
|
let former = who(IdentityKind::G1, false, false);
|
|
assert!(!opens(&former, &group("monnaie-libre"), false));
|
|
let invited = who(IdentityKind::Invite, false, false);
|
|
assert!(!opens(&invited, &group("monnaie-libre"), false));
|
|
assert!(opens(&invited, &group("monnaie-libre"), true));
|
|
}
|
|
|
|
#[test]
|
|
fn administrators_manage_access_and_see_hidden_groups_but_open_nothing_by_right() {
|
|
let admin = who(IdentityKind::Invite, false, true);
|
|
assert!(manages_access(&admin));
|
|
assert!(sees(&admin, &group("essai"), false));
|
|
assert!(!opens(&admin, &group("essai"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn where_the_electorate_is_the_web_of_trust_the_vote_goes_through_the_chain() {
|
|
let ml = group("monnaie-libre");
|
|
assert_eq!(vote_way(&ml), VoteWay::Chain);
|
|
assert_eq!(vote_way(&group("essai")), VoteWay::Server);
|
|
// Nobody votes on the server there, whatever the way one signed in.
|
|
for kind in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] {
|
|
assert!(!votes_in(&who(kind, true, false), &ml, true), "{kind:?}");
|
|
}
|
|
// Whoever opens the group may ask for a ballot to sign with a wallet...
|
|
let invited = who(IdentityKind::Invite, false, true);
|
|
assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose");
|
|
assert!(asks_ballot(&invited, &ml, true));
|
|
assert!(asks_ballot(&who(IdentityKind::G1, true, false), &ml, false));
|
|
assert!(!asks_ballot(&invited, &ml, false), "not without opening the group");
|
|
// ...and the vote counts only for a member of the web of trust, read on the chain.
|
|
assert!(chain_vote_counts(true));
|
|
assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote");
|
|
}
|
|
|
|
#[test]
|
|
fn while_the_chain_is_closed_whoever_may_ask_a_ballot_votes_in_simulation() {
|
|
let ml = group("monnaie-libre");
|
|
let invited = who(IdentityKind::Invite, false, false);
|
|
let member = who(IdentityKind::G1, true, false);
|
|
assert!(casts_simulated(&invited, &ml, true, false));
|
|
assert!(casts_simulated(&member, &ml, false, false));
|
|
assert!(!casts_simulated(&invited, &ml, false, false), "not without opening the group");
|
|
// The chain open: a ballot to sign, never a simulated vote.
|
|
assert!(!casts_simulated(&member, &ml, false, true));
|
|
// A managed group votes on the server, never in simulation.
|
|
assert!(!casts_simulated(&invited, &group("essai"), true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn in_a_managed_group_every_member_votes_on_the_server() {
|
|
let essai = group("essai");
|
|
let invited = who(IdentityKind::Invite, false, false);
|
|
assert!(votes_in(&invited, &essai, true), "managed groups: every member votes");
|
|
assert!(!votes_in(&invited, &essai, false));
|
|
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
|
|
}
|
|
|
|
#[test]
|
|
fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() {
|
|
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
|
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
|
assert!(answers_typo(&chiara, Some("chiara")));
|
|
assert!(!answers_typo(&other, Some("chiara")));
|
|
assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author");
|
|
assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers");
|
|
assert!(!answers_typo(&other, None));
|
|
}
|
|
|
|
#[test]
|
|
fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() {
|
|
let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
|
let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
|
assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna"));
|
|
assert!(lifts_objection(&admin, "luna"));
|
|
assert!(!reiterates_objection(&admin, "luna"));
|
|
assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_author_sets_the_quorum_before_the_first_vote_an_administrator_for_the_seed() {
|
|
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
|
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
|
assert!(sets_quorum(&chiara, Some("chiara"), false));
|
|
assert!(!sets_quorum(&chiara, Some("chiara"), true), "after the first vote, nobody");
|
|
assert!(!sets_quorum(&other, Some("chiara"), false));
|
|
assert!(!sets_quorum(&admin, Some("chiara"), false), "an administrator never sets it for an author");
|
|
assert!(sets_quorum(&admin, None, false), "a text of the seed: an administrator");
|
|
assert!(!sets_quorum(&admin, None, true));
|
|
assert!(!sets_quorum(&other, None, false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_is_read_by_its_recipient_only() {
|
|
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
|
let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
|
assert!(reads_message(&luna, "luna"));
|
|
assert!(!reads_message(&admin, "luna"), "not even an administrator");
|
|
}
|
|
|
|
#[test]
|
|
fn no_atelier_du_canal() {
|
|
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips() {
|
|
for k in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] {
|
|
assert_eq!(IdentityKind::parse(k.as_str()), Some(k));
|
|
}
|
|
for p in [GroupPolicy::G1Members, GroupPolicy::Managed] {
|
|
assert_eq!(GroupPolicy::parse(p.as_str()), Some(p));
|
|
}
|
|
}
|
|
}
|