Bidang RBAC Enforcement: The Middleware Gate and IN Filters
Enforcing the bidang scope in Go: middleware at the gate, IN filters in repositories, and subtests that read like the security policy.
TL;DR
Middleware at the gate verifies the JWT and injects the bidang scope so handlers stay clean. Repositories then filter by allowed modules using sqlx In, while announcements handle NULL as global, and global routes require ALL access. Leaving the repository fail-open keeps public calls simple, and subtests ensure scoped admins only see their own data.
That morning my task was narrow: continue wiring routes in main.go. The ResolveBidang resolver already worked, JWT verification already worked, yet every list endpoint still returned every row. The gate stood at the entrance; the doors of the rooms inside had no locks.
My first guess: sprinkle a module check into every handler method. Fine for two endpoints. Our CMS has entities, categories, announcements, reviews, analytics, plus user and media routes. Once the handler list grows, the pattern turns into debt: one handler that misses the check is one leak that code review will never surface.
A contract between two layers
The first layer stands at the gate. The ResolveBidang middleware places the bidang scope into the context after the JWT is verified, and handlers read it through BidangFrom without knowing the mapping details. The second layer works inside. The repository receives a slice of allowed modules, and the entities List and ListCategories queries filter the category column with an IN clause built by sqlx.In: the function takes the slice and returns the query plus a new argument list ready to execute [6].
if len(q.Modules) > 0 {
inQuery, inArgs, err := sqlx.In(" AND c.module IN (?)", q.Modules)
if err != nil {
return nil, 0, err
}
where += inQuery
args = append(args, inArgs...)
}Announcements take a different shape. Their bidang column follows the contract that NULL means a global announcement, and because NULL in SQL is never true in any comparison, the filter has to be explicit: bidang IS NULL OR bidang = ? [4]. Reviews filter through a JOIN on categories with the same module scope. The direction matches what OWASP says about authorization: when no access control rule matches, the application cannot stay neutral; it must decide to deny or to allow [2]. The middleware gate decides to deny first.
Inside main.go the routes split in two. globalRoutes holds menus, settings, and services: sitewide content only the ALL bidang may pass, enforced by RequireGlobalBidang. Everything else is CMS routing: entities, categories, announcements, reviews, analytics, all of it passing the scope middleware before reaching a handler. With this structure an audit boils down to two questions: is this route global, and if not, where does it read the scope?
A fail-open repository, on purpose
The part people question most: why is the repository fail-open? When the Modules slice is nil or empty, no filter is applied at all. It looks like a hole; it is a deliberate decision. Public callers genuinely have no scope context, and forcing them to pass an empty module list for security's sake only adds ritual without meaning. The security contract is enforced at the single entrance: every admin route must pass ResolveBidang, and integration tests pin the behavior down.
Subtests as the proof
I wrote the proof as subtests through t.Run, which gives each entry a unique name built from the parent and the entry itself [7]. The scenarios read like the security policy: an OLAHRAGA admin sees only OLAHRAGA entities, BUDAYA rows must never appear, and ALL sees everything. When a future refactor loosens a filter, these tests are the ones that scream first, not a user report.
Locking one gate plus a handful of well-placed filters turned out cheaper to maintain than scattering checks across a dozen handlers. The lower layer stays dumb and pleasant to use, the upper layer does the guarding, and the proof runs automatically on every build.
Sources: