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
- One engine per account.
Accounts<R, D, S, C, A>is the model of ONE account over its own store (AccountStore), reading the estate'sDirectoryfor who exists and where they live. An engine holds no row of another account, so the wall is a structure, not a filter; a server holds one engine per account. - An account is its own row.
Account { id, owner, admins }. Its id (acme) is a DNS label (it is a path segment) and not a person's id: Holger's people arepasskey:…, Gunnar'sannamay own the accountanna. A person founds an account and is its owner, its first admin and its first member. A person who already has an account founds nothing. - An account has an owner, admins and members. The owner is the one who buys for the account; the owner may hand the account to another member. The admins decide for the account: its people, its grants, its visibility. Admin is a fact of the account row, not a grant, so there is no row to revoke by mistake and nothing an older table can plant. A member is a person the account enrolled; it has no account of its own and pays nothing.
- Members come in one way. Each source writes the same rows through the same rule: invite or enrolment by an admin, a batch import (CSV, all or nothing), SCIM provisioning and the first company-SSO login (directory hooks the product verified), and a paid order delivered by the seller's machine. An existing principal never becomes a member.
- Grants live at account scope and repository scope. A grant is held by a principal or by a group, and a group belongs to one account (
acme/approvers) and holds the account's own people. Grants reach a group's members transitively; a group holds, its people act: a group is never a caller. What a grant means is the product's (Right). - Machines belong to an account (a CI key,
acme/ci: it lives in the account and holds the grants its right allows a machine, but it is never an admin and never the owner) or to the estate (the seller's key, the estate's plumbing), which the directory knows and no account does. A machine's or a group's id names its account before the/, so two accounts may both have aci, and an account asks only its own store whether a name is free. - A caller is an id and how it authenticated (
Via: a person, a machine key, nobody). A machine key decides nothing for an account, whoever owns it. - The operator runs the estate and stands outside every account: inside an account it holds nothing but what is public. The operator, as a person, removes an account's people beside its admins. Only the operator (the seller's machine among them) purges an account: the purge is the one order the seller's key gives an account.
- The one check,
may(who, scope, right), answers one of three things, so every door maps them the same way once:Allowed;NotTherewhen the caller is outside the account and the scope is not public (404, said before any storage is touched);Forbiddenwhen the caller sees the scope and lacks the right (403).allowed()is the yes-or-no. - Visibility is set per scope by an admin:
Private(grants only, the default),Members(everyone of the account reads, its machines included),Public(everyone reads, the anonymous caller and an identity without a principal row included). - A gone principal is forgotten everywhere. In Gunnar an outsider's repository grant is a row in the other account's store, which the engine of its own account never touches. When a principal leaves the directory (removed, or its account purged) the server tells every engine,
forget(id), and each drops the rows naming it, so an id used again later inherits nothing anywhere. An engine that had nothing to drop writes no audit row for the sweep. Behind the wall there is nothing to forget. - The audit trail. Every change writes one audit row, and every refusal writes one too (
refused). A change is the account's row. A refusal is filed under the actor's own account (the estate's when it has none), so an account's trail never names another account's people. The engine hands every row to itsAuditSinkand the sink decides where the row goes byrow.account: the server's chain takes them all, a file-per-account sink writes each row to the file the row names. Each row carries the time from the clock, the actor and how it authenticated, its standing in the account, the kind, the target and a detail.
The invariants
- 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.
- 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.
- 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.
- 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.
- Only an account admin opens a repository, to its members or to everyone. Private is the default.
- 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.
- 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. - 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. - 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. - 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
- One rule at a time (
rules): pure functions over values the caller holds (an account row, a principal row). A product with its own storage and transactions calls them inside its own update and keeps writing its own rows. Gunnar does this. - The whole engine (
Accounts): every change checked, written throughAccountStore, and audited throughAuditSinkat theClock's time. Holger can start onmemory's estate and move to its own directory and stores behind the same traits.
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:
may_buytells a member of this account to ask its owner (MemberBuysNothing) and tells everybody else, the owner of another account included, that it is not the owner of this one (NotTheOwner); the draft said "members buy nothing" to the owner of another account about his own account.drop_adminrefuses the owner (IsTheOwner) so that the owner is always an admin; when the owner is the only admin the answer isLastAdmin, so both refusals are reachable.remove_member(caller, who)and the other people calls name no account: the engine is one.- The estate's own machines (the seller's key) are the directory's;
add_machineadds a machine of the account only. - The refusal for an outsider's account-wide grant under Gunnar is
NotForAnOutsiderwith the right's own word, not 0.1'sAdminForOutsider: the rule is the right's (outsider_may_hold) and not only about admin. check_account_id(the label grammar and the reserved list) is for account ids, and a machine's or a group's id is<account>/<local>with the local name a label too (check_scoped_id); a person's id keeps the wider rule (check_id), because Holger's people arepasskey:….- A group may be granted a right its machines cannot hold: the group holds it for its people, and a machine in the group does not get it.
Visibility::Membersopens a repository to everyone of the account, its machines included.forget(id)was added when the property runner found that, with one store per account, an outsider's grant under Gunnar outlives its holder (the holder's own engine cannot reach the row). The review did not cover it; the server tells every engine when a principal leaves the directory, andforgetis refused while the directory still knows the id (StillKnown).