Skip to main content

Crate cratefield_module_orgs

Crate cratefield_module_orgs 

Source
Expand description

§cratefield-module-orgs

Organizations, memberships, roles and email invitations for a Cratefield venture: a person creates an organization and is its owner, invites people by address, and everyone who belongs holds a role the venture configured.

Mounted at /v1/orgs. Four migrations, seven tables. Every route but the admin listing acts for the account the venture’s Auth port identifies; no route reads a subject id from anywhere else, so one person cannot act as another.

§Composing it

use cratefield_module_orgs::Orgs;

let orgs = Orgs::builder()
    // `owner` is always in the set and is the creator's role.
    .roles(["owner", "manager", "staff"])
    // Non-owner roles that may manage members and invitations.
    .managers(["manager"])
    .build();
buildermeaning
.roles([..])The roles an organization may use. owner is added if omitted. A role outside the set is refused with 422 orgs-unknown-role, so a typo cannot be stored.
.managers([..])The non-owner roles that may add, remove, re-role and invite. The default is that only owners manage.
.staff_org(org_id)The venture’s staff organization. ORGS_STAFF_ORG overrides it per deployment.
.staff_roles([..])The roles in the staff organization that may see the admin listing.
.invitation_ttl(Duration)How long an invitation stays acceptable. Seven days by default.

Configuration keys (all prefixed ORGS_):

keymeaning
ORGS_API_BASEThe public base the accept link is built from (default https://api.<venture domain>).
ORGS_ACCEPT_URLA venture’s own accept page, when it has one.
ORGS_STAFF_ORGOverrides the builder’s staff organization.
ORGS_FROM / ORGS_REPLY_TOThe invitation mail’s sender.

§The rules

  • An organization always has at least one owner. The last owner cannot leave, be removed or be demoted — each is one guarded statement whose affected-row count decides it, answered with 409 orgs-last-owner.
  • Owners may do anything. A manager role may add, remove, re-role and invite, but only an owner may grant or revoke the owner role and only an owner may change an owner’s membership.
  • A caller who is not a member gets the 404 orgs-not-found an unknown id gets, so organizations cannot be enumerated.
  • An invitation stores only the SHA-256 of its token and of the invitee’s normalized address, and is spent by one guarded UPDATE: a second accept, or one from the wrong address, finds it already gone. The raw token is mailed, never returned in a response.

§Typed API

ⓘ
// May this person see this organization's page?
let role = cratefield_module_orgs::member_role(&*db, org_id, &subject).await?;

// May this caller use an admin route?
let staff = cratefield_module_orgs::require_staff(&ctx, &headers, staff_org, &roles).await?;

§Moving from ADMIN_TOKEN to a staff org

require_staff and the admin listing accept either a machine holding ADMIN_TOKEN or a person whose verified subject is a member of the staff organization holding one of the staff roles. A deployment that has an ADMIN_TOKEN keeps working unchanged; the staff organization is the path for people.

To move:

  1. Compose the module with .roles([.., "admin"]).staff_org("<org id>").staff_roles(["admin"]). A staff role configured without a staff organization is a self_check failure, because nobody could ever hold it.
  2. Create the staff organization in the venture and give the people who should have access the staff role there. Nothing else changes: membership is grantable and revocable through the ordinary routes.
  3. Once no machine needs ADMIN_TOKEN, remove it. Every admin route then answers 401 unless the caller is staff.

No shared secret to rotate, and removing somebody from the staff organization is a DELETE — that is the reason to move.

§Service accounts, grants and the audit log

Three library modules sit beside the routes (issue #878). They are plain functions over the Database port with no routes of their own, so a venture whose people sign in some other way can still use them — it applies MIGRATION_SERVICE_ACCOUNTS, MIGRATION_GRANTS and MIGRATION_AUDIT under a module of its own and declares ACCESS_PERSONAL_DATA. A venture that mounts Orgs gets the tables with it.

  • service_accounts — non-human principals an organization owns (name, description, owning member, creator, disabled flag) and their API keys: {brand}_{id}_{secret}, returned once by mint_key, stored only as a SHA-256 and compared in constant time, revocable, rotatable, with an optional expiry and a last-used stamp. verify_key answers None for every refusal alike — unknown, wrong secret, revoked, expired, account disabled or deleted. disable_owned_by disables every account a member owns and revokes their keys in one transaction, for when the member leaves.
  • grants — principal × selector × permission, where a selector is a kind (or *), a space and a path prefix matched on whole segments (reports/q3 covers reports/q3/plan, never reports/q3x), and permissions are ordered read < write < admin. decide is deny-by-default and an applicable deny wins; expired grants stop applying, and a stored row that no longer parses fails closed. decide_with_credential and coverage intersect with a credential’s scope list, which can only narrow. coverage answers prefixes, for a listing that must filter before it ranks or limits. may_grant lets only an admin holder hand out what lies inside what they hold. A venture’s default access goes in as Grant::implicit values, so the same rule governs it.
  • audit — an append-only log: actor, the member a service account acted for, the credential’s prefix (never a secret), action, resource, detail. list pages newest first.
ⓘ
use cratefield_module_orgs::grants::{decide, split_path, Grant, Permission, Resource, Selector};

let mine = Selector::new("*", "personal:alice", vec![])?;
let grants = vec![Grant::implicit("member:alice", mine, Permission::Admin)];
let page = Resource::new("pages", "personal:alice", split_path("notes/today"))?;
assert!(decide(&grants, &["member:alice"], &page, Permission::Write, now).is_allowed());

§Ports

Requires Db and Auth. Optionally uses Clock (expiry and single-use), IdGen (ids and the invitation token) and Mailer. A mailer that reports NotConfigured leaves no invitation behind: the row is deleted and the caller gets a 503 mail-not-configured. Orgs is what still surfaces that failure to the client; the waitlist, whose answer must not vary, logs the same NotConfigured and answers 202 anyway.

§Personal data (ADR 0015)

tablesubjectkinddisposition
org_membersuser_subidentifiererase
orgscreated_byidentifierretain
org_invitations—contactunreachable (the address is held only as a hash)
org_service_accountsowner_subidentifiererase
org_service_account_keysvia org_service_accounts.owner_subidentifiererase (secret_hash redacted)
org_grantsprincipalidentifiererase
org_auditactor_idusageretain (an erasable audit trail is no trail)

An organization outlives its creator — deleting the row would delete every other member’s organization with it — so orgs is retained, with the reason published in the manifest. A membership is the member’s, and erasing their account removes it.

Modules§

audit
The audit log (issue #878): who did what to which resource of an organization, and with which credential.
grants
Access grants (issue #878): who may do what to which part of an organization’s data.
service_accounts
Service accounts (issue #878): non-human principals an organization owns — an agent, a CI job, an integration — and the API keys they authenticate with.

Structs§

InvitationMail
What the invitation template is given. Serialized through the registry, so it is a wire format: adding a field is fine, renaming one breaks overrides. The module also puts the resolved theme under theme.
Member
One membership, as a member of the organization reads it.
Membership
An organization and the role the caller holds in it.
Org
An organization, as a venture reads it.
Orgs
The organizations module (issue #652): organizations, memberships, roles and email invitations. Compose it with Orgs::builder.
OrgsBuilder
Builds an Orgs.
Staff
An authorized staff caller, as require_staff returns it.

Enums§

OrgsError
What an orgs entry point can fail with.

Constants§

ACCESS_PERSONAL_DATA
The personal-data declarations for the three issue #878 tables, for a venture that applies MIGRATION_SERVICE_ACCOUNTS, MIGRATION_GRANTS and MIGRATION_AUDIT under a module of its own and must declare what they hold. Orgs declares the same four sets.
MIGRATION_AUDIT
The audit log (issue #878): audit. Public for the reason MIGRATION_SERVICE_ACCOUNTS is.
MIGRATION_GRANTS
Access grants (issue #878): grants. Public for the reason MIGRATION_SERVICE_ACCOUNTS is.
MIGRATION_SERVICE_ACCOUNTS
Service accounts and their keys (issue #878): service_accounts. Public so a venture with its own identity, which does not mount this module’s routes, can apply the table under its own module.
MODULE_NAME
The module’s name: it is mounted at /v1/orgs, and its config keys are prefixed ORGS_.
TEMPLATE_INVITATION
The registry id a venture overrides.

Functions§

default_templates
The module’s default template, for Harness::builder().templates(..). It renders in the theme the module resolves for each mail (the venture’s core Brand, with the deployment’s MAIL_THEME config on top); use themed_templates to compose the venture’s own theme.
member_role
The role sub holds in org_id, or None when they are not a member — the one call an external app needs to answer “may this person see this organization’s page”, without going through HTTP.
require_staff
Authorizes a staff caller for an admin action: a machine holding ADMIN_TOKEN, or a person whose verified subject is a member of staff_org holding one of roles.
themed_templates
The module’s template in theme, the venture’s own style. The deployment’s MAIL_THEME config still applies on top.