Conversation
Reviewer's GuideCompletes the member-management grant-only cutover by binding organizations.* permissions to the Organization root, adding them to scoped admin roles, enforcing every member surface with payload-scoped require_can, and aligning permission reporting, schema artifacts, migration cleanup, and tests with the new legacy-inert contract. Sequence diagram for grant-only member management authorizationsequenceDiagram
actor User
participant GraphQL
participant Resolver
participant Organization
participant Grants
User->>GraphQL: organizationMembers(organizationId)
GraphQL->>Resolver: Resolve payload organization
Resolver->>Organization: _org_or_deny(organizationId)
Organization-->>Resolver: Organization
Resolver->>Grants: require_can(VIEW_ORG_MEMBERS, org)
alt grant exists
Grants-->>Resolver: Authorized
Resolver-->>GraphQL: Member list
else no grant
Grants-->>Resolver: PermissionDenied
Resolver-->>GraphQL: Access denied
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…der) The org-admin cutover (#2443/#2444/#2445) left the legacy org-permission machinery with no consumers. Remove it: - Delete the HasOrgPerm strawberry extension (accounts/extensions.py) + its tests — @hasOrgPerm was already gone from the schema; nothing imports it. - Delete get_user_permitted_org (accounts/permissions.py) and the permissioned_queryset / perm_filter / _perm_q / _org_perm_exists_across_fields legacy predicates (common/permissions/utils.py) — zero remaining consumers. - Drop the X-Organization-ID header read entirely: teams list-reads now take the org from the organizationId filter (header-free like reports/member management); the OrganizationMiddleware + get_current_organization + active_org helpers had no other runtime consumers (notes/clients never read the header). - Tests: teams reads rewritten to the payload-org contract; delete the HasOrgPerm / org-context / permissioned_queryset unit tests; docstrings that cited the deleted HasOrgPerm now describe the grant cutover.
613ecf8 to
2bf3ffc
Compare
…der) The org-admin cutover (#2443/#2444/#2445) left the legacy org-permission machinery with no consumers. Remove it: - Delete the HasOrgPerm strawberry extension (accounts/extensions.py) + its tests — @hasOrgPerm was already gone from the schema; nothing imports it. - Delete get_user_permitted_org (accounts/permissions.py) and the permissioned_queryset / perm_filter / _perm_q / _org_perm_exists_across_fields legacy predicates (common/permissions/utils.py) — zero remaining consumers. - Drop the X-Organization-ID header read entirely: teams list-reads now take the org from the organizationId filter (header-free like reports/member management); the OrganizationMiddleware + get_current_organization + active_org helpers had no other runtime consumers (notes/clients never read the header). - Tests: teams reads rewritten to the payload-org contract; delete the HasOrgPerm / org-context / permissioned_queryset unit tests; docstrings that cited the deleted HasOrgPerm now describe the grant cutover.
b940d30 to
7aa68e5
Compare
2bf3ffc to
c6db0a8
Compare
…der) The org-admin cutover (#2443/#2444/#2445) left the legacy org-permission machinery with no consumers. Remove it: - Delete the HasOrgPerm strawberry extension (accounts/extensions.py) + its tests — @hasOrgPerm was already gone from the schema; nothing imports it. - Delete get_user_permitted_org (accounts/permissions.py) and the permissioned_queryset / perm_filter / _perm_q / _org_perm_exists_across_fields legacy predicates (common/permissions/utils.py) — zero remaining consumers. - Drop the X-Organization-ID header read entirely: teams list-reads now take the org from the organizationId filter (header-free like reports/member management); the OrganizationMiddleware + get_current_organization + active_org helpers had no other runtime consumers (notes/clients never read the header). - Tests: teams reads rewritten to the payload-org contract; delete the HasOrgPerm / org-context / permissioned_queryset unit tests; docstrings that cited the deleted HasOrgPerm now describe the grant cutover.
Cut the teams domain over to the grant model on main (ADR 0001 §5.3, teams slice). The three team mutations read authority through require_can() (the grant predicate) instead of the legacy HasOrgPerm / permissioned_queryset check. - accounts/groups.py: ORG_ADMIN / ORG_SUPERUSER scoped RoleDefs carrying teams.* only (the member-management and reports codenames resolve to no concrete model, so they cannot ride a RoleDef — they stay legacy until their own slices). - accounts/services.py + apps.py: sync_roles provisions the Role rows; backfill_org_admin_grants() converts every existing org-admin PermissionGroup membership into a Grant (post-migrate, before reconcile). Legacy groups are kept (dual) so member management/reports and the admin portal keep working off the legacy arm. - teams/schema.py: create/update/deleteTeam swap HasOrgPerm for require_can(user, perm, org) — grant-only. - teams/models.py: Team declares OrgScoped (org_via=()) — permissions.E005 requires it now that a scoped Role grants teams.*. - common/permissions/domain.py: teams joins LEGACY_INERT_APPS — its legacy rows are no longer reported and the global tier folds per org (like shelters). The teams READ query stays membership-gated. - docs/adr/0001: status note for the teams-first grant-only landing. Tests: teams 65 passed, accounts/common 521 passed, shelters/reports/notes/ tasks 793 passed; system checks clean; no migration drift; schema.graphql regenerated (the @hasOrgPerm directive drops off the three mutations).
The teams read stays member-shared (mobile note/task team pickers) and now also authorizes holders of teams.view_team at the org without membership — a role-backed ORG_ADMIN/ORG_SUPERUSER grant, a direct-grant operator, or the global tier. This keeps the read coherent with the grant-only mutations (who-can-manage ⊇ who-can-list) and with the per-org permission report, closing the can-mutate-but-not-read gap for non-member holders. - teams/schema.py: teams resolver allows membership (unchanged directory arm) OR can(user, teams.view_team, org); same denial message. - teams/tests/test_grant_authorization.py: TeamReadGrantAuthorityTestCase — non-member with view grant lists, superuser without membership lists, non-member without a grant is denied.
…n equivalence tests Adversarial-review fixes: - teams/schema.py: the three mutations resolved the header org with Organization.objects.get(...), which now runs BEFORE the (previously extension-level) authz and would raise an uncaught DoesNotExist for an unknown org id. Resolve via filter().first() + PermissionDenied (same denial as the read resolver) — module-level helper, since strawberry-django mutation resolvers are invoked unbound. - test_current_user_reachability.py: the ORG_ADMIN-member equivalence test asserted the LEGACY predicate for every reported perm, which is vacuous for teams.* (grant-only now; the member still holds the inert legacy group). Assert can() for teams.* and legacy for member-mgmt/reports, and add CurrentUserTeamsReportCanEquivalenceTestCase — the teams report≡can() tripwire (scoped grant, member superuser, user_permission holder), mirroring the shelters equivalence.
… grant-only RFC 0003 first step folded into the teams cutover: the teams READ now authorizes via can()/require_can(teams.view_team) instead of org membership, so the whole teams surface (read + write) is grant-based and the membership arm is gone. - notes/groups.py: Team.perms.VIEW on the CASEWORKER template + scoped CASEWORKER_ROLE RoleDef (teams.view_team only). - accounts/services.py: sync_roles provisions it; three backfills deduped into _backfill_role_grants() with shelter/org-admin/caseworker wrappers; accounts/apps.py wires backfill_caseworker_grants() into post-migrate. - teams/schema.py: teams read is require_can(user, teams.view_team, org). - tests: grant authz read/write contract incl. role-backed caseworker reads and member-without-grant denied; role-manager dual-write tests updated (CASEWORKER is role-backed now); admin/mutation query guards pin the bounded per-grant-holder admin cost (pre-existing for shelters since #2412); teams report≡can() equivalence tripwire added. - docs/adr/0001: read is grant-only via the Caseworker role.
…rm from team mutations)
…cated fallback - teams query org = filters.organizationId (authoritative) else the X-Organization-ID header, which is now a deprecated backward-compatible fallback while clients migrate to the filter (to be stripped later) - admin Teams page passes activeOrg.id as the filter so switching orgs re-runs the query for the right org - gate FE actions on grants: Add on teams.add_team, per-row Edit/Delete on teams.change_team / teams.delete_team — view-only holders (e.g. role-backed caseworkers) see a read-only directory with no actions menu - ThreeDotMenu takes canEdit/canDelete and conditionally renders the actions - regen schema.graphql + expo shared-clients schema + ba-platform types - extend TeamsPage tests: org filter variable assertion + view-only hides Add and the per-row actions menu
The read and the three mutations each resolved the acting org the same way (filter's organizationId when present, else the X-Organization-ID header, PermissionDenied on an unknown org) via two near-duplicate helpers. Merge _active_org / _resolve_teams_org into one module-level _resolve_org(info, filters=None); revert a no-op destructure restyle in the Teams page.
strawberry-django's `auto` resolves the FK's `organization_id` column to the same `TeamFilter.organizationId: ID` input (exact-match, OR/AND-safe) the hand-written filter_field produced — schema.graphql is byte-identical. Remove the custom Q-building method and its Q/Info imports.
…erived for update/delete The team mutations stopped reading the active-org machinery entirely, so the team surface no longer needs X-Organization-ID (only the read keeps it as a deprecated fallback while mobile migrates to the organizationId filter): - createTeam: CreateTeamInput gains a required organizationId (there is no row yet to scope by) — authorize ADD at it, create there. - updateTeam / deleteTeam: the payload already names the team by id; resolve the row and authorize CHANGE/DELETE at team.organization. The org fetch is gone from both (update 13->12, delete 9->8 queries, pinned). - teams/selectors.team_get: organization confine is now optional. - FE TeamFormDrawer passes activeOrg.id on create; tests updated (org provider + organizationId in mock). - schema.graphql + ba-platform types + expo clients schema regenerated - Backend full suite: 1748 passed.
_can(user, perm) was a pass-through for can(user, perm, org=self.org) with a single call site; inline it (with the file's local-import style) and delete the helper.
…rop trailing newline) The committed file was last regenerated with a manual node one-liner that appended a trailing newline; graphql-codegen (what CI's generate-graphql-types runs) writes the file without one, so 'Make sure GraphQL Schema is up to date' failed on git diff --exit-code. Content is otherwise byte-identical.
Audit of PR comments/docs — strip historical narrative (PR/#-references, 'pre-role-back baseline', dated 'on main' claims), drop rationale that is restated elsewhere (phantom-ContentType constraint, dual-write, RFC 0003 first step), and condense module/class docstrings that re-list what the per-test docstrings already pin. Comment/docstring-only; no behavior change (full affected test files still pass).
Comment-only: drop the self-evident explanation blocks (raw-id-vs-autocomplete cost, the readonly object-grant columns, the per-member strict-test narrative, the org-filter auto field note) and compress the widget/form/mirror docstrings to their essential why. The behavior is unchanged and the strict zero-delta query test still guards the N+1 regression the removed comments described.
…change Regression for the review flag that member-role management might delete every scoped Grant at the org: member_roles_replace is add + scoped remove, so a grant-only role assigned independently (a scoped Role with no PermissionGroup) must survive a demotion that revokes the member's invitable roles.
…e refusal, m2m mirror - teams/schema: validate client org ids with get_or_none (blank/garbage denies like unknown instead of raising ValueError); empty filter id no longer falls back to the header; missing vs. foreign teams share the standard PERMISSION refusal (no existence oracle). - common/permissions: single PERMISSION_DENIED_MESSAGE. - accounts: enforce the membership <-> Grant mirror at the User.groups m2m edge (accounts.signals) — covers admin/scripts/shell, reverse writes and clear(); UserAdmin.save_related hook removed. Batch role lookups. Cascading PermissionGroup delete deliberately keeps Grants (teardown); the admin delete page says so. - sync_roles provisions one _all_role_defs() list; ORG_SUPERUSER_ROLE derives from ORG_ADMIN_ROLE; test pins role bundles <= templates. - tests: denied-path assertions check the message (no more passing on a crash); new malformed/blank/null org-id and oracle tests; signal tests; backfill tests rebuild pre-cutover state via _add_legacy_membership. - docs: ADR §5.3 + overview note the invariant and actual landing route.
- TeamsPage: skip the query without View (server would refuse). - TeamFormDrawer: only create needs the active org (row-derived otherwise). - ba-platform: drop stale teams mention from the legacy-only comment list.
- tests: drop the PERMISSION_DENIED alias and use the shared message constant; make _assert_denied's expected message required (no defensive branch); drop two redundant sync_roles() setUps the base class covers. - role_manager: remove the now-unused organization kwarg on the mirror helpers and the single-use scoped_role_for_group wrapper (admin batches through scoped_roles_for_groups); inline the single-use ORG_ADMIN_ROLE_PERMISSIONS constant. - perf: batch the admin delete-page role lookup (N -> 1); select_related the organization in _backfill_role_grants; skip the signal when pk_set is empty; skip the message-only uniqueness lookup when a team edit does not change the name. - comments: remove duplicated/stale mirror notes (OrgRoleManager era), compress query-pin and refusal comments, stop restating the mobile client list in two places.
deleteRooms/deleteBeds read the deleted pks back with values_list() and no ordering, so the GraphQL response followed the DB's unspecified row order — DeleteRoomsMutationTestCase::test_delete_multiple_rooms is latently flaky and failed on CI. Return the requested order (duplicates collapsed) and pin the contract with a reversed-request regression test for both bulk deletes.
…orts.view_reports The reports slice of the org-admin cutover (ADR 0001 §5.3), mirroring teams: - accounts/seed._resolve_permissions now binds each codename to a REAL model's ContentType when one declares it in Meta.permissions (resolved from the app registry, so provisioning never depends on create_permissions ordering) — reports.view_reports on ScheduledReport rides a scoped Role past the phantom-ContentType guard. Portal codenames no model declares (organizations.*) keep the synthesized-CT fallback for legacy provisioning. - ORG_ADMIN/ORG_SUPERUSER Roles add reports.view_reports; existing backfilled grants inherit it from the Role row on the next sync_roles (no re-backfill). - reportSummary (GraphQL) authorizes via require_can at the header org; the DRF interaction-data export authorizes via can() at the org_id target. Membership no longer consulted; legacy-only holders fail closed. ScheduledReport declares OrgScoped (permissions.E005). - reports joins LEGACY_INERT_APPS (legacy rows not reported; global tier folds). - Member management (organizations.*) is the last legacy-only domain.
- reports/tests/test_grant_authorization.py: grant-only contract (role-backed ORG_ADMIN reads+exports; legacy-only denied; direct-grant holder without membership; member denied; cross-org denied; superuser global tier). - reports/tests/test_views.py: grant fixture is a scoped Role + Grant now. - accounts/tests/test_current_user_reachability.py: reports moved to the can() arm (predicate keyed on LEGACY_INERT_APPS); global-tier folds updated; user_permission fold test extended to reports. - accounts/tests/test_roles.py: phantom/E005 docs reflect real-model binding. - schema.graphql + expo shared-clients schema.ts regenerated (@hasOrgPerm drops off reportSummary); ADR §5.3 status notes reports joined.
(Amends the reports slice — these test files were left unstaged.)
…anizationId Full cutover for the reports slice (web feature, no deprecated header fallback): the X-Organization-ID header is never consulted for reports. - reportSummary(organizationId: ID!, ...) resolves the org from the payload via a fail-closed _org_or_deny (unknown AND non-numeric ids deny — no ValueError) and require_can(reports.view_reports) at that org. - DRF HasReportAccess fails closed on unknown/non-numeric org_id too. - FE: the betterangels-admin Reports page (the only reports consumer) passes activeOrg.id as organizationId; query + codegen regenerated. - accounts/seed: retire_superseded_phantom_permissions() retires phantom Permission/ContentType rows superseded by real-model binding, RE-POINTING any user_permission/role/group/template references onto the real twin first (no silent revocation); keeps organizations.* phantoms (no real twin). Wired into _seed_on_migrate after the backfills. - Tests: payload-org + headerless/stale-header/unknown/non-numeric coverage; phantom-retirement test asserts a user_permission survives re-pointing. - ADR §5.3 notes reports is header-free.
… the real twin Deleting a superseded phantom Permission row that a user_permission (or role/group/template) references would SILENTLY REVOKE the holder — nothing else re-points user_permissions. retire_superseded_phantom_permissions now moves every reference (user_permissions, auth groups, Role, PermissionGroup, PermissionGroupTemplate M2Ms) onto the real row before deleting; the test asserts a direct user_permission grant survives on the real row.
- accounts/seed.py: avoid incompatible assignment when app config is missing — skip via continue instead of reassigning to an empty tuple - reports/tests/test_grant_authorization.py: make ReportSummaryGraphQLGrantMixin inherit GraphQLBaseTestCase so graphql_client/execute_graphql/assert helpers type-resolve; drop redundant direct GraphQLBaseTestCase base from the two test classes
…EV-2559) A holder that already references both the phantom Permission and its real twin would make the re-point .update() collide with the through table's unique constraint and abort post_migrate. Drop the phantom reference first when the holder already holds the real row.
…mirror The membership mirror (accounts.signals) now Grant-mirrors a direct PermissionGroup membership, so the legacy-only denial fixture must drop the mirror to model a pre-cutover holder.
7aa68e5 to
38fad3c
Compare
…tion model Phase 1 of the member-management cutover (ADR 0001 §5.3) — make the organizations.* portal codenames role-able: - accounts/seed.seed_org_portal_permissions(): binds the five UserOrganizationPermissions codenames to the real django-organizations Organization ContentType (the org-root the actions act on). Runs in _seed_on_migrate before sync_roles, so RoleDef/template resolution binds the real rows; the phantom retirement then drops the old synthesized (organizations, member/...) rows. - permissions.E005: the org-root Organization model is identity-scoped — a scoped Grant scopes TO an organization, so a permission on Organization is an org-level action on the very row the grant scopes to (no org_via hop). No carve-out beyond the root; other unscoped models still error. - accounts/groups.py: ORG_ADMIN/ORG_SUPERUSER RoleDef bundles now mirror their legacy templates exactly (admin = member mgmt + reports + teams; superuser = admin + change_org_member_role) so a mirrored grant never amplifies. - Tests: E005 quiet for org-root perms on a scoped role; phantom retirement now retires the organizations phantoms too (real twin exists) while keeping genuinely twin-less phantoms and re-pointing user_permission references.
…org root) Completes the last org-admin domain cutover (ADR 0001 §5.3): member reads and the add/remove/change-role mutations authorize via require_can at the payload org; legacy PermissionGroup rows alone no longer authorize. Every ORG_ADMIN/ORG_SUPERUSER template permission is now grant-backed. - accounts/seed.py: seed_org_portal_permissions binds the organizations.* portal codenames to the real org-root Organization ContentType at post_migrate (replacing phantom rows); retire_superseded_phantom_permissions re-points Permission-M2M refs before deleting superseded phantom rows. - accounts/groups.py: ORG_ADMIN/ORG_SUPERUSER Role bundles carry the member-management codenames (change_org_member_role is superuser-only); backfilled Grants inherit via sync_roles. - accounts/schema.py: organizationMember(s) + add/remove/change-role mutations drop HasOrgPerm/get_current_organization; authorize via require_can at the payload org through a shared _org_or_deny helper. Header-free (web feature). - common/permissions/domain.py: organizations joins LEGACY_INERT_APPS; DUAL_APPS stays empty. - permissions.E005 is identity-scoped for the org-root Organization model. - schema.graphql + expo schema.ts regenerated via real codegen. - docs/adr/0001: §5.3 status block for the member-management cutover. - Tests: new accounts/tests/test_member_management_grant_authorization.py; reachability legacy-domain class flipped to a whole-bundle can()-equivalence tripwire; permissions/mutations/queries member cases updated.
…2558) removeOrganizationMember / changeOrganizationMemberRole no longer take organizationId + user/id — they are keyed on the OrganizationUser membership row (membershipId) and authorize require_can at the row's org, mirroring teams' row-keyed update/delete. A missing/unknown membership fails closed with PermissionDenied (no int() ValueError path). - OrganizationMemberType exposes membershipId (annotated per-org); the admin + shelter-operator user pages select it and send it on remove. - E005 org-root exemption narrowed to an ORG_ROOT_PORTAL_CODENAMES allowlist. - retire_superseded_phantom_permissions drops a holder's phantom reference before re-pointing when it already holds the real row (unique-constraint guard) + regression test for the both-rows holder. - schema.graphql + expo/ba-platform/react codegen regenerated; ADR 5.3 updated; member-management tests moved to the membership-keyed contract.
- test_member_management_grant_authorization: mixin inherits HasGraphQLProtocol (tasks idiom), declares graphql_client, types org params as Organization, int()s the membership pk — clears attr-defined/no-any-return errors. Base order flipped to (GraphQLBaseTestCase, Mixin) like the tasks tests. - seed._resolve_permissions: list() the app models (Iterator vs tuple assign); str() the lazy perm label for Permission(name=...).
- drop the auto-mirrored Grant in the legacy-only denial fixture (the User.groups m2m edge mirrors one on direct membership adds); - measured member-add query pin is 29 after the teams/reports upmerge.
c6db0a8 to
d0642d1
Compare
The org-admin cutover (#2443/#2444/#2445) left the legacy org-permission machinery with no consumers. Remove it: - Delete the HasOrgPerm strawberry extension (accounts/extensions.py) + its tests — @hasOrgPerm was already gone from the schema; nothing imports it. - Delete get_user_permitted_org (accounts/permissions.py) and the permissioned_queryset / perm_filter / _perm_q / _org_perm_exists_across_fields legacy predicates plus the now-dead active_org helper (common/permissions/utils.py) — zero remaining consumers after the cutover. - Tests: drop the HasOrgPerm / permissioned_queryset unit tests; docstrings that cited the deleted HasOrgPerm now describe the grant cutover. The X-Organization-ID header is deliberately KEPT: mobile's useOrgTeams callers still send only { isActive } and rely on it, so teams reads keep the deprecated header fallback (filter first, with the blank/malformed-id guards). The OrganizationMiddleware + get_current_organization stay with it; the strip is DEV-2566, done once mobile passes organizationId.
|
🚀 Expo continuous deployment is ready for betterangels!
iOS Simulator Build: Simulator Build Link |
|
🔍 [shelter-web] Preview available at: https://shelter.dev.betterangels.la/branches/feat-perm-members-grant-only Last updated: 2026-09-10T15:57:28.500Z |
|
🔍 [betterangels-admin] Preview available at: https://admin.dev.betterangels.la/branches/feat-perm-members-grant-only Last updated: 2026-09-10T15:57:28.495Z |
f9bbab9 to
332d76f
Compare
Summary
Tracks DEV-2558 (epic DEV-2551) — member-management
organizations.*grant-only cutover.Completes the grant-based authorization cutover for member management (ADR 0001 §5.3) — the last legacy-only org-admin domain. Member reads and the add/remove/change-role mutations now authorize exclusively through grants (
require_can/can()over role-backed ORG_ADMIN/ORG_SUPERUSER Grants, scoped direct Grants, or the global tier). LegacyPermissionGrouprows alone no longer authorize anything.This stacks on the teams (#2443) and reports (#2444) cutovers; with it, every ORG_ADMIN/ORG_SUPERUSER template permission is grant-backed and the org-admin legacy rows are fully inert.
The org-root bind (why this was blocked before)
The
organizations.*portal codenames are registered on no model, so the last-token phantom-ContentType guard blocked them from riding scoped Roles.seed_org_portal_permissionsresolves the org-root model — theOrganizationContentType — and binds the five codenames (access_org_portal,add_org_member,remove_org_member,view_org_member,change_org_member_role) to it atpost_migrate, replacing the phantom rows. Superseded phantom rows are retired idempotently with references re-pointed first (retire_superseded_phantom_permissions). permissions.E005 is identity-scoped so the org's own rows never trip the cross-object guard.Changes
accounts/seed.py—seed_org_portal_permissions()binds org-root codenames to the realOrganizationContentType;retire_superseded_phantom_permissions()re-pointsPermission-M2M references before deleting superseded phantom rows.accounts/groups.py—ORG_ADMIN_ROLE_PERMISSIONS/ORG_SUPERUSER_ROLE_PERMISSIONSbundle the member-management codenames (superuser addschange_org_member_role). Backfilled org-admin Grants inherit from the Role row on the nextsync_roles.accounts/schema.py—organizationMember/organizationMembersand the add/remove/change-role mutations dropHasOrgPerm/get_user_permitted_org/get_current_organization; they authorize viarequire_can(...)at the org in the payload (organizationId) through a shared_org_or_denyhelper.X-Organization-IDfallback: payload org only. (Mutations already carriedorganizationId.)common/permissions/domain.py—organizationsjoinsLEGACY_INERT_APPS;DUAL_APPSstays empty (every org-admin domain has now cut over).schema.graphqlregenerated (member field directives dropped,@hasOrgPermdirective definition removed); exposchema.tsregenerated via real codegen.test_member_management_grant_authorization.py(role-backed allow, legacy-only DENY, direct-grant allow, cross-org isolation, global tier, header inert);test_current_user_reachability.pylegacy-domain class flipped to a whole-bundlecan()-equivalence tripwire;test_permissions.py/test_mutations.py/test_queries.pymember cases updated to grant semantics.Behavior notes / review focus
currentUserorg entries now fold the ORG_ADMIN bundle'sorganizations.*perms from the grant arm only (the legacy arm no longer contributes for member management).post_migrateaftersync_roles; phantom retirement is idempotent and re-points refs so no permission is silently revoked. No data migration; Grants backfill already covered the new codenames via Role rows.currentUsercontract.Test plan
ruff check/formatclean on changed files.schema.graphql+ exposchema.tsregenerated from real codegen (not hand-edited).Summary by Sourcery
Complete the grant-only authorization cutover for organization member management and make all org-admin permissions grant-backed.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests:
Chores: