Skip to content

Headless BFF access control is centralized and gate-forced

Context

The headless API (apps/headless/, Circle.so-compatible BFF) re-implemented content lookups view by view. Because each handler invented its own scoping, several endpoints returned content from Private/Secret spaces to members without an access_group, or read across tenant boundaries (get_object_or_404(Post, pk=...) with no community/space gate). The /v1 API had this centralized in Space.accessible_to() / HasSpaceAccess; headless did not.

Two distinct questions were being conflated:

  • Visibility — "does this space appear in the sidebar?" (visible_to), intentionally looser (Public + Private are listed to all active members).
  • Access — "can this member read/write content in this space?" (accessible_to), the only check allowed to gate content.

Decision

Every headless handler that touches Space content must go through exactly one of three canonical gates. A bare get_object_or_404(<ContentModel>, pk=...) without a gate is a bug, not a shortcut — and a structural test rejects it.

  1. Single-space endpoints_resolve_accessible_space(membership, space_id): 404 for invisible (Secret) spaces, 403 for visible-but-accessed (Private without access_group).

  2. Cross-space queries.accessible_to(membership) on the model's QuerySet (Post, Comment, Event, Lesson, Announcement, Space), which scopes to the membership's own community and enforces space access.

  3. Per-object lookups (including mutation paths — comments, reactions, restores, reports, progress) → visible_content_qs(membership, model) + resolve_accessible_object(membership, qs, pk) (in apps/spaces/permissions.py): 404 for invisible/cross-community rows, 403 for visible-but-inaccessible. This is the fail-closed wrapper; it never confirms the existence of content the caller can't see.

Authorization on top of the gate reuses the /v1 permission classes (IsAuthorOrModerator, IsAdminOrModerator, IsNotMuted, IsNotLocked) instead of re-implemented loose checks.

CourseLessonDetailView.get is the only allowlisted exception: it intentionally looks up a lesson without a content gate so it can serve the locked=True stub to locked-out members (which contains no real content, sections: []).

Considered Options

  • Leave the status quo: each handler scopes its own queryset. Rejected — the divergence already leaked Private/Secret content and allowed cross-tenant reads; there was no way to audit "is this endpoint gated?".
  • Only add .accessible_to to headless views without shared helpers. Rejected — mutation paths (comments/reactions/reports) needed a 404-for-invisible wrapper (visible_to is the wrong gate, a bare 404 confirms existence). visible_content_qs/resolve_accessible_object encode that once.

Consequences

  • The gates and the structural test are mandatory, not advisory: new or edited headless endpoints that return Space content must route through one of the three patterns above. See rule 0 in CLAUDE.md.
  • apps/headless/tests/test_visibility_contracts.py guards behaviorally (outsider/insider) and apps/headless/tests/test_headless_access_regressions.py::StructuralAccessGuardTests guards statically — it scans headless/views.py and fails if a content-model get_object_or_404 lives in a handler without an access gate. Any new allowlisted exception must be justified in the test's comment.
  • Some endpoints changed status-code semantics to be fail-closed (e.g. a bookmark on an inaccessible post is now a 404/403 instead of silently succeeding). Frontends treat non-2xx as "no access", which matches the user model.
  • Headless no longer needs its own permission vocabulary: it composes the /v1 permission classes, so a rule change in one place stays in sync.

Strum — Documentação.