Skip to content

Support revoking and re-assigning space roles (authorization-scoped latest-wins) #129

Description

@tkuhn

Problem

A granted role can currently be revoked only by the original granter (same signing key — #112's samePublisherClause, AuthorityResolver.java:819). So an admin can't revoke a member another admin admitted, and a member can't leave on their own.

Model: assign/revoke as authorization-scoped latest-wins

State of an (agent, role, spaceRef) key = the sign of the latest authorized assertion by dct:created (subject-IRI tiebreak), active-by-default. This is exactly what preset assignments already do (#302): authorization-scoped latest-wins, not npx:invalidates (AuthorityResolver.java:1064-1068, 1173-1198).

Only new primitive: key-scoped negative assertions (see Revocation vocabulary below) — mirror gen:DeactivatedPresetAssignment (GEN.java:130; active-by-default, a revocation is just a newer authorized row) — one for instantiations (agent, role, spaceRef) and one for attachments (role, spaceRef), both carrying dct:created.

  • Re-assignment needs no new vocab (a newer positive assertion); toggling falls out.
  • Distinct from npx:invalidates, which stays for nanopub-level undo ("this exact statement was a mistake"); the negative assignment is key-level ("agent no longer holds this role, however granted").

Authorization filter — who may publish a winning assertion: whoever's tier could grant the role, plus the assignee for their own role.

Role May set latest assertion
root admin nobody at runtime — constitutional (see below)
admin admins
maintainer admins
member admins, maintainers
observer admins, maintainers, members, + self
  • Self may always revoke its own role (leave) at any tier, but may only assign at observer (self-attest).
  • Forgeability is handled by #302's scoping: the candidate set is filtered to authorized publishers before MAX(dct:created), so a postdated nanopub from an unauthorized key can't win. Among co-equal admins it's "latest claimed time wins" — a shared-clock-trust assumption, fine within a mutually-trusting admin set.

Revocation vocabulary

Both forms are stative (assert a state, not an action), mirroring the existing past-participle marker gen:DeactivatedPresetAssignment. Each is a positive assertion (RDF can't assert negation); the "no longer" is emergent — it's the latest authorized assertion superseding a prior positive, exactly as gen:hasRole's "currently has" is emergent. Two layers, split by arity:

Instantiation revocation (agent → role): gen:RevokedRoleInstantiation

3-component key (space, agent, role) → a typed node, in its own nanopub:

:r a gen:RevokedRoleInstantiation ;
   gen:forSpace <space> ;
   gen:forAgent <agent> ;     # whose role is revoked
   gen:hasRole  <roleIRI> .   # the role revoked (version-pinned, matches the materialized key)
# pubinfo carries dct:created (the latest-wins key) + the signature (= the revoker)
  • One form covers everything except admin. A predicate/back-compat role only materializes via a declared gen:hasRole attachment (nonAdminTierUpdate anchors on it; the materialized row carries gen:hasRole ?role, AuthorityResolver.java:1407), so every non-admin role has a role IRI to key on. gen:hasAdmin is itself a back-compat predicate (BackcompatRolePredicates.java:64) — not a special case.
  • Admin is the sole exception: adminTierUpdate materializes admin RIs from the hasRootAdmin seed / closed-over grants with npa:hasRoleType gen:AdminRole and no gen:hasRole (lines 860-871). Admin revocation therefore keys on the admin tier (gen:hasAdmin / AdminRole); root admins are un-revokable (below).

Attachment revocation (role → space): gen:detachedRole

2-component key (space, role) → a single predicate triple, the stative antonym of gen:hasRole:

<space> gen:detachedRole <roleIRI> .   # role no longer available in the space; admin-authored
  • Mirrors the positive gen:hasRole attachment, which is itself predicate-detected (SpacesExtractor.java:414). Past-participle reads as a resulting state, consistent with the Deactivated… convention.
  • Authorized by admins (matching who may attach). Cascades: a winning gen:detachedRole removes the role's availability, so all instantiations anchored on that attachment drop out (the structural-rebuild case).
  • Applies to preset-derived attachments too. Preset-bundled roles materialize as the same gen:RoleAssignment shape (forSpaceRef + gen:hasRole, differing only by npa:derivedFromPreset, AuthorityResolver.java:1082-1088), so they share the (space, role) key. gen:detachedRole competes against both direct and preset-derived rows — giving per-role opt-out, which preset deactivation (all-or-nothing over the whole bundle, scoped to derivedFromPreset) can't. Keyed on (space, role), not (space, role, preset): "R is not a role here, whatever supplies it." Implementation: presetAttachmentValidationUpdate's INSERT gains FILTER NOT EXISTS { newer authorized gen:detachedRole for (space, role) } + displacement DELETE; the comparison uses the preset assignment's dct:created (:1099) as the derived attachment's effective timestamp.
  • DECISION — sticky vs. latest-wins on preset re-assignment. Under plain latest-wins, a newer gen:PresetAssignment (later dct:created than the detach) re-attaches R. If a detach should survive preset re-assignment ("sticky opt-out"), that needs a per-(space, role) suppression that outranks the bundle rather than competing on timestamp — a deliberate extra rule. Default for v1: plain (non-sticky) latest-wins.
  • (Role-half only — a preset's views are read-time in Nanodash, unaffected by server-side detach.)

Common to both

  • No new authority/ordering fields: dct:created (pubinfo) is the latest-wins key; the signature is the actor. A self-revoke (leave) is simply signer == forAgent (instantiation form only).
  • Both are new, previously-unused terms → back-compat (below).
  • Wiring: extractor recognizes the new term (add to the type/predicate set at SpacesExtractor.java:65) and emits a negative row — keyed like a RoleInstantiation (instantiation form) or RoleAssignment (attachment form) + dct:created + pubkeyHash, parallel to the DeactivatedPresetAssignment path (SpacesExtractor.java:905-915). The negative competes in the per-key latest-wins at the materialization tier where the key is boundnonAdminTierUpdate (roles), adminTierUpdate (admin), attachmentValidationUpdate (attachments) — not at extraction, since a predicate-keyed instantiation only acquires its role IRI during the declaration join.

Root admins are un-revokable (strict)

Root admins (npa:hasRootAdmin in the root gen:Space nanopub, SpacesExtractor.java:288-300) are constitutional: sourced only from the root-def seed (#110), never assignable or revocable at runtime — no negative assertion can win against them. Removal is only via the root-def / ref lifecycle, so the root cohort is permanent for the ref's lifetime (continuation revisions carry no hasRootAdmin; editing root admins means migrating the ref). Downward powers unchanged — they stay in the admin set.

Exempt them by deriving on demand, not a stamp — the resolver already reads hasRootAdmin from the spaces graph:

FILTER NOT EXISTS {
  GRAPH <spacesGraph> {
    ?def a npa:SpaceDefinition ; npa:forSpaceRef ?spaceRef ; npa:hasRootAdmin ?agent .
  }
}

No re-stamp, no re-ingest, zero consumer impact (admins look as today). Trade-off: root-admin-ness isn't a stamped fact, so a consumer wanting to surface it must join hasRootAdmin itself.

Backwards compatibility

Fully back-compat, given an unused negative-assignment vocab:

  • No existing nanopub is reinterpreted — nothing already published matches the new vocab, so all current memberships stay active.
  • Active-by-default overlay, not a rewrite — state = sign of the latest authorized, non-invalidated assertion on the key; with zero negatives in existing data the latest is always positive → identical to today. npx:invalidates keeps working unchanged (nanopub-level undo).
  • No state-graph shape change — negatives never materialize as rows (they act via the suppression filter / displacement DELETE), root admins use derive-on-demand → active-member rows look as today, no consumer repoint.
  • No re-ingest — nothing to backfill; extractor + resolver changes take effect on the normal periodic rebuild from already-ingested data (a redeploy, not a re-sync).

Rollout realities (not format breaks):

  • Mixed-version fleet — until all servers upgrade, a revocation is honored only on upgraded ones; old servers don't know the vocab and fail open (keep the member). Full effect once the fleet is upgraded.
  • dct:created well-definedness — ensure positives carry it (or define a fallback ordering), since the latest-wins comparison goes live the moment a negative touches that key.

Notes

  • Griefing surface is admin↔admin only (self-perpetuating admin set; root admins exempt). Lower tiers get no peer-revoke; self only touches its own role.
  • dct:created is globally consistent across the fleet (unlike load number, which diverges on resync) — why #302 uses it.
  • Port #302's resolver; canary against the published get-space-members / role-listing query nanopubs (raced /api, first-2xx-wins) before merge.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions