§22 S4: scope-role invitation backend + capability flags (@S4 C.2)

The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).

- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
  invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
  preserving a stronger child grant — no negative override), revoke, list,
  user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
  POST keying on optional collection_id so the invite UI's one control maps
  to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
  contributors refused (C.2.4); a pending grantee's row is recorded but
  confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
  notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
  can_invite, can_contribute, role) on the project/collection GETs to drive
  the C.3 role-aware empty states.

Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 23:47:28 -07:00
parent e6bd69f132
commit c9fd1c535e
7 changed files with 740 additions and 1 deletions
+20
View File
@@ -682,6 +682,26 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
return row is not None
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
collection within it)? Managing membership is an *Owner* capability whose
reach covers the project — a deployment owner/admin, a global Owner, or this
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
collection Owner's reach is its own collection only (C.2.3), so it is not
offered project-scope invites. Identical to `is_project_superuser` — the
invite gate IS "is an Owner over this project"."""
return is_project_superuser(user, project_id)
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
whose reach covers it — the collection's Owner, its project's Owner, a global
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
narrowest invite reach; a collection Owner who is nothing more may invite
here but not at the project or globally (C.2.3)."""
return is_collection_superuser(user, collection_id)
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for