Community Room Authorization
Overview
A team sub-community ("room") is a plain Community row scoped to a Team via team_id — same model, same feed/goals/members machinery as a public community (see Features → Communities § Team Sub-Community Fields). For a team_id-nil (public/free) community, none of the rules on this page apply — access is the existing public-community model, unaffected.
For a team_id-present community, two independent rules gate everything: who can read/write the room (this page), and who can manage the room's lifecycle (a separate, broader permission). They are enforced by different policy objects and do not imply each other — this is the trap a future contributor is most likely to fall into, and the reason this page exists.
Rule 1 — room access requires actual membership
CommunityAccessPolicy#can_access_room? (rails_api/app/services/community_access_policy.rb, shipped OBJ-2302, PR #2199):
def can_access_room?
return true if @community.team_id.blank?
return true if @user&.admin
@user.present? && CommunityMember.exists?(user_id: @user.id, community_id: @community.id)
endteam_idblank (public/free community) — always accessible. This policy is a no-op outside team rooms.@user&.admin— a global site admin (User#admin, the app-wide admin flag), not a team-role admin/owner. Bypasses the membership check entirely.- Everyone else — must have an actual
CommunityMemberrow for that specific room. Being a seated member of the team is not enough; team-query data (isMemberonteamHome.rooms[]/teamSettings.subCommunities[]) reads the same underlying membership, so it stays consistent with what this policy enforces.
Gated call sites — reads (rails_api/app/graphql/resolvers/community_queries.rb, via authorize_room_access!):
| Query | Behavior on failure |
|---|---|
community(id:) | FORBIDDEN GraphQL error |
communityFeed(communityId:) | FORBIDDEN GraphQL error |
communityMembers(communityId:) | FORBIDDEN GraphQL error |
communityBadges(communityId:) | FORBIDDEN GraphQL error |
Gated call sites — writes:
| Mutation / Interaction | Guard | Behavior on failure |
|---|---|---|
Social::CreateCommunityPost | require_room_access (calls CommunityAccessPolicy) | fail_with(code: :forbidden) |
Social::AddPostComment | require_room_access (calls CommunityAccessPolicy) | fail_with(code: :forbidden) |
Do not loosen this policy to work around a 403. A member who isn't seated on the room shouldn't read or post to it by direct API call any more than through the UI — that's the exact gap OBJ-2302 closed. If a legitimate new surface needs different access, it needs its own reasoned rule, not a widened can_access_room?.
Social::AddPostComment carries the same require_room_access guard as Social::CreateCommunityPost (OBJ-2347) — comment creation is covered by this page's rules, not just post creation.
Rule 2 — archived rooms are read-only, server-enforced
archived_at is a plain column, not a soft-delete marker (acts_as_paranoid's deleted_at is separate) — an archived room's record and feed stay live for a 30-day retention window (Teams::ArchiveSubCommunity::ARCHIVE_WINDOW), readable by existing members, not writable by anyone.
Social::CreateCommunityPost and Social::AddPostComment both carry a require_not_archived guard — the post path added v4.35 Phase 1 (OBJ-1825), mirroring the pre-existing not_archived? guard in Teams::JoinSubCommunity; the comment path added later (OBJ-2347), mirroring CreateCommunityPost:
def require_not_archived
return unless community
fail_with(error: 'This room is archived', code: :conflict) if community.archived_at.present?
endOrdered after require_room_access in the guard chain — membership is checked first, then room state. Failure returns code: :conflict, which Mutations::CreateCommunityPost#resolve and Mutations::AddPostComment#resolve both surface as a normal { post: nil, errors: [...] } / { post_comment: nil, errors: [...] } GraphQL response, not a hard error — this is a validation-style failure, not an authorization failure. A :forbidden from Rule 1's guard is different: both resolvers raise it as a hard GraphQL::ExecutionError with extensions: { code: 'FORBIDDEN' } instead.
Client-side suppression alone is not a control. ionic_frontend/src/views/Community.vue's team-room mode suppresses the composer and post actions when a room is archived (canPostToRoom/readOnly wired down to CommunityFeed.vue/CommunityFeedCard.vue), but that's UI honesty, not enforcement — the require_not_archived guard above is what actually stops a member from posting to an archived room via dev tools, a replay, or any non-honest client. Any new mutation that writes to a CommunityPost, comment, or reaction should carry the same two guards; AddPostComment now does (OBJ-2347). There is no PostReaction write path today — rails_api/app/models/post_reaction.rb and the read-only aggregation in community_queries.rb are the only call sites — but whoever builds one must add require_room_access and require_not_archived from the start, not retrofit them later.
What Rule 1 does not cover — managing a room is not being in it
Room lifecycle mutations — archive, un-archive, set-default — are gated by a separate policy: TeamAccessPolicy#can?(:manage_team), a team-role check (team owner/admin), not CommunityAccessPolicy. Teams::ArchiveSubCommunity, Teams::UnarchiveSubCommunity, and Teams::SetDefaultSubCommunity all guard on policy.can?(:manage_team) and never check CommunityMember membership at all.
Accepted, deliberate consequence: a team owner/admin can archive or set a default for a room they are not a member of, but cannot open its feed (Rule 1 still applies to them, since TeamAccessPolicy's manage_team permission doesn't imply a CommunityMember row, and they're a team-role admin, not User#admin). This is correct, not a bug — managing a room's lifecycle and reading its contents are different concerns. Recorded here so nobody "fixes" it into a bypass later.
Related docs
- Features → Communities § Team Sub-Community Fields — the
CommunityTypefields this policy interacts with (isMember,teamId,archivedAt,joinPolicy) - Features → Communities § Team Room Feed — the user-facing surface this policy protects
- GraphQL Reference § Teams — Sub-Communities, Membership & Invites —
joinSubCommunity's own independentarchived_at/join_policychecks (defense-in-depth on the join path, distinct from the post path documented here)
Last updated: 2026-08-20 — Social::AddPostComment closed the comment-path room-access and archived-room gaps (OBJ-2347); this page no longer documents the comment path as an open hole. Originally written for v4.35 Phase 1 (OBJ-1825) once the archived-room write guard (Rule 2) landed server-side, per Roy's review note that this contract needed to state both rules in one place.