v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "trustin-access"
|
||||
description = "Access core of trustinWallet as libreDecision uses it today: identities, groups, who sees and who opens what."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,193 @@
|
||||
//! 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.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Access management stays with trustinWallet's administrators.
|
||||
pub fn manages_access(identity: &Identity) -> bool {
|
||||
identity.admin
|
||||
}
|
||||
|
||||
/// 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("essai", "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 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user