Skip to content

6009: add organizations to invitation querysets and sync - #6039

Draft
yasinelmi wants to merge 8 commits into
learningequality:unstablefrom
yasinelmi:invitation_api
Draft

6009: add organizations to invitation querysets and sync#6039
yasinelmi wants to merge 8 commits into
learningequality:unstablefrom
yasinelmi:invitation_api

Conversation

@yasinelmi

@yasinelmi yasinelmi commented Jul 12, 2026

Copy link
Copy Markdown

Summary

Following #6008 (which added Invitation.organization), this extends the invitation querysets and the /sync mechanism to actually support organization invitations end-to-end, per #6009.

  • Organization.filter_edit_queryset/filter_view_queryset — new classmethods based on active OrganizationRole membership (admin = edit, admin/editor/viewer = view), mirroring the existing Channel pattern. Used both by the sync permission gate below and by UserFilteredPrimaryKeyRelatedField on the serializer, so a user can only submit an org they actually have edit rights to.
  • Invitation.filter_edit_queryset/filter_view_queryset — extended to grant the same access to organization invitations.
  • InvitationSerializer — accepts organization in place of channel; channel is now optional. Validation requires exactly one of channel/organization (not neither, not both) — this is the "Organizations XOR channels" requirement from Add Organizations to Invitation Model #5971 that was deliberately deferred to this API-layer work (per rtibbles' comment on Add Organizations to Invitation API Route (And Sync Events) #6009 and the review thread on 5971: added organizations to invitation model #6008).
  • /sync's handle_changes() — organization-scoped changes (e.g. an org admin creating/revoking an invitation for someone else) don't fit the existing channel-scoped or self-scoped permission checks, since the actor isn't the target and there's no channel. Adds a permission check via Organization.filter_edit_queryset, then routes accepted changes through the existing per-user change queue (tagging the target user, not the actor) rather than adding a dedicated organization concept to the shared Change model.

Note on scope: an earlier version of this PR also added full organization_id broadcast parity with channel_id (a Change.organization field/migration, a dedicated task, organization_revs), so that other org admins would see invitation changes live rather than on next refresh. Per rtibbles' review, that's been reverted — channel_id/user_id exist on Change specifically to decide broadcast routing, and there's no established need for that same live cross-admin behavior for organizations; permission checks belong at the viewset layer, which this PR already handles without touching the shared Change schema.

Testing

  • Tests in test_models.py (OrganizationTestCase, InvitationOrganizationTestCase) covering the queryset permission logic directly.
  • Tests in test_invitation.py (OrganizationInvitationSyncTestCase) covering create/accept/revoke via /sync, non-admin rejection, the "requires at least one of channel/organization" validation, and the mutual-exclusivity rejection.
  • Full existing suite and pre-commit hooks pass locally (verified prior to the latest revert; re-verifying once local Postgres/Redis are back up).

References

Closes #6009. Builds on #6008 (Invitation.organization field) and #5953 (Organization/OrganizationRole models).

AI usage

Implemented with Claude Code: I worked through the codebase with it to trace how the existing channel-invitation sync mechanism works end-to-end (handle_changes, Change model, apply_channel_changes_task/apply_user_changes_task), identified that organization-scoped changes didn't fit either existing permission branch, and reviewed/directed the resulting implementation and test coverage rather than accepting it as-is. The channel/organization mutual-exclusivity requirement was added after re-reading the #6008 review thread and rtibbles' follow-up comment on #6009 flagging it as still outstanding. I initially built full Change.organization broadcast parity with channel_id, then reverted it after rtibbles' review correctly identified that permission-gating and broadcast-routing are separable concerns, and the latter wasn't needed here.

Extends the channel-invitation flow to support organization invitations,
per learningequality#6009 (following learningequality#6008's Invitation.organization field):

- Add Organization.filter_edit_queryset/filter_view_queryset based on
  active OrganizationRole membership, and extend Invitation's equivalents
  to grant org admins edit access and org members view access.
- Allow InvitationSerializer to accept an organization in place of (or
  alongside) a channel, requiring at least one of the two.
- Teach the /sync endpoint's permission gate to recognize organization-
  scoped changes, since org admins acting on another user's invitation
  don't fit the existing channel-scoped or self-scoped checks.
@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @yasinelmi, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

…tures

- generate_create_event/generate_update_event/generate_delete_event
  (viewsets/sync/utils.py) never gained an organization_id parameter
  when handle_changes() was taught to read it, causing a TypeError in
  every organization invitation test that passed organization_id.
- Two OrganizationInvitationSyncTestCase fixtures were missing
  invited=self.invited_user, which InvitationSerializer.get_fields()
  requires to unlock the accepted field for sync-based updates,
  matching the existing SyncTestCase fixture pattern.
Per the "Organizations XOR channels" requirement from learningequality#5971 and
rtibbles' comment on learningequality#6009 confirming this was deliberately deferred
to the API layer: InvitationSerializer.validate() now rejects an
invitation that has both channel and organization set, not just one
that has neither.

This invalidates the "co-owner" scenario (one invitation with both
fields set) the earlier test exercised, so that test is replaced with
one asserting the combination is rejected.
Extends the Change/sync mechanism to give organization_id the same
three roles channel_id already has, rather than reusing the per-user
change queue as a stopgap:

- Change.organization: new FK field + migration, so organization scope
  is a real, queryable column instead of being buried in the opaque
  kwargs JSON blob.
- apply_organization_changes_task: dedicated Celery task mirroring
  apply_channel_changes_task, draining pending organization-scoped
  changes. apply_user_changes_task now excludes organization-tagged
  rows so a change is only ever processed by one task.
- handle_changes(): organization-scoped changes route through their
  own permission-gated bucket via Organization.filter_edit_queryset,
  rather than being folded into user-only changes. The self-only check
  stays first in priority, since a user must be able to act on their
  own invitation even before holding any org role (e.g. accepting it).
  Also covers organization_ids that end up on self-only changes, so a
  self-accept carrying organization_id can't get stranded unapplied.
- organization_revs: full broadcast support in return_changes(),
  mirroring channel_revs, so any org admin sees invitation changes
  live, not just the acting user.
- generate_create_event/update_event/delete_event: gained an
  organization_id parameter so events can actually carry it.
- accept/decline actions and the sync-based accept path now tag
  organization_id on their Change events.

Also drops organization_name/organization__name from the invitation
viewset's values/field_map - unused and not requested; organization_id
is what's actually needed by the API consumer today.
0170_change_organization collided with unstable's own new
0170_merge_20260717_0136 (both depended on prior leaves). Renumbered
to 0171 and repointed its dependency at the new merge migration.
@yasinelmi
yasinelmi marked this pull request as ready for review July 20, 2026 13:31
rtibbles pointed out on PR review that channel_id/user_id exist on
Change specifically to decide where a change propagates to, and there
was no established need for organizations to have that same live
cross-admin broadcast - permission checks belong at the viewset layer
(which Organization.filter_edit_queryset via UserFilteredPrimaryKeyRelatedField
already handles), not as a new routing/broadcast concept on the shared
Change model.

Reverts:
- Change.organization field and its migration
- apply_organization_changes_task, and the organization exclusion on
  apply_user_changes_task
- The dedicated organization_changes bucket, organization_revs, and
  get_organization_revs() in handle_changes()/return_changes()
- organization_id tagging on accept/decline Change events

Keeps:
- The handle_changes() permission gate for organization-scoped changes
  (still needed - an org admin acting on someone else's invitation
  doesn't fit the channel-scoped or self-scoped checks), now routing
  through the existing per-user change queue instead of a dedicated one
- Organization.filter_edit_queryset/filter_view_queryset (used by both
  the permission gate and the serializer's UserFilteredPrimaryKeyRelatedField)
- All queryset, validation (including XOR), and read-path behavior
@rtibbles
rtibbles requested a review from rtibblesbot July 27, 2026 14:17
@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot

rtibblesbot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🔵 Review posted

Last updated: 2026-07-27 14:59 UTC

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

PR #6039 — the permission querysets on Organization/Invitation are idiomatic (single filter() so the multi-valued join matches one role row, status checked as well as role) and well unit-tested. The problems are concentrated in the new organization branch of SyncView.handle_changes, which routes org changes onto a client-supplied, unvalidated user_id — that yields a silent-drop path, a change-injection path, and no error feedback to the actor. Test coverage also misses acceptance criteria from #6009.

CI passing. No UI files changed, so Phase 3 (UI verification / manual QA) did not apply.

  • blocking — org change with no user_id is accepted but never applied (endpoint.py:97); unvalidated client user_id allows task fan-out and cross-user change injection (endpoint.py:121); org-admin edit rights don't actually enable revoke (models.py:3841); missing delete-event / CRUD / co-owner test coverage (test_invitation.py:353, :512).
  • suggestion — no applied/errored feedback to the actor (endpoint.py:109), FK dereference cost in validate() (invitation.py:53), unreachable accept event on the sync path (invitation.py:194), organization_id param with no production caller (utils.py:37).
  • nitpick — soft-deleted organizations not excluded (models.py:1899).

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran a phased review pipeline over the pull request diff:

  • Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
  • Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
  • Specialized frontend/backend review passes applied framework-specific lenses where those files changed
  • For UI changes: manual QA and an accessibility audit against a live dev server, when available
  • Checked CI status and linked issue acceptance criteria
  • Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence

user_only_changes.append(c)
elif c.get("channel_id") in allowed_ids:
channel_changes.append(c)
elif (

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.

blocking: An org-scoped change with no user_id is accepted here, then never applied — silent data loss with a 200.

Change.create_changes stores it as channel_id=NULL, user_id=NULL. apply_user_changes_task filters user_id=user_id, channel__isnull=True (tasks.py:36) — an exact match, so NULL matches no invocation; apply_channel_changes_task doesn't match either; and the reconciliation safety net explicitly excludes it (reconcile_change_tasks.py:44 filters channel_id__isnull=True, user_id__isnull=False). Meanwhile the change is returned in allowed, so the client marks it synced and drops its local copy.

This is the expected shape, not a hypothetical: the client's getUserId for invitations is obj.invited (frontend/shared/data/resources.js:2077), and invited is never populated for API-created invitations — it isn't in InvitationSerializer.Meta.fields and create() only sets sender. For invite-by-email the invitee may not have an account at all. The new tests pass only because they hand-supply user_id=self.invited_user.id.

Suggest deriving the routing target server-side instead of trusting/requiring the client — routing org changes onto the actor's queue (user_id=request.user.id) is the simplest option, since application already runs under the actor's permissions (apply_changesviewset.sync_initial(change.created_by)), and it also restores error feedback to the initiating client. If the invitee needs the broadcast, resolve them server-side from the invitation email. A test creating an org invitation for an email with no matching User would catch this.

apply_user_changes_task.fetch_or_enqueue(
request.user, user_id=request.user.id
)
other_target_user_ids = set(

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.

blocking: user_id reaching here is entirely unvalidated. The user branch above pins user_id == request.user.id and the channel branch gates on channel edit rights; the new org branch gates only on organization_id ∈ allowed_org_ids and is not restricted to the INVITATION table. Nothing checks that the target user is a member of the org, is the invitee, or is related to the change at all.

Two consequences:

  1. Client-controlled task fan-out — one POST with N changes each carrying a distinct user_id and an org the caller admins enqueues N apply_user_changes_task jobs for N unrelated users (fetch_or_enqueue only dedupes per user).
  2. Change injection into another user's feed — the Change row is persisted with user_id=<arbitrary user>, and return_changes matches Q(user=request.user), so once applied it is delivered to that user's client and written to their IndexedDB. Write permissions are still checked against change.created_by, so this isn't DB-level escalation, but "create an invitation for my org" is legitimately allowed — so an org admin can make an arbitrary user's client display a pending invitation not addressed to their email, which Invitation.filter_view_queryset would never return over REST.

Both disappear if the target is derived server-side (invitee by email, or the actor). If client-supplied user_id must stay, restrict this branch to INVITATION changes and validate that user_id is the invitation target.

# needs the change to be accepted and applied, so it's routed
# through the existing per-user queue, tagging the target user
# rather than the actor.
user_only_changes.append(c)

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.

suggestion: Worth stating the trade-off this comment currently reads past: tagging the target user means the actor never gets the change back. return_changes builds Q(user=request.user) & relevant_to_session_filter | Q(channel_id=...) & ..., and an org change has neither the actor's user_id nor a channel_id — including the Q(errored=True, session_id=session_key) term, which is ANDed inside relevant_to_session_filter.

So when an org change fails validation during apply_changes (e.g. the new both-channel-and-organization rule), it is marked errored=True server-side but the initiating client is never told, having already discarded its local copy in allowed. The frontend rollback path can't fire. That's a departure from every other change type; if it's intentional, the comment should say so.

@@ -3807,7 +3839,14 @@ def filter_edit_queryset(cls, queryset, user):
return queryset

return queryset.filter(

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.

blocking: This grants an active ORGANIZATION_ADMIN edit access to the org's invitations, but InvitationSerializer.get_fields is unchanged and still gates writability on channel-era relationships (if self.instance.sender == request.user: fields["revoked"].read_only = False). An org admin who isn't the sender therefore cannot revoke: revoked stays read-only, DRF drops it from validated_data, BulkListSerializer.update skips the object because obj.keys() is empty (viewsets/base.py:283), and the request returns 200 with nothing changed and no error.

Multi-admin orgs are the point of org-level roles, so "only the original inviter can revoke" is unlikely to be intended. test_revoke_organization_invitation_by_admin makes self.org_admin the sender, so it exercises the pre-existing sender rule; a test where admin A revokes an invitation sent by admin B would fail today. Extending get_fields to unlock revoked for org admins (mirroring the sender case) would close it.

Same shape for accept/decline over sync: gated on self.instance.invited == request.user, and invited is never set for API-created invitations. Latent, since the REST actions compare emails via _ensure_invitee, but worth noting.

pass


class OrganizationInvitationSyncTestCase(SyncTestMixin, StudioAPITestCase):

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.

blocking: #6009 asks for "create, update, delete" coverage of the invitation-organization relationship and for org invites to be "correctly synced with create/update/delete events", and frames the work as driven by CRUD/SYNC tests here. Two gaps:

  • No org equivalent of test_delete_invitation (line 297) / test_delete_invitations (line 318), even though generate_delete_event is already imported and delete_from_changes goes through the Invitation.filter_edit_queryset this PR changed.
  • No organization variant of CRUDTestCase (line 541), so the /invitation/ REST route itself is untested for orgs — POST with organization, and the new ?organization= filter at invitation.py:129.

Also missing, and reachable only because of this PR: an active admin of org A submitting a change for org B (allowed_org_ids is the only thing stopping them, with nothing asserting rejection), and an org change submitted without user_id — see the endpoint.py:97 finding; a test asserting the invitation is actually created would have caught that one.

list_serializer_class = BulkListSerializer

def validate(self, data):
channel = data.get("channel", getattr(self.instance, "channel", None))

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.

suggestion: self.instance is a single Invitation set per item by BulkListSerializer.to_internal_value (base.py:262), loaded via _data_lookup_dict with no select_related — so .channel and .organization each trigger a SELECT, up to 2N extra queries for a sync batch of N updates. Only truthiness is used, so self.instance.channel_id / self.instance.organization_id gives the same answer with no query.

@@ -163,6 +192,7 @@ def accept(self, request, pk=None):
INVITATION,
{"accepted": True},
channel_id=invitation.channel_id,

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.

suggestion: These two REST actions got user_id so their events are routable, but InvitationSerializer.update still emits generate_update_event(..., channel_id=instance.channel_id) with no user_id. For an org invitation that is channel_id=None, user_id=None, persisted by BulkListSerializer.save with applied=Truereturn_changes can never match it, so it is dead weight in the table.

The full org broadcast was deliberately reverted in 22b1f51, so that's out of scope here — but user_id=instance.invited_id (or the accepting user) would at least make the event reachable, matching what these actions now do. If the sync accept path is intentionally dead for organizations, a comment saying so would be worth more than the silent row.

event["channel_id"] = channel_id
if user_id:
event["user_id"] = user_id
if organization_id:

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.

suggestion: After the Change.organization revert, nothing in contentcuration/ outside the test helpers in tests/viewsets/base.py passes organization_id to these helpers, and a server-generated event carrying it would just deposit the key into Change.kwargsreturn_changes routes on channel_id/user_id only. Consider dropping the parameter until something server-side actually emits an org-scoped event, so the signature doesn't imply routing support that doesn't exist.

return self.name

@classmethod
def filter_edit_queryset(cls, queryset, user):

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: Organization.deleted is a soft-delete flag (line 1880) and CustomManager doesn't filter it, so neither of these classmethods excludes deleted orgs — and both call sites pass an unfiltered base queryset (Organization.objects.all() in the serializer field, Organization.objects.filter(id__in=...) in SyncView). An admin of a soft-deleted org can still create invitations into it, and accepting one creates a live OrganizationRole. Channel defers deleted filtering to its viewset, so either layer is consistent with the repo — but with no Organization viewset yet, these methods are currently the only gate.

if user.is_admin:
return queryset

return queryset.filter(

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.

praise: Checking status=ORGANIZATION_ROLE_STATUS_ACTIVE alongside the role — and keeping all three conditions in one filter() so they match a single user_roles row rather than across rows — is the easy thing to get wrong here. test_filter_edit_queryset__pending_admin_cannot_edit locks it in.

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

PR #6039 — 0 of 11 prior findings resolved: HEAD is still 22b1f51d, the diff is byte-identical to the last review, and none of the open threads have replies. All 11 remain open (breakdown collapsed below); the 5 blocking ones still gate merge. CI passing. No UI files changed, so Phase 3 did not run.

One new blocking finding this pass, inline on viewsets/invitation.py: org invitations created through the sync API never get invited populated, so the invitee can't accept or decline through sync.

Two further notes on already-open threads rather than new ones:

  • models.py:3841 (existing thread) — beyond blocking revoke, the org-admin edit clause makes InvitationSerializer.update reachable for a non-invitee, and that method reads self.initial_data.get("accepted") rather than validated_data. An org admin syncing {"accepted": true} on someone else's invitation calls instance.accept()OrganizationRole.update_or_create(status=ACTIVE), adding that user to the org without consent, while accepted itself is dropped as read-only so the invitation stays pending and the operation replays. Reading from validated_data fixes both halves.
  • endpoint.py:97 (existing thread) — the escape hatch is table-agnostic: any change carrying organization_id and no channel_id bypasses the user_id == request.user.id check, for every table in viewset_mapping present and future. Scoping it to table == INVITATION (mirroring how the created_channel_ids exception is scoped to table == CHANNEL) keeps it as narrow as the feature needs.
Prior-finding status

UNADDRESSED — contentcuration/contentcuration/viewsets/sync/endpoint.py:97 — org change with no user_id accepted but never applied (blocking)
UNADDRESSED — contentcuration/contentcuration/viewsets/sync/endpoint.py:121 — unvalidated client-supplied user_id allows task fan-out and change injection (blocking)
UNADDRESSED — contentcuration/contentcuration/models.py:3841 — org-admin edit rights don't enable revoke; get_fields still gates on sender == request.user (blocking)
UNADDRESSED — contentcuration/contentcuration/tests/viewsets/test_invitation.py:353 — missing org delete-event and org CRUDTestCase coverage required by #6009 (blocking)
UNADDRESSED — contentcuration/contentcuration/tests/viewsets/test_invitation.py:512 — no test for channel invitation with organization role (co-owner) (blocking)
UNADDRESSED — contentcuration/contentcuration/viewsets/sync/endpoint.py:109 — actor gets no applied/errored feedback for org changes (suggestion)
UNADDRESSED — contentcuration/contentcuration/viewsets/invitation.py:53 — validate() dereferences self.instance.channel/.organization, up to 2N queries per batch (suggestion)
UNADDRESSED — contentcuration/contentcuration/viewsets/invitation.py:194 — accept/decline event unroutable for org invitations (suggestion)
UNADDRESSED — contentcuration/contentcuration/viewsets/sync/utils.py:37 — organization_id parameter has no production caller after the broadcast revert (suggestion)
UNADDRESSED — contentcuration/contentcuration/models.py:1899 — soft-deleted organizations not excluded from the permission querysets (nitpick)
UNADDRESSED — contentcuration/contentcuration/models.py:1906 — single-filter() role+status check on Organization.filter_edit_queryset (praise)


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Ran the same phased review passes as a first review (core, frontend/backend lenses, manual QA when required)
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

"revoked",
"email",
"channel",
"organization",

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.

blocking: invited isn't in Meta.fields and the only code that sets it is send_invitation_email (views/users.py:66-72), which is channel-only (request.data["channel_id"], request.user.can_edit(channel_id)) and isn't extended here. So an organization invitation created through the sync API has invited = NULL.

get_fields gates both accepted and declined on self.instance.invited == request.user (line 101), so the real invitee of a real org invitation can never flip either flag through sync. test_accept_organization_invitation_creates_role passes only because the fixture constructs the invitation with invited=self.invited_user — a state this branch's creation path cannot produce. (The accept/decline REST actions do work, since _ensure_invitee matches on email.)

Either resolve invited from email on create, or gate on email match the way _ensure_invitee does — and build the test fixture through the sync create path so the gap is caught.

- Route org-scoped changes through the actor's own queue instead of
  trusting a client-supplied user_id: fixes a silent-drop when user_id
  is omitted (endpoint.py:97) and a change-injection vector where an
  org admin could tag an arbitrary user as the target (endpoint.py:121).
  Scoped to the invitation table specifically, mirroring how the
  created_channel_ids exception is scoped to the channel table.
- InvitationSerializer.get_fields(): unlock `revoked` for any active
  org admin of the invitation's organization, not just the original
  sender - org-admin edit rights on the queryset didn't actually enable
  revoke without this.
- InvitationSerializer.get_fields(): gate accepted/declined on email
  match (mirroring _ensure_invitee) instead of the `invited` FK, which
  is never populated for invitations created through the sync API -
  the real invitee of a real org invitation couldn't accept/decline.
- InvitationSerializer.validate(): use channel_id/organization_id
  instead of dereferencing the related objects, avoiding up to 2N
  extra queries per sync batch.
- Organization.filter_edit_queryset/filter_view_queryset: exclude
  soft-deleted organizations.
- New tests: org change with no user_id still applies; a client-
  supplied user_id is not trusted as the routing target; cross-org
  isolation; revoke by a non-sender org admin; org invitation delete
  via sync; accept of an invitation created through the real sync
  create path (not hand-constructed with `invited` set); organization
  filter on the list endpoint.
@yasinelmi
yasinelmi marked this pull request as draft July 27, 2026 21:26
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.

Add Organizations to Invitation API Route (And Sync Events)

3 participants