Skip to content

feat(perm): teams fully grant-based & header-free — ORG_ADMIN + CASEWORKER role-backed, org in filter/payload (DEV-2559) - #2443

Merged
vecchp merged 24 commits into
mainfrom
feat/perm/teams-grant-only
Sep 10, 2026
Merged

vecchp merged 24 commits into
mainfrom
feat/perm/teams-grant-only

Conversation

@vecchp

@vecchp vecchp commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What & why

Cut the teams domain fully over to the grant-based authorization model on main (ADR 0001 §5.3, teams slice). The teams surface — read and write — now authorizes via can()/require_can over role-backed templates with backfilled Grants; org membership is no longer consulted for teams.

  • Mutations (createTeam/updateTeam/deleteTeam): grant-only. ORG_ADMIN/ORG_SUPERUSER are role-backed with a scoped Role carrying teams.*.
  • Read (teams query): grant-only. CASEWORKER is role-backed as the RFC 0003 first step — a scoped Caseworker Role carrying teams.view_team — so the workers who pick teams on notes/tasks read via grants.

Why not the §5.3 "all four consumers at once" milestone: on main the member-management codenames (organizations.*) and reports.view_reports resolve to no concrete model, so a scoped RoleDef carrying them fails sync_roles' phantom-ContentType guard. Teams was the slice that could ride Roles today; reports and member management stay legacy-only until their own slices.

Changes

  • accounts/groups.pyORG_ADMIN_ROLE/ORG_SUPERUSER_ROLE scoped RoleDefs carrying teams.*, exported as ORG_ADMIN_ROLES.
  • notes/groups.pyTeam.perms.VIEW added to the CASEWORKER template + scoped CASEWORKER_ROLE RoleDef carrying teams.view_team (RFC 0003 first step; the rest of the caseworker bundle stays legacy until RFC 0003).
  • accounts/services.pysync_roles() provisions all three role sets; the three backfills are deduped into one _backfill_role_grants() helper with backfill_shelter_grants/backfill_org_admin_grants/backfill_caseworker_grants wrappers.
  • accounts/apps.pybackfill_org_admin_grants() + backfill_caseworker_grants() run in _seed_on_migrate after sync_roles().
  • teams/schema.py
    • mutations: HasOrgPermrequire_can(…, teams.*); org resolved via a module-level helper that fails closed on an unknown org header (no DoesNotExist).
    • teams read: require_can(user, teams.view_team, org) — grant-only. Membership helper removed. Org resolution: TeamFilter.organizationId wins; the X-Organization-ID header is the deprecated fallback (_resolve_teams_org fails closed on an unknown org).
  • teams/models.pyTeam declares OrgScoped (org_via = ()); required by permissions.E005 (no migration).
  • common/permissions/domain.pyteams joins LEGACY_INERT_APPS (legacy rows not reported; global tier folds like shelters).
  • Teststeams/tests/test_grant_authorization.py: grant-only read + write contract (role-backed ORG_ADMIN & CASEWORKER read/manage; legacy-only admin denied on mutations; ADD-only cannot update/delete; member-without-grant denied on the read; direct-grant holder & superuser read; grant at org A does not authorize org B). Read-org tests: org filter works without the header, the filter wins over a stale header, and an unknown filter org is denied. Equivalence/report tests strengthened: test_org_admin_member_report_matches_legacy_enforcement now asserts can() for teams.* (was vacuously asserting the legacy predicate), and new CurrentUserTeamsReportCanEquivalenceTestCase pins entry ≡ can() for the teams domain. Role-manager dual-write tests updated (CASEWORKER is role-backed now). Admin/mutation query-count guards updated for grant-holding members. FE: TeamsPage tests assert the query is issued with filters.organizationId and that a view-only holder sees neither Add nor the per-row actions menu.
  • schema.graphql — regenerated (@hasOrgPerm drops off the three mutations; TeamFilter.organizationId and CreateTeamInput.organizationId: ID! added).
  • libs/react/betterangels-admin Teams page + drawer — passes activeOrg.id as filters.organizationId (read) and in the createTeam payload; gates Add/Edit/Delete on the team grants (see design notes). ThreeDotMenu takes canEdit/canDelete and conditionally renders each action.
  • docs/adr/0001 — §5.3 status note.
  • Regenerated clients — expo shared-clients schema, @monorepo/ba-platform types (TeamFilter.organizationId, CreateTeamInput.organizationId).
  • Cleanups from adversarial review — the read's org filter is organization_id: auto (strawberry-django resolves the FK column to organizationId: ID; the hand-written filter_field + Q are gone, schema byte-identical); team_get's org confine is optional; single _resolve_read_org + _org_or_deny helpers replace the duplicated resolvers.

Design notes / deliberate scope

  • Pure grant, no membership arm. Workers read teams via the role-backed Caseworker grant (teams.view_team); admins via ORG_ADMIN. The FE gates on currentUser.organizationsOrganization[].permissions (grant-native since feat(perm): frontend reachability — grants-based org list + global permissions (DEV-2557) #2414).
  • Org filter, header deprecated (read) — mutations are header-free. The read takes the org as a TeamFilter.organizationId (authoritative, so switching orgs re-runs the query scoped to the right org); the X-Organization-ID header is kept only as a backward-compatible fallback — the mobile clients (libs/expo) still rely on it — and is marked for later removal. The mutations never read the header: createTeam carries organizationId in the payload (required — no row exists to scope by yet) and updateTeam/deleteTeam resolve the row the payload names by id and authorize at team.organization (the org is an attribute of the row).
  • FE action gating. The admin Teams page gates Add on teams.add_team and the per-row Edit/Delete actions on teams.change_team/teams.delete_team, so a view-only holder (e.g. a role-backed caseworker) sees a read-only directory — no Add button, no actions menu (previously the actions menu rendered for everyone with View).
  • Backfill at deploy covers every existing admin/caseworker; OrgRoleManager.add_roles mirrors Grants for new ones (role-backed templates). Legacy groups are kept (dual) so member management/reports keep working off the legacy arm until their slices; teams legacy rows are inert (suppressed from reports).
  • Role bundles are partial (teams.* / teams.view_team only) — a documented divergence from the §5.3 "one template, four consumers" plan, forced by the phantom-ContentType guard. Later slices extend the RoleDefs and sync_roles reconciles idempotently.
  • Known, pre-existing admin cost: the Django-admin Organization page does ~6 queries/member for grant-holding members (present for shelter operators since feat(perm): cut over the shelter domain to grant-based authorization (DEV-2555) #2412). The query-count guards were updated to pin that bounded budget rather than pretend grant-holding members cost nothing.
  • Behavior flip: a legacy-only holder (PermissionGroup, no Grant) fails closed on teams — intended; backfill covers existing org admins/caseworkers.

Review round 2 — adversarial fixes

  • Malformed/blank org ids deny, they don't crash. _org_or_deny validates with the house get_or_none pk guard, so organizationId: "not-an-id" or "" (read filter, header, or create payload) is a clean PERMISSION refusal instead of a Django ValueError. An absent filter keeps the header fallback; an explicitly empty one denies — it never silently falls back.
  • One refusal for missing vs. forbidden teams. updateTeam/deleteTeam answer a missing team with the same message as a team the caller may not touch (PERMISSION_DENIED_MESSAGE in common/permissions/utils.py), closing an existence oracle.
  • The membership ⇔ Grant mirror is now enforced at the User.groups m2m edge (accounts.signals). Every writer keeps the invariant — OrgRoleManager, the user-admin group picker, scripts, the shell — and the bespoke UserAdmin.save_related hook is gone. Reverse writes (permission_group.user_set.add/remove/clear) are covered. A cascading delete of a legacy PermissionGroup deliberately leaves the Grants (teardown retires legacy rows; the Grants are the successor authority) — the admin delete page now says so instead of implying the capability is revoked.
  • "Template ≈ Role" is guarded. sync_roles provisions from one _all_role_defs() list, ORG_SUPERUSER_ROLE derives from ORG_ADMIN_ROLE, and a test asserts every scoped RoleDef's bundle is a subset of its template's.
  • Denied-path tests can no longer pass on a crash. _assert_denied asserts the expected message; new tests cover non-numeric/blank filter, header and payload ids; an explicitly-null filter id still falls back to the header.
  • FE/docs polish: the Teams query is skipped when the holder lacks View (no doomed request); the org guard moved into the create branch only (edits don't need an active org); stale teams references removed from the useActiveOrgState comment; ADR + overview record the mirror invariant and the actual landing route.
  • Cleanup: removed dead shims (the test-side PERMISSION_DENIED = PERMISSION_DENIED_MESSAGE alias, the now-unused organization kwarg on the mirror helpers, a single-use ORG_ADMIN_ROLE_PERMISSIONS constant, a defensive optional branch in _assert_denied) and pruned duplicated/stale comments. Small perf wins: batched the admin delete-page role lookup (N→1), select_related("organization") in the backfills, an empty-pk_set short-circuit in the signal, and the message-only uniqueness query is skipped when a team edit doesn't change the name.

Verification

  • Backend: full suite 1835 passed, 31 skipped (all apps, incl. hmis); ruff check + format clean; targeted mypy clean on all touched files
  • manage.py check clean (incl. permissions.E005); makemigrations --check → no changes
  • ruff check + format clean; targeted mypy clean on all touched files
  • FE: eslint clean on changed files; TeamsPage/TeamFormDrawer tests extended (vitest + tsc run in CI)

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

The PR makes team creation, updates, and deletion grant-only by introducing scoped ORG_ADMIN/ORG_SUPERUSER roles, backfilling Grants from existing organization-admin memberships, and routing mutations through require_can; it also updates permission reporting, tests, schema output, and ADR documentation while leaving team reads and other legacy domains unchanged.

Sequence diagram for grant-only team mutation authorization

sequenceDiagram
    actor User
    participant TeamsAPI
    participant Organization
    participant PermissionService
    participant GrantStore
    participant TeamStore

    User->>TeamsAPI: create_team / update_team / delete_team
    TeamsAPI->>Organization: get_current_organization(info)
    TeamsAPI->>PermissionService: require_can(user, Team.perms.*, org)
    PermissionService->>GrantStore: can(user, permission, org)
    alt Grant allows permission
        PermissionService-->>TeamsAPI: authorized
        TeamsAPI->>TeamStore: team_create / team_get / team_update / team_delete
        TeamStore-->>User: mutation result
    else No scoped Grant
        PermissionService-->>User: PermissionDenied
    end
Loading

File-Level Changes

Change Details Files
Cuts team mutation authorization over from legacy organization groups to scoped grant evaluation.
  • Replaces @hasOrgPerm with explicit require_can checks using the active organization.
  • Keeps team reads membership-gated while enforcing exact ADD, CHANGE, and DELETE permissions for writes.
  • Preserves the existing denial payload and organization-header selection behavior.
apps/betterangels-backend/teams/schema.py
apps/betterangels-backend/schema.graphql
Adds role-backed organization-admin authorization for the teams permission set.
  • Defines scoped ORG_ADMIN and ORG_SUPERUSER roles containing only teams.* permissions.
  • Registers those roles during role synchronization and backfills idempotent Grants from existing legacy group memberships.
  • Adds Team organization scoping metadata required for scoped-role validation.
apps/betterangels-backend/accounts/groups.py
apps/betterangels-backend/accounts/services.py
apps/betterangels-backend/accounts/apps.py
apps/betterangels-backend/teams/models.py
Updates permission-domain reporting to reflect teams as grant-only while retaining legacy groups for other domains.
  • Marks teams as legacy-inert so legacy rows are excluded and enforceable global grant authority folds per organization.
  • Updates reachability/equivalence expectations to combine grant-backed teams with legacy-only member-management and reports permissions.
apps/betterangels-backend/common/permissions/domain.py
apps/betterangels-backend/accounts/tests/test_current_user_reachability.py
Adds comprehensive regression coverage for the teams authorization cutover and adjusts performance fixtures.
  • Verifies role-backed and standalone scoped grants, superuser access, organization isolation, exact permission checks, and denial of legacy-only or unauthorized users.
  • Updates test setup to provision synchronized roles and increases mutation query-count expectations for grant scope resolution.
apps/betterangels-backend/teams/tests/test_grant_authorization.py
apps/betterangels-backend/teams/tests/test_mutations.py
apps/betterangels-backend/teams/tests/utils.py
Documents the staged teams-first rollout and its deliberate limitations.
  • Records why member-management and reports remain legacy-only due to phantom ContentType validation.
  • Documents retained legacy groups, teams-only role permissions, and the unchanged membership-gated read query.
apps/betterangels-backend/docs/adr/0001-grant-based-authorization.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="apps/betterangels-backend/accounts/groups.py" line_range="52-64" />
<code_context>
+    Team.perms.VIEW,
+]
+
+ORG_ADMIN_ROLE = RoleDef(
+    name=ORG_ADMIN.name,
+    permissions=list(ORG_ADMIN_ROLE_PERMISSIONS),
+    is_invitable=ORG_ADMIN.is_invitable,
+)
+
+ORG_SUPERUSER_ROLE = RoleDef(
+    name=ORG_SUPERUSER.name,
+    permissions=list(ORG_ADMIN_ROLE_PERMISSIONS),
+    is_invitable=ORG_SUPERUSER.is_invitable,
+)
+
+ORG_ADMIN_ROLES: tuple[RoleDef, ...] = (ORG_ADMIN_ROLE, ORG_SUPERUSER_ROLE)
</code_context>
<issue_to_address>
**issue (broader_impact):** Replacing or clearing an organization's legacy permission-group roles deletes every scoped Grant for that user in the organization, including independently assigned grant-only roles such as a Team Admin Grant. After this cutover, assigning or removing an unrelated role can silently revoke the user's team mutation authority.

**Triggers:** When a user has both an ORG_ADMIN/legacy role and an independently assigned scoped Team role, and member-role management calls `clear_roles` or `replace_roles`.

**Suggested fix:** Delete only Grants whose roles correspond to the permission groups being removed, rather than deleting all Grants at the organization scope; preserve independently assigned grants.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +52 to +64
ORG_ADMIN_ROLE = RoleDef(
name=ORG_ADMIN.name,
permissions=list(ORG_ADMIN_ROLE_PERMISSIONS),
is_invitable=ORG_ADMIN.is_invitable,
)

ORG_SUPERUSER_ROLE = RoleDef(
name=ORG_SUPERUSER.name,
permissions=list(ORG_ADMIN_ROLE_PERMISSIONS),
is_invitable=ORG_SUPERUSER.is_invitable,
)

ORG_ADMIN_ROLES: tuple[RoleDef, ...] = (ORG_ADMIN_ROLE, ORG_SUPERUSER_ROLE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): Replacing or clearing an organization's legacy permission-group roles deletes every scoped Grant for that user in the organization, including independently assigned grant-only roles such as a Team Admin Grant. After this cutover, assigning or removing an unrelated role can silently revoke the user's team mutation authority.

Triggers: When a user has both an ORG_ADMIN/legacy role and an independently assigned scoped Team role, and member-role management calls clear_roles or replace_roles.

Suggested fix: Delete only Grants whose roles correspond to the permission groups being removed, rather than deleting all Grants at the organization scope; preserve independently assigned grants.

@vecchp vecchp changed the title feat(perm): teams mutations authorize via grants — ORG_ADMIN role-backed (teams cutover) feat(perm): teams is fully grant-based — ORG_ADMIN + CASEWORKER role-backed (read & write cutover) Sep 9, 2026
@vecchp vecchp changed the title feat(perm): teams is fully grant-based — ORG_ADMIN + CASEWORKER role-backed (read & write cutover) feat(perm): teams fully grant-based — ORG_ADMIN + CASEWORKER role-backed, org-filter read, FE-gated actions Sep 9, 2026
@vecchp vecchp changed the title feat(perm): teams fully grant-based — ORG_ADMIN + CASEWORKER role-backed, org-filter read, FE-gated actions feat(perm): teams fully grant-based & header-free — ORG_ADMIN + CASEWORKER role-backed, org in filter/payload Sep 9, 2026
@vecchp vecchp changed the title feat(perm): teams fully grant-based & header-free — ORG_ADMIN + CASEWORKER role-backed, org in filter/payload feat(perm): teams fully grant-based & header-free — ORG_ADMIN + CASEWORKER role-backed, org in filter/payload (DEV-2559) Sep 9, 2026
@vecchp vecchp added the graphql-inspector:approved-breaking-change Auto approve breaking changes to graphql schema label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

🚀 Expo continuous deployment is ready for betterangels!

  • Project → betterangels
  • Environment → Preview
  • Platforms → android, ios
  • Scheme → betterangels
  🤖 Android 🍎 iOS
Runtime Version 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
App version: 1.2.11
Git commit: a1900145e6c2315da5a85930acd4fcf2f8619aa2
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
App version: 1.2.11
Git commit: a1900145e6c2315da5a85930acd4fcf2f8619aa2
Update Details Update Permalink
DetailsBranch: feat-perm-teams-grant-only
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
Git commit: 9f3fe7afd2d8dc043428acbf710fdf0d84455a74
Update Permalink
DetailsBranch: feat-perm-teams-grant-only
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
Git commit: 9f3fe7afd2d8dc043428acbf710fdf0d84455a74
Update QR

iOS Simulator Build: Simulator Build Link

…e backfills

Review findings on the org admin change page:

- GrantInline/DelegatedGrantInline rendered ~540 queries for a ~90-member
  org (~6 per grant row).  autocomplete_fields was the worst offender (a
  per-row related lookup for every existing FK).  Switch both inlines to
  raw_id_fields + select_related, and make object-grant columns read-only
  (they are edited on the Grant admin, not the org-scope inline): ~3/grant.

- The remaining 3/grant were the stock ForeignKeyRawIdWidget re-querying
  self.rel.model._default_manager.get(pk=...) per widget per row — invisible
  to select_related because the widget never looks at the row instance.
  Add LoadedRowRawIdWidget (renders from the loaded row via the inline
  form's instance binding) and GrantRowForm, which hands each raw-id FK
  widget its select_related'd row: ~1/grant.

- The last 1/grant came from the row template stringifying the instance:
  Grant.__str__ walks principal_user, principal_org, role and scope_org,
  and GrantInline did not select_related scope_org.  Load all four FKs on
  both grant inline querysets: 0/grant.

- OrganizationMemberInlineQueryCountTestCase now asserts a strict zero
  delta: adding six members (six more grant rows) adds no queries at all.

- Add OrgAdminAndCaseworkerBackfillTestCase covering backfill_org_admin_
  grants and backfill_caseworker_grants (create / idempotent / convert-only),
  which previously had zero coverage.
…r clients

More of the adversarial review on the teams grant-only cutover:

- The Django User-admin group picker bypassed OrgRoleManager: a superuser
  adding a user straight to an org's role-backed PermissionGroup produced a
  legacy-only holder with no Grant, who then could not manage teams.
  UserAdmin.save_related now mirrors the same transitions OrgRoleManager
  performs — module-level mirror_membership_grant/unmirror_membership_grant
  (scoped by the group's own org, with OrgRoleManager passing its known org
  so the membership path keeps its query count) — keeping group and Grant in
  step whether the role came from the org member page or the user page.
  Tests: adding mirrors a Grant, removing unmirrors it, label-only groups
  conjure none, and an unchanged save changes nothing.

- The teams read's header deprecation is now on record: the ADR and
  schema docstrings list the header-only clients that must migrate before
  X-Organization-ID is stripped (betterangels-admin TeamsPage already sends
  filters.organizationId; mobile useOrgTeams — NoteForm, TaskForm,
  FilterTeamsOptions, UserTeamPreferenceSelect — sends only { isActive }),
  and note that a role-less member's team read is intentionally grant-gated.
  The resolver docstring also records that an omitted org id arrives as ""
  and must keep the header fallback until those clients migrate.

- createTeam with an unknown organizationId is now pinned to fail closed
  (the shared _org_or_deny path was only covered on the read side), and a
  non-numeric organizationId filter is pinned to deny rather than leak a
  ValueError.
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.
vecchp pushed a commit that referenced this pull request Sep 10, 2026
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.
@mikefeldberg

Copy link
Copy Markdown
Contributor

Previous review items — status

Item Status
Admin N+1 / weakened test (top finding) Fixed. raw_id_fields + LoadedRowRawIdWidget/GrantRowForm + select_related; test back to delta == 0.
Backfill tests for org-admin/caseworker Fixed. OrgAdminAndCaseworkerBackfillTestCase + bonus RoleDefTemplateConsistencyTestCase.
Direct group-add bypasses mirroring Fixed, via the m2m-edge signal (different from my recommendation — see F2).
Mobile header-deprecation checklist Addressed as documentation (ADR §5.3 now lists every header-only consumer). Hook itself not migrated; no ticket ref.
ORG_SUPERUSER_ROLE derivation, blank/UNSET org handling, unknown-org tests, message consolidation, _all_role_defs Done (message constant not reused in extensions.py — see F6).
Teams authz registry canary (my Phase 1) Not addressed — no teams/tests/test_authz_registry.py; see F8.

New findings

F1 (medium) — reconcile-driven group deletion no longer revokes the mirrored authority

Verified empirically with a throwaway probe (since deleted): an org with types ["outreach","shelter"] changes to ["shelter"]; reconcile_org_groups deletes the stale Caseworker PermissionGroup; the member's membership is gone but the mirrored Caseworker Grant survives (PROBE grants after reconcile dropped the Caseworker group: ['Caseworker']). The member keeps teams.view_team (can() is grant-driven) while their legacy-domain perms (notes/tasks/reports/member management) are revoked with the group — a partial, inconsistent revocation the next post-migrate applies silently.

This is the direct cost of the deliberate "group deletion must not revoke Grants" rule (needed so teardown can retire legacy rows). The signal can't distinguish teardown from config cleanup, but reconcile_org_groups can: it knows these are stale derived rows. Options: (a) unmirror explicitly in reconcile_org_groups before the stale delete (best — restores complete revocation for config changes while keeping teardown semantics), (b) accept and document the asymmetry in the ADR, (c) revisit the teardown asymmetry. Add a test pinning whichever you pick; today the only pinned behavior is test_deleting_the_group_leaves_the_grant_for_teardown, which is teardown-shaped.

F2 (low-med, architecture) — signal-hosted invariant: document the exception precisely

  • python.md says "Never put business logic in models, signals, or GraphQL resolvers." The mirror is now enforced by mirror_group_membership_grants (a signal), with logic centralized in role_manager. It's a conscious, ADR-documented trade-off — but the next contributor may "fix" it back into services and silently lose the admin/script writers. Worth an explicit line in the styleguide (or a strong comment on the receiver) naming it as the sanctioned exception and why.
  • The docstring claim "every writer keeps the invariant — the manager, the user admin, scripts, the shell" overclaims: through.objects.bulk_create, raw SQL, loaddata, and queryset.update() bypass m2m signals. Soften to "every writer that goes through the m2m manager" so an authorization invariant isn't stated more broadly than it holds.
  • Test-maintenance tax is real: modelling the pre-cutover "legacy-only" state now requires Grant.objects...delete() after the add (in test_roles.py, test_grant_authorization.py, and implicitly in test_admin.py). The helper exists in test_roles.py but is duplicated inline elsewhere — move it to accounts/tests/helpers.py so the transition concept has one home.

F3 (low) — same-role direct Grant is destroyed with the membership

unmirror_membership_grants deletes by (user, role, org). A direct Grant of the same role at the same org is the same row (unique constraint), so removing the role-backed membership also revokes the independently granted role. It's documented in the docstring, but there's no test pinning it and no warning on the admin surfaces that share the row. Cheap to add a test; consider a help-text note in GrantAdmin.

F4 (low) — triple-literal permission message

PERMISSION_DENIED_MESSAGE is exported and used by require_can/teams, but accounts/extensions.py (L63, L97) still hard-codes the same string. Import the constant there too, or the "one string" claim isn't true.

F5 (nit) — set(scoped_roles_for_groups(objs))

In PermissionGroupAdmin.get_deleted_objects, set(dict) silently means "keys of the mapping". scoped_roles_for_groups(objs).keys() (or a set-returning helper for membership tests) reads better.

F6 (nit) — stale doc reference

reconcile_org_groups' docstring still credits accounts.signals.delete_orphaned_group for tearing down the auth.Group; that receiver no longer exists (teardown is structural via the MTI inheritance, per migration 0007). The diff touches this function area; worth correcting.

F7 (nit) — LoadedRowRawIdWidget drops using

GrantRowForm reconstructs the widget without the original's using kwarg. Multi-DB isn't in play here, so this is fine today; a short comment or preserving using would make it robust.

F8 — teams authz registry canary (repeat)

Still absent. shelters/tests/test_authz_registry.py pins every Query/Mutation field to its authorization route precisely because the cutover removed @hasOrgPerm and made protection behavioral. Teams did the same and is now in the same state, with no equivalent canary; adding one (4 fields: teams, create_team, update_team, delete_team) costs ~20 lines and matches the established pattern. If you're deliberately skipping it, record why in the ADR; otherwise it's the last outstanding item from my previous review.

@vecchp
vecchp added this pull request to the merge queue Sep 10, 2026
@vecchp
vecchp removed this pull request from the merge queue due to a manual request Sep 10, 2026
@vecchp

vecchp commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Implemented — all in #2456 (stacked on #2455; merges right after the chain, #2443 untouched). Per finding:

  • F1 — took option (a): reconcile_org_groups unmirrors each stale derived row's members before the delete, so config cleanup now fully revokes; teardown deletes still leave Grants standing. Pinned by test_reconcile_of_a_stale_group_unmirrors_the_grant (org types outreach+shelter → shelter; group gone, mirror gone).
  • F2 — receiver docstring now says "every writer that goes through the m2m manager" and names the bypasses (bulk_create/raw SQL/loaddata/queryset.update), with ADR 0001 §4 phase 2 as the sanctioned exception. The helper duplication (iii) was already consolidated in test(perm): shared helpers for legacy-only holders & grantless members #2451common/tests/utils.py (make_permission_group, add_legacy_membership, make_legacy_only_holder, revoke_grants).
  • F3 — test added: test_removing_a_membership_revokes_a_same_row_direct_grant. GrantAdmin now carries a fieldset description saying the unique constraint makes a direct grant and the membership mirror the same row, so removing the membership removes it.
  • F4 — moot: accounts/extensions.py was deleted by 590dc503 in this cycle; grep confirms no non-test hardcoded literal remains.
  • F5.keys().
  • F6 — both services.py refs fixed, plus the same stale reference in admin.py's change_view docstring found while doing this; all now describe the structural MTI cascade (migration 0007).
  • F7using=getattr(widget, \"using\", None) preserved.
  • F8teams/tests/test_authz_registry.py added, same shape as shelters (registry dict + exact set-equality over __strawberry_definition__.fields; 4 fields).
  • Item 4 (mobile header hook) — resolved upstream: feat(perm): retire the X-Organization-ID header — mobile teams cutover (DEV-2566) #2450/chore(fe): stop sending the retired X-Organization-ID header #2452 retired the X-Organization-ID header entirely, so there is no mobile consumer left to migrate.

One observation from doing F3 (no behavior change made — flagging for direction): the admin inline's row-deletion route now only strips the legacy membership — a mirrored Grant survives the row delete by design (the teardown asymmetry the delete-view note already calls out), while the org-type route fully revokes via F1. The change_view confirmation counts members on both routes, so on the row-delete route its "revoked from N members" can overstate authority loss for grant-holding members. Want the row-delete route treated as config cleanup too (unmirror there as well), or is holding the asymmetry right?

Validation: full backend suite 1890 passed / 31 skipped on the branch; ruff clean.

@vecchp
vecchp added this pull request to the merge queue Sep 10, 2026
vecchp pushed a commit that referenced this pull request Sep 10, 2026
…y, nits

F1  reconcile_org_groups unmirrors stale derived rows before deleting them,
    so an org-type change fully revokes (mirrored Grants no longer survive
    config cleanup); teardown deletes keep leaving Grants standing.
F2  signal docstring states the sanctioned exception precisely (m2m-manager
    writers only); helper duplication already consolidated in #2451.
F3  same-row direct-grant revocation pinned by test + GrantAdmin fieldset
    description (unique constraint makes both grants one row).
F4  moot — accounts/extensions.py deleted in 590dc50.
F5  set(dict) -> .keys().
F6  stale delete_orphaned_group refs corrected (services.py, admin.py);
    tear-down described as the structural MTI cascade (migration 0007).
F7  LoadedRowRawIdWidget preserves the original widget's using kwarg.
F8  teams/tests/test_authz_registry.py canary added (4 fields).
Merged via the queue into main with commit e36cea3 Sep 10, 2026
7 checks passed
@vecchp
vecchp deleted the feat/perm/teams-grant-only branch September 10, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

graphql-inspector:approved-breaking-change Auto approve breaking changes to graphql schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants