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.
Single-space endpoints →
_resolve_accessible_space(membership, space_id): 404 for invisible (Secret) spaces, 403 for visible-but-accessed (Private without access_group).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.Per-object lookups (including mutation paths — comments, reactions, restores, reports, progress) →
visible_content_qs(membership, model)+resolve_accessible_object(membership, qs, pk)(inapps/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_toto headless views without shared helpers. Rejected — mutation paths (comments/reactions/reports) needed a 404-for-invisible wrapper (visible_tois the wrong gate, a bare 404 confirms existence).visible_content_qs/resolve_accessible_objectencode 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.pyguards behaviorally (outsider/insider) andapps/headless/tests/test_headless_access_regressions.py::StructuralAccessGuardTestsguards statically — it scansheadless/views.pyand fails if a content-modelget_object_or_404lives 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
/v1permission classes, so a rule change in one place stays in sync.