Skip to content

Announcement Scope: Writing a Value vs Mutating a Row

Adityo Guni Waluyo

Two authorization functions for per-division announcements: resolveBidang for new values, canManage for existing rows, NULL as global.

TL;DR

Portal now scopes announcements by division with two separate checks. resolveBidang governs creation, letting ALL admins write global NULL or any bidang while scoped writers are locked to their own. canManage governs existing rows, so only ALL can touch global content and labeled rows are owner-only, enforced in the service layer before any mutation.

Announcements in this department's CMS used to be flat: every admin could edit every announcement. Once the portal splits by division, the first question isn't about UI, it's about the rules. I paused right before writing the code: is one check function enough, or do I need two?

My first guess was one. Both checks look like they just examine a bidang. It turned out there are two different questions here, and they have different answers.

Writing a New Value Is One Question

The first function, resolveBidang, answers: what is this writer allowed to write into?

Admin ALL is free: create a global announcement (leave the value empty), or one for OLAHRAGA or BUDAYA, all valid. A scoped writer is pinned to their own bidang. If they submit OLAHRAGA while belonging to PARIWISATA, the request gets a 403 whose message names the target bidang. Values outside the list hit a validation error instead of being silently normalized.

The easy-to-miss detail: global content is marked NULL in the database, not the value ALL. Migration 000057 adds a nullable announcements.bidang ENUM plus an index, and the 35 existing announcements stay visible to every admin because NULL reads as cross-division. The ALL value in the enum exists for other tables; on announcements, NULL is the global sentinel.

Existing Rows Are the Second Question

The second function, canManage, answers something else: who owns the row I'm about to touch?

Being allowed to create OLAHRAGA-labeled data doesn't automatically let you edit someone else's OLAHRAGA row. The rule: global (NULL) content is managed only by ALL admins; labeled content is touchable only by its owner. guardScope wires this into Update, Trash, Restore, and DeletePermanent, all before the mutation runs. The TestCanManage unit test locks the combinations, including the case where an OLAHRAGA writer can't touch a global row.

Running the scenarios in my head: an OLAHRAGA writer creates an announcement, the bidang is set automatically, done. They try to edit a colleague's BUDAYA row and guardScope trips before a single line changes. The unit tests cover the rest; I can trust that forgetful edge cases get caught there, not in front of a user.

The two functions deliberately stay separate. OWASP's Deny by Default principle says an application can't stay neutral and must always decide to deny or permit, even when no rule matches [4]. The same cheat sheet also reminds you to verify authorization checks happen in the right location; here that means the service layer, before a request reaches the repository [6]. Merging both questions into one function makes scoping rules unreasonably hard to reason about, and that is exactly the crack people look for.

The data's journey shows in the function signatures. Create and Update now take requesterBidang from the session, not from a spoofable payload. Handlers just relay: read identity from middleware, hand it to the service. Even the 403 message is specific, naming the target bidang or saying the row belongs to another division, so an admin who hits the rule knows who to ask for help. A generic 403 is great for logs and useless for humans.

The editor side matters just as much. The information form reads session.user.bidang, and the bidang dropdown only renders for global admins. Scoped writers don't have to think: their bidang rides along automatically and the field can't hold garbage. It pairs with the server rules: the UI hides the option, the server rejects whatever slips through.

On live tables, this migration is safe to run. On MySQL 5.7, an ALTER TABLE that falls back to the COPY algorithm blocks concurrent DML; an ADD COLUMN like this typically runs INPLACE which keeps DML flowing [5]. Old rows aren't touched, so the deploy needs no special window.

About global content, I made a call: day-to-day department announcements get a bidang at birth, and the NULL sentinel is reserved for genuinely cross-division items like national-holiday notices. If every routine item had to pass through an ALL admin to become global, that admin's queue becomes the new bottleneck. A rare sentinel works better as a marker for exceptions than as the default path.

My position is firm: separate resolveBidang and canManage from the start. Writing a new value and mutating an existing row are two authorization surfaces that happen to sit in one feature, not one question forced into one function.

Sources

Related articles