Skip to content

feat(perm): write tiers — decouple write scope from read scope (RFC 0002) - #2447

Open
vecchp wants to merge 1 commit into
feat/perm/orgadmin-teardownfrom
feat/perm/write-tiers
Open

vecchp wants to merge 1 commit into
feat/perm/orgadmin-teardownfrom
feat/perm/write-tiers

Conversation

@vecchp

@vecchp vecchp commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the RFC 0002 §Precondition on the grant predicate core: can_obj now consults a model's write tier independently of org_via (its read scope). Read scope and write scope no longer fall out of one declaration. This is the shared prerequisite the object-grant arm and every outreach cutover (clients §5.1, notes §5, tasks/referrals via RFC 0003) inherit — and it is behavior-preserving today, because can_obj has no production caller yet (verified: only its def + tests).

Stacks on the org-admin stack: #2443 teams → #2444 reports → #2445 members → #2446 org-admin teardown → this PR.

What changes

  • OrgScoped.write_tier (common/models.py) + WRITE_SHARED / WRITE_OBJECT constants. None derives the safe default: ORG for any org-anchored model (org_via not None), fail-closed for platform-shared.
  • can_obj is tier-aware (common/permissions/selectors.py):
    • ORG (derived) → the row filter, unchanged from the pre-tier contract.
    • WRITE_SHARED (declared) → can_anywhere — any holder of the permission anywhere may act.
    • undeclared platform-shared → fails closed (finding C1 / RFC 0002 §Precondition): only the global tier (scopes is ALL) acts; the read rule never feeds an undeclared write.
    • Replaces the old "do not route platform-shared writes through this until the C1 fix lands" WARNING with the implemented contract.
  • ClientProfile declares WRITE_SHARED (clients/models.py, RFC 0002 decision Setup Basic VSCode .devcontainer and include terraform and terragrunt #1) — its CHANGE/DELETE on main come from model-level perms on the CASEWORKER group, so the cutover preserves them as "holds the permission anywhere" rather than C1 fail-closed. It is the only org_via = None model in the codebase.
  • permissions.E007 — only a platform-shared model may declare a tier (org-anchored models derive ORG from their anchor), and WRITE_OBJECT stays reserved until the object arm turns on with the clients cutover (declaring it today would route writes to a predicate nothing satisfies).
  • ADR 0001 §2.5 — status note recording the write-tier precondition.

Behavior notes / review focus

  • No authority change on any current surface: no production code calls can_obj; visible() (reads) is untouched and still org_via-driven; ClientProfile's declared SHARED tier matches what the old fail-open can_obj would have returned anyway. This PR is the predicate groundwork, not a cutover.
  • The fail-closed default is the safety property: any future platform-shared model without a declared tier is unwritable by finite org-scoped holders until it declares one (or an object grant covers the row, once the arm ships).
  • OBJECT tier / object-grant predicate / whitelist are deliberately NOT in this PR — per ADR §2.5 the arm turns on with its first consumer (clients), and E007 refuses WRITE_OBJECT until then.

Test plan

  • Full backend suite green (~1760).
  • manage.py check clean (E007 quiet with ClientProfile).
  • New unit tests: ORG tier unchanged; SHARED == can_anywhere (holder anywhere yes, stranger no, superuser yes); fail-closed default (finite holder denied, global tier allowed); E007 quiet + both error paths.
  • ruff clean on all touched files.

Summary by Sourcery

Decouple object write authorization from model read scope while preserving current behavior and establishing safe defaults for future platform-shared write surfaces.

New Features:

  • Introduce independent write-tier declarations for models, including shared write access for platform-shared client profiles.

Bug Fixes:

  • Make object-level authorization fail closed for platform-shared models without an explicit write tier, preventing read scope from granting unintended write access.

Enhancements:

  • Update object permission checks to support organization-scoped, shared, and safe default write behavior independently of read scoping.
  • Add validation preventing invalid write-tier declarations and document the write-tier authorization precondition.

Documentation:

  • Record the write-tier authorization model and its reserved object-tier status in the authorization ADR.

Tests:

  • Add coverage for organization-scoped behavior, shared-tier authorization, fail-closed defaults, and invalid tier declarations.

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR establishes RFC 0002’s write-tier foundation: can_obj now chooses ORG, explicitly SHARED, or fail-closed platform-shared authorization independently of read scope, with ClientProfile opted into SHARED, E007 enforcing declaration rules, and tests and ADR documentation covering the staged contract. The object-grant/OBJECT arm is intentionally not enabled yet.

File-Level Changes

Change Details Files
Introduces model-level write tiers that decouple object-write authorization from read scoping.
  • Adds SHARED and reserved OBJECT tier constants plus an optional OrgScoped declaration.
  • Derives ORG behavior for org-anchored models and fail-closed behavior for undeclared platform-shared models.
  • Declares ClientProfile as SHARED to preserve its current permission semantics.
apps/betterangels-backend/common/models.py
apps/betterangels-backend/clients/models.py
Makes can_obj select authorization behavior from the model’s write tier.
  • Retains the existing org row-filter check for org-anchored models.
  • Uses can_anywhere for explicitly shared platform models.
  • Allows only the global tier for undeclared platform-shared models and rejects non-OrgScoped objects.
apps/betterangels-backend/common/permissions/selectors.py
Adds E007 validation to prevent unsafe or premature write-tier declarations.
  • Rejects tier declarations on org-anchored models.
  • Rejects WRITE_OBJECT until the object-grant arm is enabled.
  • Adds coverage for valid ClientProfile and both invalid declaration paths.
apps/betterangels-backend/common/permissions/checks.py
apps/betterangels-backend/common/tests/test_permission_checks.py
Adds selector tests covering unchanged org behavior and new shared/fail-closed semantics.
  • Verifies shared authorization for permission holders anywhere, strangers, and superusers.
  • Verifies undeclared platform-shared models deny finite org-scoped holders while allowing global-tier holders.
apps/betterangels-backend/common/tests/test_permissions_selectors.py
Records the write-tier precondition and rollout boundary in the authorization ADR.
  • Documents the independent write-scope contract and fail-closed default.
  • Confirms OBJECT tier and object-grant behavior remain disabled until the clients cutover.
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/common/permissions/checks.py" line_range="267-280" />
<code_context>
+    for model in apps.get_models():
+        if not issubclass(model, OrgScoped):
+            continue
+        tier = model.__dict__.get("write_tier")
+        if tier is None:
+            continue
+        if model.org_via is not None:
+            errors.append(
+                Error(
+                    f"{model.__name__}.write_tier = {tier!r} on an org-anchored model.",
+                    hint="Org-anchored models derive the ORG write tier from org_via; "
+                    "only a platform-shared model (org_via = None) declares a tier.",
+                    obj=model,
+                    id="permissions.E007",
+                )
+            )
+        elif tier == WRITE_OBJECT:
+            errors.append(
+                Error(
</code_context>
<issue_to_address>
**issue (bug_risk):** E007 accepts arbitrary non-`None` tier strings on platform-shared models. `can_obj` recognizes only `WRITE_SHARED`, so a typo or unsupported value passes `manage.py check` and silently fails closed for every finite org-scoped holder.

**Triggers:** When a platform-shared model declares a tier value other than `WRITE_SHARED` or the reserved `WRITE_OBJECT`.

**Suggested fix:** Reject tier values outside the defined constants with `permissions.E007` before applying the platform-shared and reserved-tier checks.
</issue_to_address>

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

Comment on lines +267 to +280
tier = model.__dict__.get("write_tier")
if tier is None:
continue
if model.org_via is not None:
errors.append(
Error(
f"{model.__name__}.write_tier = {tier!r} on an org-anchored model.",
hint="Org-anchored models derive the ORG write tier from org_via; "
"only a platform-shared model (org_via = None) declares a tier.",
obj=model,
id="permissions.E007",
)
)
elif tier == WRITE_OBJECT:

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 (bug_risk): E007 accepts arbitrary non-None tier strings on platform-shared models. can_obj recognizes only WRITE_SHARED, so a typo or unsupported value passes manage.py check and silently fails closed for every finite org-scoped holder.

Triggers: When a platform-shared model declares a tier value other than WRITE_SHARED or the reserved WRITE_OBJECT.

Suggested fix: Reject tier values outside the defined constants with permissions.E007 before applying the platform-shared and reserved-tier checks.

…002)

Implements the RFC 0002 §Precondition: can_obj consults a model's write tier
independently of org_via (its read scope).  Behavior-preserving today —
can_obj has no production caller yet — and the shared prerequisite the object
arm and the clients/notes/tasks cutovers inherit.

- common/models.py: OrgScoped.write_tier declaration + WRITE_SHARED /
  WRITE_OBJECT constants.  None derives the safe default: ORG for org-anchored
  models, fail-closed for platform-shared.
- common/permissions/selectors.py: can_obj is tier-aware — ORG (unchanged row
  filter) for org-anchored; declared WRITE_SHARED -> can_anywhere; undeclared
  platform-shared fails closed (finding C1 / RFC 0002) with only the global
  tier acting.  Replaces the "do not route platform-shared writes through this"
  WARNING with the implemented contract.
- clients/models.py: ClientProfile declares WRITE_SHARED (RFC 0002 decision #1
  — client CHANGE/DELETE on main come from model perms on CASEWORKER, so the
  cutover preserves them as "holds the permission anywhere").
- common/permissions/checks.py: permissions.E007 — only platform-shared models
  may declare a tier (org-anchored derive ORG), and WRITE_OBJECT stays reserved
  until the object arm turns on with the clients cutover.
- docs/adr/0001 §2.5: write-tier status note.

Tests: can_obj tier unit tests (ORG unchanged, SHARED == can_anywhere,
fail-closed default vs global tier) + E007 quiet/error tests.
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