Skip to content

feat(perm): member management grant-only cutover (organizations.* on org root) (DEV-2558) - #2445

Open
vecchp wants to merge 37 commits into
mainfrom
feat/perm/members-grant-only
Open

vecchp wants to merge 37 commits into
mainfrom
feat/perm/members-grant-only

Conversation

@vecchp

@vecchp vecchp commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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). Legacy PermissionGroup rows 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_permissions resolves the org-root model — the Organization ContentType — and binds the five codenames (access_org_portal, add_org_member, remove_org_member, view_org_member, change_org_member_role) to it at post_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.pyseed_org_portal_permissions() binds org-root codenames to the real Organization ContentType; retire_superseded_phantom_permissions() re-points Permission-M2M references before deleting superseded phantom rows.
  • accounts/groups.pyORG_ADMIN_ROLE_PERMISSIONS / ORG_SUPERUSER_ROLE_PERMISSIONS bundle the member-management codenames (superuser adds change_org_member_role). Backfilled org-admin Grants inherit from the Role row on the next sync_roles.
  • accounts/schema.pyorganizationMember/organizationMembers and the add/remove/change-role mutations drop HasOrgPerm/get_user_permitted_org/get_current_organization; they authorize via require_can(...) at the org in the payload (organizationId) through a shared _org_or_deny helper.
  • Header-free — member management is a web feature, so no X-Organization-ID fallback: payload org only. (Mutations already carried organizationId.)
  • common/permissions/domain.pyorganizations joins LEGACY_INERT_APPS; DUAL_APPS stays empty (every org-admin domain has now cut over).
  • Schema/codegenschema.graphql regenerated (member field directives dropped, @hasOrgPerm directive definition removed); expo schema.ts regenerated via real codegen.
  • ADR — §5.3 status block: member management joined; org-admin legacy rows now inert, teardown remains.
  • Tests — new 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.py legacy-domain class flipped to a whole-bundle can()-equivalence tripwire; test_permissions.py/test_mutations.py/test_queries.py member cases updated to grant semantics.

Behavior notes / review focus

  • Deny is the flip to eyeball: a legacy-only ORG_ADMIN (PermissionGroup membership, no Grant) is now DENIED for member management — same contract teams/reports already shipped.
  • Reachability: currentUser org entries now fold the ORG_ADMIN bundle's organizations.* perms from the grant arm only (the legacy arm no longer contributes for member management).
  • Migration safety: the org-root bind runs at post_migrate after sync_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.
  • Admin FE may need its member-management UI permission checks to align with the flipped backend contract if any were group-based; the reachability equivalence test pins the currentUser contract.

Test plan

  • Full backend suite green (pytest across all apps).
  • Targeted member-management grant-auth tests green.
  • ruff check/format clean on changed files.
  • schema.graphql + expo schema.ts regenerated 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:

  • Authorize organization member reads and management mutations exclusively through organization-scoped grants, including role-backed, direct, and global-tier grants.
  • Expose membership IDs and use them to identify the organization membership targeted by removal and role-change operations.

Bug Fixes:

  • Fail closed for unknown organizations or membership IDs instead of raising lookup or validation errors.
  • Prevent legacy-only organization admin memberships from retaining member-management access after the authorization cutover.

Enhancements:

  • Bind organization member-management permissions to the Organization model and retire superseded phantom permissions without losing existing references.
  • Complete the org-admin authorization transition by making organizations permissions grant-backed and removing legacy permission-group enforcement.
  • Remove header-based organization authorization from member-management operations and authorize against the payload or membership organization.
  • Align current-user permission reachability with grant-based enforcement across the complete org-admin permission bundle.

Documentation:

  • Update the grant-based authorization ADR to document the completed member-management cutover and org-root permission binding.

Tests:

  • Add comprehensive grant-authorization coverage for role-backed, direct, global, cross-organization, legacy-only, and header-independence scenarios.
  • Update permission, query, mutation, permission-seeding, and current-user reachability tests for grant-only behavior.

Chores:

  • Regenerate GraphQL schemas and client types for the updated member-management API.

@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.

Sorry @vecchp, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Completes 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 authorization

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Moves member-management authorization from legacy organization groups to organization-scoped grants.
  • Adds member-management permissions to the ORG_ADMIN and ORG_SUPERUSER role bundles, with role change restricted to ORG_SUPERUSER.
  • Authorizes member queries and mutations with require_can at the payload organization, eliminating header-based and legacy permission checks.
  • Adds coverage for role-backed, direct-grant, global-tier, cross-organization, legacy-only denial, and header-inert behavior.
apps/betterangels-backend/accounts/groups.py
apps/betterangels-backend/accounts/schema.py
apps/betterangels-backend/accounts/tests/test_member_management_grant_authorization.py
apps/betterangels-backend/accounts/tests/test_permissions.py
apps/betterangels-backend/accounts/tests/test_mutations.py
apps/betterangels-backend/accounts/tests/test_queries.py
Binds organizations.* permissions to the Organization root model so they can participate in scoped Roles and Grants.
  • Seeds the five portal permissions on the Organization ContentType before role synchronization.
  • Retires superseded phantom permissions idempotently while re-pointing existing permission references.
  • Treats Organization-root permissions as identity-scoped for permissions.E005 validation and adds migration/check coverage.
apps/betterangels-backend/accounts/apps.py
apps/betterangels-backend/accounts/seed.py
apps/betterangels-backend/common/permissions/checks.py
apps/betterangels-backend/accounts/tests/test_template_permissions.py
apps/betterangels-backend/common/tests/test_permission_checks.py
Completes the org-admin grant-only domain transition and aligns effective-permission reporting with enforcement.
  • Adds organizations to LEGACY_INERT_APPS and leaves DUAL_APPS empty.
  • Updates organization effective-permission folding so currentUser entries match can() for the full ORG_ADMIN bundle, including global-tier authority.
  • Flips reachability tests from legacy predicate equivalence to grant/can equivalence.
apps/betterangels-backend/common/permissions/domain.py
apps/betterangels-backend/accounts/selectors.py
apps/betterangels-backend/accounts/tests/test_current_user_reachability.py
Updates the public authorization contract and project documentation for the cutover.
  • Removes HasOrgPerm and hasOrgPerm directives from member-management schema fields and mutations.
  • Regenerates GraphQL schemas and updates ADR 0001 with the org-root binding, header-free behavior, and inert legacy rows.
  • Adjusts expected GraphQL denial payloads and query-count assertions.
apps/betterangels-backend/schema.graphql
libs/expo/shared/clients/src/lib/apollo/graphql/__generated__/schema.ts
docs/adr/0001-grant-based-authorization.md
apps/betterangels-backend/accounts/tests/test_permissions.py
apps/betterangels-backend/accounts/tests/test_mutations.py
apps/betterangels-backend/accounts/tests/test_queries.py

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

@vecchp vecchp added the graphql-inspector:approved-breaking-change Auto approve breaking changes to graphql schema label Sep 9, 2026
@vecchp vecchp changed the title feat(perm): member management grant-only cutover (organizations.* on org root) feat(perm): member management grant-only cutover (organizations.* on org root) (DEV-2558) Sep 9, 2026
vecchp pushed a commit that referenced this pull request Sep 9, 2026
…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.
@vecchp
vecchp force-pushed the feat/perm/members-grant-only branch from 613ecf8 to 2bf3ffc Compare September 9, 2026 22:42
vecchp pushed a commit that referenced this pull request Sep 9, 2026
…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.
@vecchp
vecchp force-pushed the feat/perm/reports-grant-only branch from b940d30 to 7aa68e5 Compare September 9, 2026 22:46
@vecchp
vecchp force-pushed the feat/perm/members-grant-only branch from 2bf3ffc to c6db0a8 Compare September 9, 2026 22:47
vecchp pushed a commit that referenced this pull request Sep 9, 2026
…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.
…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.
@vecchp
vecchp force-pushed the feat/perm/reports-grant-only branch from 7aa68e5 to 38fad3c Compare September 10, 2026 15:32
…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.
@vecchp
vecchp force-pushed the feat/perm/members-grant-only branch from c6db0a8 to d0642d1 Compare September 10, 2026 15:38
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.
@github-actions

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-members-grant-only
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
Git commit: 01fb814ef3b020ada8d9e7d6d7c0efe1b014086d
Update Permalink
DetailsBranch: feat-perm-members-grant-only
Runtime version: 4d84f3f87051ebb9c1713e7e2d4522e4b23135d9
Git commit: 01fb814ef3b020ada8d9e7d6d7c0efe1b014086d
Update QR

iOS Simulator Build: Simulator Build Link

@github-actions

Copy link
Copy Markdown

🔍 [shelter-web] Preview available at: https://shelter.dev.betterangels.la/branches/feat-perm-members-grant-only

Last updated: 2026-09-10T15:57:28.500Z

@github-actions

Copy link
Copy Markdown

🔍 [betterangels-admin] Preview available at: https://admin.dev.betterangels.la/branches/feat-perm-members-grant-only

Last updated: 2026-09-10T15:57:28.495Z

@vecchp
vecchp force-pushed the feat/perm/reports-grant-only branch 2 times, most recently from f9bbab9 to 332d76f Compare September 10, 2026 21:39
Base automatically changed from feat/perm/reports-grant-only to main September 11, 2026 01:31
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.

2 participants