Skip to content

Organisation and permissions

Jaloliddin Ismailov edited this page Oct 4, 2026 · 7 revisions

Everything the Hub knows about who may do what lives in one table. This page is the reference for that table — authority_records — and for the engine that turns its rows into permissions. If you are changing anything that touches access control, read this first.

Accounts, organisations and scopes

An account is a row in accounts: a person, or the technical identity of an organisation. It holds sign-in credentials, membership status and profile details — but conspicuously little authority. The role column on an account distinguishes entity kind and technical administration only; it grants no constituency power on its own. A title without a record is decoration.

The things authority can apply to are the scopes:

Scope Examples
platform The constituency itself — global focal points, safeguarding officers
body Governance bodies — the Council, the Secretariat, the steering group
working_group Thematic working groups
organisation Member and partner organisations
negotiation_track UNFCCC negotiation tracks
team Operational teams

A scope is what keeps authority local. "Contact point" means nothing until you know which group, and the same person can hold entirely different records in different scopes simultaneously — contact point of one working group, ordinary member of another, liaison to a body, representative of their organisation.

The authority record

An authority record is a mandate or a participation, expressed as one row:

authority_records
─────────────────────────────────────────────
id            bigint
account_id    bigint  → accounts
kind          enum    'mandate' | 'participation'
role          text    e.g. 'wg.contact_point', 'body.liaison'
scope_type    enum    'platform' | 'body' | 'working_group' | …
scope_id      bigint  the row the role applies to
status        enum    'active' | 'ended' | …
council_seat  text    resolved seat key, when the role carries one
starts_at     timestamptz
ends_at       timestamptz   null while current
substitute    boolean       covering a seat held by someone else
migrated_from text          provenance for backfilled rows

Two fields carry most of the meaning. kind separates a mandate — a responsibility conferred by a governance process (election, selection, appointment) — from participation — a grant of access (joining a working group, a team enrolment). The distinction matters downstream: mandates derive from S-series policy, and several checks ask "is this a mandate" rather than "does this role exist". role is a key into a controlled vocabulary — AUTHORITY_ROLES in src/lib/authority.ts — that declares what the role is allowed to look like:

focal_point: {
  label: 'Global Focal Point',
  scopeTypes: ['platform'],
  councilSeat: 'focal_point',
  capabilities: [
    'council.vote',
    'constituency.coordinate',
    'points.award',
    'intelligence.contacts.read',
  ],
  requiresCw: true,   // requires active Constituency Work membership (S17)
},

Every role in the registry cites the policy it encodes — S13 for bodies and Council, S25 for contact-point mandates, S15 for liaisons, S10 for elections. When you add a role, you add the citation with it.

From rows to permissions

Nothing in the code checks authority_records directly for a permission decision. The single entry point is deriveAuthority() in src/lib/authority.ts, which every endpoint and collection hook reaches through src/lib/access.ts. Given an account, it produces an AccessProfile:

interface AccessProfile {
  teamRoles: string[]                                  // legacy team ids
  wgAssignments: { wgSlug: string; role: string }[]
  negotiationAssignments: { scopeType: string; scopeId: string; role: string }[]
  councilSeats: string[]       // 'wg:finance', 'org:12', …
  bodyScopes: string[]         // platform-body scopes the account sits in
  records: { … }               // resolved current rows, for audit and display
  unmappedRecords: number      // rows whose role resolved to nothing
}

Two rules in deriveAuthority are worth memorising because they define the system's temperament:

  • Deny, never widen. A row whose role string matches no registry entry — say a legacy overlord value migrated from an older system — resolves to nothing. It is counted in unmappedRecords, visible to operators, and grants zero capabilities. Unknown data is a signal, never a loophole.
  • Scopes gate roles. resolveRecordRole only resolves a registry key when the record's scope_type is inside that role's declared scopeTypes. A focal_point record scoped to a working group is invalid on its face, even though focal_point is a real role — the registry says it only exists at platform scope.

Council seats and substitutes

Council authority is the subtlest part of the model because the seat belongs to the scope, not the person. A working group holds a Council seat; the person holding wg.contact_point there occupies it. Two consequences follow.

First, seats are the electorate — councilElectorate in src/lib/decisionRuntime.ts counts distinct seats with an eligible holder, not accounts. A substitute covering a vacant seat does not inflate the electorate, and a holder whose Constituency Work membership has lapsed does not count.

flowchart TB
    WG["Working group: Finance"] -->|holds| Seat["Council seat<br/>'wg:finance'"]
    CP["Contact point<br/>authority record"] -->|occupies| Seat
    Sub["Substitute record<br/>substitute = true"] -.->|covers while vacant| Seat
    Seat --> Electorate["councilElectorate<br/>(counts seat once)"]
Loading

Second, substitutes are rows, not hacks. A substitute record carries substitute = true against the same seat, scoped so it can only confer the covered seat's vote — the role registry keeps substitute grants confined to what the principal's record would have provided. Granting a substitute requires locking both accounts in a fixed order, which brings us to concurrency.

Writing records safely

All writes go through src/lib/authorityService.ts — grantAuthority, endRecord, revokeAuthority. Direct inserts into authority_records outside that module are a bug by definition. The write path serialises concurrent operations with two layers:

  1. Advisory locks on every account involved, acquired ascending and deduplicated (withAuthorityLock in src/lib/authorityLock.ts). The deterministic ordering is what makes deadlock impossible.
  2. Row locks inside the transaction, then the write.
sequenceDiagram
    participant A as grantAuthority()
    participant L as advisory lock
    participant T as transaction
    participant DB

    A->>L: lock accounts [7, 42] (sorted)
    L->>DB: pg_advisory_xact_lock(7)
    L->>DB: pg_advisory_xact_lock(42)
    A->>T: begin
    T->>DB: re-read + re-validate principal row
    T->>DB: insert authority_records
    T->>DB: commit (locks release)
Loading

The re-read inside the lock is load-bearing. The endpoint checked the record was valid before acquiring the lock; between check and lock, another request may have ended it. Re-validating inside the transaction is what makes the check meaningful — the same pattern appears in the membership-record end endpoint and in substitute grants.

A partial unique index on (account_id, role, scope_type, scope_id) WHERE status = 'active' is the database's own guarantee that an account cannot hold the same active record twice, even if two racing requests both pass validation.

Time windows

Records carry starts_at / ends_at because authority in YOUNGO is genuinely time-bounded: elected mandates have terms, project roles end with the project, participation lapses. deriveAuthority only sees records inside their window, and grants that start in the future are honoured when their time comes — a record dated for next month grants nothing today.

What this means in practice

  • Adding a role = an entry in AUTHORITY_ROLES with label, allowed scopeTypes, capabilities, and a policy citation. Then grant it through the service.
  • Checking authority in an endpoint = call accessProfile(req) (or the hook-level equivalent) and test capabilities/seats — never hand-join authority_records.
  • Granting or ending = grantAuthority / endRecord in authorityService.ts. Let it lock; don't lock around it differently.
  • Debugging "why can't X do Y" = look at their rows (member/admin/accounts/:id shows records), then check unmappedRecords for strings the registry doesn't know, then check the window dates.

Clone this wiki locally