Two-Axis RBAC: Roles for Actions, Bidang for Data
Roles decide actions, bidang decides data. Notes from building a fail-closed scope resolver in Go: ModulesFor, ResolveBidang, and the NULL trap.
TL;DR
Roles handle actions while a bidang column scopes data by agency, avoiding a huge permission matrix. A helper maps bidang to modules, returns nil for unknowns to fail closed, and copies the ALL slice to prevent Go append bugs. Announcements use NULL for global posts with an IS NULL filter, and empty bidang maps to ALL for legacy JWT compatibility.
Two admin accounts sat open on my screen. Both carried the admin role, one from the tourism agency, one from sports. The permission matrix we already had answered only half of the question: both may edit, both may publish. What it could not answer: which rows of data each of them may see. The question showed up once our portal started serving several city agencies on a single system.
My first guess: add per-module permissions to the same table. That path explodes fast. Ten agencies times five modules means fifty rule rows for a single role, and every new agency forces an audit of every combination. The solution I ended up with separates two things that had been glued together: roles for actions, bidang (agency) for data.
The users.bidang column and the scope package
Role-based access control is not a new idea; Ferraiolo and Kuhn formalized it in 1992, and it became the dominant model for modern access control [1]. What I added here is only the second axis. A migration adds the users.bidang column; on MySQL 5.7, a plain ADD COLUMN runs with ALGORITHM=INPLACE and LOCK=NONE on InnoDB, so normal table operations are not blocked while the migration runs [3].
The column holds five values: pariwisata, olahraga, budaya, kepemudaan, or ALL. The internal/shared/scope package translates that value into the list of category modules the account may manage. PARIWISATA automatically includes EKONOMI_KREATIF, following our agency's SPBE domain. One small detail that is easy to miss: ModulesFor(ALL) returns a copy, not the original slice, because append in Go may allocate a new array and the result has to be stored back into a variable [5]. Without the copy, one caller appending to the set would poison the next caller's scope.
func ModulesFor(bidang string) []string {
if bidang == All {
out := make([]string, len(allModules))
copy(out, allModules)
return out
}
return bidangModules[bidang] // nil for an unknown bidang
}Fail-closed at three points
OWASP puts the principle bluntly: when no access control rule matches, the application cannot stay neutral; it must decide to deny or to allow [2]. My implementation takes the deny side. ModulesFor returns nil for an unknown bidang, and nil means no data. The ResolveBidang middleware that loads the bidang after JWT authentication fails closed too: a database error ends in a 500, not an unfiltered view.
There is one deliberate exception. An empty string maps to ALL. Not an oversight but a compatibility bridge: every JWT issued before the migration carries no bidang information, and every user was defaulted to ALL at migration time. Without this mapping, a small deploy would lock out every legacy account.
The NULL trap in global announcements
The announcements table gets a bidang column with the contract that NULL means global. SQL semantics matter here. NULL is never true in any comparison, not even against itself, so lookups must go through IS NULL [4]. The announcements list filter becomes explicit:
WHERE status = 'published'
AND (bidang IS NULL OR bidang = ?)The rest of the work is boring in a good way. NarrowModules receives an explicit ?module= request, matches it against the scope, and answers with an empty list without touching the database when the module is out of scope. A small gate that closes by default turned out to be worth more than a giant permission matrix: those three decision points, the resolver that returns nil, the middleware that maps empty to ALL, and the OR clause forced by NULL semantics, hold almost all of the security logic.
Sources: