Skip to content

chore(perm): perm-core hygiene — single-source role bundles, drop dead dual state - #2448

Open
vecchp wants to merge 2 commits into
feat/perm/write-tiersfrom
feat/perm/core-hygiene
Open

vecchp wants to merge 2 commits into
feat/perm/write-tiersfrom
feat/perm/core-hygiene

Conversation

@vecchp

@vecchp vecchp commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Architecture/hygiene pass over the grant-based permission core (ADR 0001). Zero behavior change — the role/domain contract is byte-identical; only the declaration and documentation are tightened. Stacks on the org-admin stack (#2443 teams → #2444 reports → #2445 members → #2446 teardown → #2447 write tiers).

What changes

  1. Single source of truth for the org-admin role bundles (accounts/groups.py)

    • ORG_ADMIN_ROLE / ORG_SUPERUSER_ROLE are now built via RoleDef.from_template(ORG_ADMIN / ORG_SUPERUSER) — the same pattern shelters already use — instead of hand-duplicated ORG_ADMIN_ROLE_PERMISSIONS / ORG_SUPERUSER_ROLE_PERMISSIONS lists.
    • Why it matters: the grant-side bundle and the legacy template must mirror exactly (a mirrored Grant must never amplify beyond the legacy bundle). Two hand-maintained lists were the strongest latent drift risk in the model — a future edit to one side silently widening or narrowing authority. from_template makes divergence impossible.
    • CASEWORKER_ROLE stays hand-defined deliberately (a strict RFC 0003 subset) — the comment now says why.
  2. Dead transitional state removed (common/permissions/domain.py)

    • DUAL_APPS (empty since the org-admin cutover completed) is deleted; GLOBAL_TIER_ORG_APPS is now frozenset(LEGACY_INERT_APPS) (same value, kept as its own name for the fold's readability).
    • Module docstring rewritten to the two states that actually exist (grant-only in LEGACY_INERT_APPS / legacy-only notes-clients) — removing stale references to the deleted HasOrgPerm/HasOrgPermOrGrant and long-closed PR numbers.
  3. Doc drift — the organization_effective_permissions fold docstring no longer references the retired "dual" transitional state.

  4. Drift guard test — pins ORG_ADMIN_ROLE.permissions == ORG_ADMIN.permissions (and superuser) so a future hand-edit of one side without the other fails CI.

Review focus

  • Confirm the org-admin RoleDef.from_template conversion is byte-identical to the old manual RoleDefs (name, permission list order, is_invitable=False) — the targeted + full suites assert this.
  • GLOBAL_TIER_ORG_APPS value is unchanged (it was LEGACY_INERT_APPS | frozenset()); only DUAL_APPS and stale docs are gone.

Test plan

  • Full backend suite green (~1765).
  • New no-drift guard test green; manage.py check clean; ruff clean.

Deferred (noted, not done — too much import churn across five stacked PRs): splitting common/permissions/utils.py (permission-catalog registry vs runtime predicates) into separate modules.

Summary by Sourcery

Consolidate organization role definitions and permission-domain metadata around the current grant-based model without changing authorization behavior.

Enhancements:

  • Build the organization admin and superuser grant roles directly from their template bundles to maintain a single source of truth.
  • Remove the obsolete dual permission state and simplify domain enforcement to the remaining grant-only and legacy-only states.
  • Update permission documentation to reflect the current grant-based authorization model and retired permission paths.

Documentation:

  • Refresh GraphQL error and permission documentation to remove retired org-permission extensions and describe current grant authorization flows.

Tests:

  • Add a guard test ensuring organization admin and superuser role bundles remain identical to their templates.

Chores:

  • Preserve the existing global-tier domain set while removing the unused transitional state and stale references.

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This hygiene-only change preserves permission behavior while making org-admin role definitions single-sourced from their templates, removing dead dual-state configuration, updating stale documentation, and adding a no-drift regression guard.

File-Level Changes

Change Details Files
Made org-admin grant role bundles derive directly from their legacy templates.
  • Replaced duplicated permission lists and manual RoleDef construction with RoleDef.from_template.
  • Preserved the deliberate hand-defined strict subset for CASEWORKER_ROLE.
  • Added regression coverage for permission ordering and invitable flags matching both templates.
apps/betterangels-backend/accounts/groups.py
apps/betterangels-backend/accounts/tests/test_template_permissions.py
Removed the completed dual-state permission model and clarified the active domain states.
  • Deleted the empty DUAL_APPS state.
  • Kept GLOBAL_TIER_ORG_APPS as a separately named copy of LEGACY_INERT_APPS with unchanged values.
  • Rewrote domain and effective-permission documentation to describe only grant-only and legacy-only enforcement.
apps/betterangels-backend/common/permissions/domain.py
apps/betterangels-backend/accounts/selectors.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

@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/tests/test_template_permissions.py" line_range="167-170" />
<code_context>
+    drift from, or amplify beyond, the legacy bundle.  This pins that property
+    so a future hand-edit of one side without the other fails here.
+    """
+    from accounts.groups import ORG_ADMIN, ORG_ADMIN_ROLE, ORG_SUPERUSER, ORG_SUPERUSER_ROLE
+
+    assert list(ORG_ADMIN_ROLE.permissions) == list(ORG_ADMIN.permissions)
+    assert list(ORG_SUPERUSER_ROLE.permissions) == list(ORG_SUPERUSER.permissions)
+    assert ORG_ADMIN_ROLE.is_invitable == ORG_ADMIN.is_invitable
+    assert ORG_SUPERUSER_ROLE.is_invitable == ORG_SUPERUSER.is_invitable
</code_context>
<issue_to_address>
**nitpick (testing):** The drift-guard test does not assert that `ORG_ADMIN_ROLE.name` and `ORG_SUPERUSER_ROLE.name` match their corresponding `TemplateConfig.name` values, so a future role-name divergence can pass this test while registering or synchronizing a grant role under a different name than its legacy template.

**Triggers:** When either role definition is later hand-edited independently of its template.

**Suggested fix:** Add assertions for both role names, and optionally `is_global`, so the guard covers the complete `RoleDef` contract claimed by the test.

```suggestion
    assert list(ORG_ADMIN_ROLE.permissions) == list(ORG_ADMIN.permissions)
    assert list(ORG_SUPERUSER_ROLE.permissions) == list(ORG_SUPERUSER.permissions)
    assert ORG_ADMIN_ROLE.name == ORG_ADMIN.name
    assert ORG_SUPERUSER_ROLE.name == ORG_SUPERUSER.name
    assert ORG_ADMIN_ROLE.is_invitable == ORG_ADMIN.is_invitable
    assert ORG_SUPERUSER_ROLE.is_invitable == ORG_SUPERUSER.is_invitable
```
</issue_to_address>

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

Comment on lines +167 to +170
assert list(ORG_ADMIN_ROLE.permissions) == list(ORG_ADMIN.permissions)
assert list(ORG_SUPERUSER_ROLE.permissions) == list(ORG_SUPERUSER.permissions)
assert ORG_ADMIN_ROLE.is_invitable == ORG_ADMIN.is_invitable
assert ORG_SUPERUSER_ROLE.is_invitable == ORG_SUPERUSER.is_invitable

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.

nitpick (testing): The drift-guard test does not assert that ORG_ADMIN_ROLE.name and ORG_SUPERUSER_ROLE.name match their corresponding TemplateConfig.name values, so a future role-name divergence can pass this test while registering or synchronizing a grant role under a different name than its legacy template.

Triggers: When either role definition is later hand-edited independently of its template.

Suggested fix: Add assertions for both role names, and optionally is_global, so the guard covers the complete RoleDef contract claimed by the test.

Suggested change
assert list(ORG_ADMIN_ROLE.permissions) == list(ORG_ADMIN.permissions)
assert list(ORG_SUPERUSER_ROLE.permissions) == list(ORG_SUPERUSER.permissions)
assert ORG_ADMIN_ROLE.is_invitable == ORG_ADMIN.is_invitable
assert ORG_SUPERUSER_ROLE.is_invitable == ORG_SUPERUSER.is_invitable
assert list(ORG_ADMIN_ROLE.permissions) == list(ORG_ADMIN.permissions)
assert list(ORG_SUPERUSER_ROLE.permissions) == list(ORG_SUPERUSER.permissions)
assert ORG_ADMIN_ROLE.name == ORG_ADMIN.name
assert ORG_SUPERUSER_ROLE.name == ORG_SUPERUSER.name
assert ORG_ADMIN_ROLE.is_invitable == ORG_ADMIN.is_invitable
assert ORG_SUPERUSER_ROLE.is_invitable == ORG_SUPERUSER.is_invitable

…d dual state

Architecture cleanup of the grant-based permission model (ADR 0001).  Zero
behavior change — the full role/domain contract is identical; only the way it
is declared and documented is tightened.

- accounts/groups.py: ORG_ADMIN_ROLE / ORG_SUPERUSER_ROLE are now built with
  RoleDef.from_template(ORG_ADMIN / ORG_SUPERUSER) — the grant-side bundle is
  the SAME source as the (inert) legacy template, so the two can never drift.
  Deletes the hand-duplicated ORG_ADMIN_ROLE_PERMISSIONS /
  ORG_SUPERUSER_ROLE_PERMISSIONS lists (the strongest latent drift risk: a
  mirrored Grant diverging from, or amplifying beyond, the legacy bundle).
  CASEWORKER_ROLE stays hand-defined (deliberate RFC 0003 subset) — noted.
- common/permissions/domain.py: drop the DUAL_APPS transitional state (empty
  since the org-admin cutover completed); GLOBAL_TIER_ORG_APPS is now
  frozenset(LEGACY_INERT_APPS).  Module docstring rewritten to the two real
  states (grant-only / legacy-only) with the stale HasOrgPerm/PR references
  removed.
- accounts/selectors.py: fold docstring no longer references the retired
  "dual" transitional state.
- accounts/tests/test_template_permissions.py: pins that the org-admin Role
  bundles equal their TemplateConfig bundles (no-drift guard).
@vecchp
vecchp force-pushed the feat/perm/write-tiers branch from 8ac9f0f to 3af672f Compare September 10, 2026 16:09
@vecchp
vecchp force-pushed the feat/perm/core-hygiene branch from 037aa50 to c31b215 Compare September 10, 2026 16:09
permissions.md still described the deleted machinery — the HasOrgPerm
extension, permissioned_queryset/_perm_q, get_user_permitted_org, and the
header-first org context — as current architecture; graphql_errors.md listed
HasOrgPerm in its extension and exception tables.  Rewrite both to the grant
model:

- org context: payload first; the X-Organization-ID header survives only as
  the teams list-read fallback (strip = DEV-2566);
- authority via Role/Grant + can()/scopes()/visible()/can_obj() and
  require_can() at the write boundary; the User.groups m2m edge mirrors
  dual-write memberships to Grants; legacy_inert roles are grant-only;
- key files and testing sections point at the selectors and the per-domain
  grant-authorization suites.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants