Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/accounts/README.md

edda-accounts

The one account model of the nordisk products. Gunnar wrote the first version; Holger takes the same crate for its SaaS security model. The crate is pure logic behind four traits: the estate's directory, one account's store, a clock and an audit sink. It holds no process-wide state and never reads a clock itself. The one product knob is the Policy.

The shape

The invariants

  1. An account always has an admin. The last admin is neither dropped nor removed; the account is purged instead. A purge removes the members first, then every row of the account, then the account. An account founded later under the same id inherits nothing.
  2. The owner is a member and an admin, for as long as it owns. It is dropped or removed only after it handed the account to another member.
  3. Admin only for members. A name among the admins is honoured only while it is a person who is a member of that account, whatever rows exist. An outsider, a machine or a group is nobody's admin.
  4. Only the owner or the seller's machine buys. Members never buy, for any account and any purpose. An appointed admin decides for the account but never pays for it.
  5. Only an account admin opens a repository, to its members or to everyone. Private is the default.
  6. Only the seller's machine delivers a paid order. A person with server rights who names an order is refused, so an operator cannot plant a member inside an account.
  7. The wall (policy). Policy::holger() forbids cross-account grants: no grant, no group membership and no access crosses an account's wall, except reading what an account admin made public; a row planted across the wall is not honoured. Policy::gunnar() allows an outsider what its right allows (Right::outsider_may_hold): by default anything on a repository and nothing account-wide that an admin holds by right, so the whole account stays its own people's. Groups hold the account's own people under both, and a group of another account is granted nothing under either.
  8. A right is held by the kinds it names (Right::holdable_by): a grant to another kind is refused, and a planted one is not honoured.
  9. An engine touches only its own account. Nothing an engine does changes another account's rows or the estate's own; the server's sweep (forget) is how a row naming a gone outsider leaves another account's store.
  10. A refusal writes nothing. Every check runs before the first write, so a refused call leaves every store as it was and adds one refused audit row.

The property tests (tests/invariants.rs) drive random sequences of calls over several engines under both policies and check every invariant after each step.

The crate's test run is cargo test -p edda-accounts --all-features, and that is the step edda's CI runs (cargo xtask ci): tests/csv_import.rs is behind required-features = ["csv"], so a plain cargo test never compiles it and would count one test file short.

What a grant means to each product

The model asks a product's Right six things: what a held right covers, whether an account admin holds it without a grant, which right a public scope gives everyone, what a new member gets, which kinds may hold it, and what an outsider may hold of it. The crate ships Gunnar's GitLevel; Holger defines its rights in its own crate (the story in tests/holger_story.rs shows the shape).

Gunnar: GitLevel Holger: its own right
Rights Read < Write < Admin, a ladder: each covers the ones below it A set: each right covers only itself (Read, Post, Import, Approve, ...)
An admin holds without a grant every level: the account is its admins' at every level the configuring rights (members, repositories, tokens, audit); never Post, Import, Approve or the endpoints
Public read Read (clone, fetch, events) Read (pull what is available)
A new member gets Read on the account nothing: Read is granted, or a repository is opened to members
Held by anybody Approve and Waive by persons only

Separation of duties on one artifact (its importer never approves it) is Holger's rule in the chain, not a grant.

Two ways to use it

use edda_accounts::memory::{ManualClock, MemoryAudit, MemoryEstate};
use edda_accounts::{AccountId, Accounts, Answer, Caller, GitLevel, Grant, MemberSource, Policy, Refusal, Scope};

let estate = MemoryEstate::<GitLevel>::new().with_anonymous("anonymous");
let engine = |id: &str| {
    Accounts::new(
        AccountId::from(id),
        estate.directory(),
        estate.store(&AccountId::from(id)),
        ManualClock::at(0),
        MemoryAudit::default(),
        Policy::holger(),
    )
};
let mut acme = engine("acme");
let mut bolaget = engine("bolaget");
let anna = Caller::person("passkey:anna");
acme.found(&anna).unwrap();
bolaget.found(&Caller::person("passkey:bob")).unwrap();
acme.enroll(&anna, &"passkey:ann".into(), MemberSource::Invite).unwrap();

// The wall: acme cannot give bolaget's owner a right in acme, and acme does not exist for him.
let across = Grant::new("passkey:bob", Scope::repo("acme", "pypi"), GitLevel::Read);
let refused = acme.grant(&anna, &across).unwrap_err();
assert!(matches!(refused.refusal(), Some(Refusal::AcrossTheWall { .. })));
let answer = acme.may(&"passkey:bob".into(), &Scope::repo("acme", "pypi"), &GitLevel::Read).unwrap();
assert_eq!(answer, Answer::NotThere);

// Every change wrote an audit row.
assert!(acme.audit().rows().iter().any(|row| row.kind == "member.enrolled"));

Choices made while building

Where the draft and the review left a gap, the smallest honest reading was taken: