Skip to main content

Bulletin Board — The Access Ladder and Who Can Do What

Overview

This is the permission layer for the entire bulletin domain. Every request, read or write, passes through resolveAccess exactly once, and the resulting AccessContext is then handed down through the stack — no handler ever recomputes permissions.

Read this before any other bulletin feature. The three rule sets described here — permissions by user_type, blocks, and category visibility — determine the behaviour of the feed, posting, commenting, and reactions alike.

Business Flow

resolveAccess(c) — five steps, in an order that matters

The ordering exists because a caller's LIFF token only means anything once it has been bound to that OA's LINE Login channel. Everything with a side effect — creating the board row, provisioning a guest — therefore happens after the token is bound. An unauthenticated caller, or one arriving from a different channel, can never write a row.

  1. Resolve the OA and its LINE Login channel from :hash — not found returns 404 with board not found.
  2. Verify the token against that specific channel via resolveUserID — failure returns 401.
  3. EnsureBoard — create the bulletin.board row for the OA if it does not exist yet.
  4. Look up the line_user row. A miss here does not prove the row is absent, because the query filters on status and deleted_date. The code therefore calls EnsureGuest, a conflict-safe insert that reports what actually exists in the table — a row that exists but is disabled or deleted is a deliberate rejection, answered with 403 BULLETIN_USER_INACTIVE, not a re-admission as a guest.
  5. Load any still-active block and assemble the AccessContext, carrying lineUserID, displayName, userType, lineOaID, oaName, oaCover, orgID, boardID, settings, audienceIDs, and block.

Settings and how permissions are decided

The bulletin.board.settings column is jsonb, and ParseSettings unmarshals it on top of the defaults. Keys absent from the blob keep their default value, and malformed JSON degrades to the defaults rather than producing a board that rejects everyone.

An empty array in view_access or write_access is a legitimate deny-all; null means "not configured" and falls back to the default.

The defaults are: view_access of member and guest, write_access of member only, require_post_approval and require_comment_approval false, max_images_per_post 4, max_images_per_comment 2, max_body_length 2000, max_comment_length 500, allow_comments_default true, allow_user_reports true, and a standard set of six reaction_emojis.

The five capabilities

MethodRule
CanView()user_type must appear in view_access, otherwise 403 BULLETIN_VIEW_FORBIDDEN
CanWrite()Must appear in write_access (otherwise BULLETIN_WRITE_FORBIDDEN) and have no block whose scope is anything other than comment. This is deny-by-default: a misspelled scope or a zero value counts as a full block, returning BULLETIN_BLOCKED
CanComment()Must appear in write_access and have no block at all — blocks of either scope forbid commenting
CanReact()Identical to CanWrite(), so a user muted with the comment scope can still react
CanReport()Requires allow_user_reports to be enabled (otherwise BULLETIN_REPORTS_DISABLED), then checks CanWrite()

A block whose expires_date lies in the past is inert. The repository already filters those out; the check at this layer is a second safety belt.

Category visibility (CanSeeCategory) — two independent axes

  • The read axis is access_mode. inherit uses board-level permissions, so guests can see it. restricted with an empty audience means all members. restricted with an audience means only members whose audiences overlap. Restricted always implies member-only, since a guest is never in a meaningful audience.
  • The write axis is post_access. member (the default) lets LINE users post; admin allows posting from the CMS only.
  • The two axes compose. For example, inherit plus admin produces an "announcements" category everyone can read but only administrators can write to.
  • An audience_ids value that cannot be read as an array — a literal null, or an array whose elements are not integers — denies access rather than widening to all members. Only a genuinely absent value (length 0) counts as the empty array meaning "all members".
  • The same rule has a SQL twin, categoryVisibilitySQL, used to filter the feed. The two must agree, and paired tests pin them together.
  • GET /bulletin/:hash filters the category list through this rule on the way out, because a category's name can itself be confidential and must be removed wholesale.

Error codes

Error codes are returned as messages so the client can branch on them: BULLETIN_VIEW_FORBIDDEN, BULLETIN_WRITE_FORBIDDEN, BULLETIN_BLOCKED, BULLETIN_REPORTS_DISABLED, BULLETIN_COMMENTS_CLOSED, BULLETIN_IMAGE_LIMIT, BULLETIN_BODY_TOO_LONG, BULLETIN_EMPTY_BODY, BULLETIN_EMPTY_TITLE, BULLETIN_TITLE_TOO_LONG, BULLETIN_INVALID_EMOJI, BULLETIN_INVALID_REASON, BULLETIN_DETAIL_TOO_LONG, BULLETIN_INVALID_IMAGE, BULLETIN_CATEGORY_ADMIN_ONLY, and BULLETIN_USER_INACTIVE.

A package-wide rule worth memorizing

Bulletin ids are a global serial, so every query must take a lineOaID — an id alone never proves ownership. And every kind of failure (does not exist, belongs to another tenant, deleted, or sits in an invisible category) must collapse into the same 404, never a 403, which would confirm the thing exists.

Key Files & Functions

FileFunctions
internal/bulletin/handler.go(*Handler).resolveAccess, the boardRepo interface
internal/bulletin/accessctx.goAccessContext, CanView, CanWrite, CanComment, CanReact, CanReport, CanSeeCategory, activeBlock, decodeAudienceIDs, resolveUserID, ChannelBinding, channelBindingOf, and the error code set
internal/bulletin/entity.goSettings, DefaultSettings, ParseSettings, Allows, AllowsEmoji, Category, AllowsLineUserPosts, plus the status, author, target, scope, access-mode, and post-access constants
internal/bulletin/repository.goFindOaByHash, EnsureBoard, FindLineUser, EnsureGuest, FindActiveBlock, FindCategories, categoryVisibilitySQL
internal/bulletin/view.govisibleCategoryViews, NewCategoryView, publicURLOf
internal/bulletin/register.goMounts /bulletin/:hash behind appguard.AppEnabledGuard(db,"bulletin")

Connections to Other Services

  • Database — tables bulletin.board (jsonb settings), bulletin.category, bulletin.block, line_oa, and line_user
  • App-enabled guard — lets a platform administrator switch the whole feature group off
  • LIFF authentication — supplies the channel-binding ladder
  • Related features — the foundation for the board and feed, post writing, commenting, and reactions/reporting
  • client-web — corresponds to bulletin-moderation, whose capability system reads the settings returned here, and underpins bulletin-board