Skip to content

Setting Another User's Bidang: A Privileged Action

Adityo Guni Waluyo

Only a superadmin may set a user bidang; the ALL default keeps 19 legacy users through the migration unharmed.

TL;DR

The schema change was simple, but deciding who can assign another user's scope was the real challenge. A central resolveBidang function lets only superadmins set it, returning 403 if others try while still allowing edits to other fields. Defaulting to ALL keeps existing users unlocked, with strict validation and eight tests locking the behavior.

Adding a bidang column to the users table is the easy part. Migration 000057 is an ADD COLUMN with an ENUM and a default. The question that actually ate time was this: who gets to decide another person's scope?

My first guess was to follow the pattern from other modules. The author field comes from the logged-in session, so just keep it consistent. It took a while to collapse: the author records who wrote something, while the bidang records where the content belongs. Different things.

The guess fell apart while writing the rule. Setting another user's scope is a meta-action: managing content and managing who may manage content are two different security surfaces. Treat them the same and the privilege-escalation hole is open before anyone notices.

A 403 That Picks Its Cases

The gatekeeper is resolveBidang(actor, requested, defaultALL). The headline rule is firm: only a superadmin may set a user's bidang.

The subtle part: the 403 is selective. A non-superadmin who SENDS a bidang value gets ErrForbidden. One who leaves it empty still succeeds at editing another user's email, avatar, or password. That's not a hole, it's a design decision: one users form serves every role without building a separate read-only mode for a single field, and the server holds the line, not the UI. Three superadmin cases are locked in tests too: create without a bidang defaults to ALL, update without one means unchanged, and anything outside the whitelist gets ErrInvalidInput. Eight scenarios, one test table.

OWASP derives this cleanly. Deny by Default says the application can't stay neutral and must always decide deny or permit [4]; Least Privilege draws the conclusion: rights that aren't needed don't get granted [6]. Setting someone else's scope clearly falls into the rarely-needed bucket, and the server must reject it explicitly, not quietly ignore it.

DEFAULT ALL as the Safety Net

The column is defined ENUM NOT NULL DEFAULT 'ALL', and the choice is operational. There were 19 existing users as of September 29, 2026. Defaulting to ALL means none of them gets locked out at deploy, and an additive ALTER TABLE on MySQL 5.7 typically runs INPLACE which keeps concurrent DML flowing, so the users table doesn't need locking [5]. Flip the default to a specific bidang and you drag 19 people into the wrong scope before they've done anything.

The part that's easy to forget but bit us first: generated types. Every time the OpenAPI contract changes, I regenerate v1.d.ts on the frontend, and the bidang field appears in request and response types without hand-editing. The order is rigid but saves you: OpenAPI first, generate types, then the form follows. If a form forgets to send the field, TypeScript screams at build time instead of a user hitting a 400 in the late afternoon.

The UI follows suit. The bidang dropdown and the role badge on the users view render only for superadmins. validBidang in the backend is a whitelist of scope constants: PARIWISATA, OLAHRAGA, BUDAYA, KEPEMUDAAN, ALL. The Actor struct now carries Bidang, so the layers are explicit: handlers carry identity, the service decides.

The contrast shows when you compare it with yesterday's module. In announcements, a scoped writer still works fully inside their territory and just gets reminded when they hit the edge. In user management there is no territory: an editor may not set anyone's bidang, including their own, because what's being governed isn't content but the structure of who may hold territory. Two modules, one scope convention, two different trust levels; the difference lives in each service instead of being flattened for uniformity.

Eight test scenarios read like an executable spec. One table records: superadmin create without a bidang falls to ALL, explicit OLAHRAGA is honored, update without a bidang changes nothing, explicit ALL is valid, HEX gets ErrInvalidInput, admin and editor get ErrForbidden when sending a value, and a non-superadmin sending nothing passes because they never touch the field. If a new role shows up tomorrow, that table is the contract: add the case first, then change resolveBidang.

My position: treat scope assignment like a vault key, not a folder label. An ordinary field may be edited by anyone who can edit the row; this one can't. With the line drawn clearly in one function and locked by tests, the same form serves every role without me wondering who else could quietly shift their own scope.

One near-miss worth recording: the edit form's select deliberately always sends a bidang, so a superadmin never thinks about two cases. The server carries the complexity through its nullable semantics while the client stays honest. That pattern, server decides the cases, is what keeps a uniform form safe across roles.

Sources

Related articles