One Admin Account per Scope, Then RBAC Checks Prove Something
Manual permission checks stalled because no admin account matched the scope. A closed CLI creates one per scope and proves it with a real login.
TL;DR
Authorization testing needs the right privilege. Admin accounts are created per bidang. The create-bidang-users command has three guards.
I was staring at the CMS screen in the KotaPortal project that morning with one task: check that a new menu appears only for the right bidang. The check stalled in the first minute. The demo account with role user was refused at the authorization gate, and the only account that could enter the CMS was the superadmin. With that account every page opened, and that is exactly where the evidence disappeared: no closed screen to compare against, so one successful view said nothing about the rules that run per bidang.
My first guess was simple. Reuse the superadmin account that already exists, or the demo account whose role is deliberately limited. Neither fit. The superadmin passes because there is no boundary left to test, while the limited demo account is built to hold no admin rights at all. Authorization testing needs the right privilege, not the avoided one.
What I needed was one admin account for each bidang, created in a way that could never cross anywhere else.
One Account for One Bidang
Authorization is different from authentication: the first verifies that a requested action is approved for a specific entity, the second only verifies identity [1]. A person who is logged in does not automatically own every resource. That was my position exactly, fully authenticated and completely unproven in authorization.
OWASP turns that way of thinking into a table: one authorization usually has two dimensions, the feature and the logical role that accesses it, and both belong in what the cheat sheet calls an authorization matrix [6]. The design choice becomes obvious inside that matrix. If the rules are read per bidang, the test accounts must exist per bidang. A single superadmin collapses every role into one point, leaving no line to inspect.
A fair objection comes up here: isn't a uniform admin account exactly the pattern OWASP warns about? A07:2021 calls an application weak when it permits default or well-known passwords, and its instruction is blunt: do not ship or deploy with any default credentials, particularly for admin users [2]. My answer lives where the account is created. This one is never shipped anywhere. It is produced by a command someone runs by hand, only against the development database, and the users table never travels through the mechanism that synchronizes databases between environments.
There is also an older rule in the project that looks like a contradiction: accounts created for development must never be made admin, because the automated login used by end-to-end tests should stay role-limited. The two rules serve different jobs. Automated login only needs a valid identity to get through a form, while an authorization check needs a different privilege per bidang. The dangerous thing is not a development admin account, but an admin account that can appear in the wrong environment.
Three Keys on the CLI That Writes Admin Rows
The create-bidang-users command exists because writing admin rows is not work that a script may perform whenever it feels like it. Three guards surround it, in an order that is not accidental.
First, the environment key. The command runs under a single condition:
APP_ENV=development go run ./cmd/create-bidang-users
The value of APP_ENV is read exactly as it stands and must equal development; a variable that is empty, missing, or misspelled stops the command before it touches the database. Go itself distinguishes an empty value from a variable that is not set at all, so the rule "no configuration means refuse" can be written without guesswork [4]. The twelve-factor idea points the same way: configuration lives in environment variables and is never grouped into named "environments", because that grouping gets more fragile as deployments multiply [3].
Then the row allowlist. The tool creates exactly one admin account for each registered users.bidang assignment and refuses to touch any account whose role is not admin. The boundary sits on the data that may change, not on the intention of whoever runs it.
Finally, the self-check. Every account it creates is read back from the database: the password hash matches, the role is right, the bidang is right, and the verified flag is set. bcrypt gives a decisive answer for that job. CompareHashAndPassword returns ErrMismatchedHashAndPassword when the password and the hash do not match [5], so a failed verification reads as a failure instead of an assumption.
The last proof is not a log line but a login. The public login endpoint accepts role user only, so staff enter through the CMS login and receive a JWT together with their permission set. I ran one account produced by the command against that CMS login, the JWT came back carrying its permissions, and the manual check could continue: enter with a bidang account, open the menu that should be available, then try a menu from another bidang and watch it be refused.
Since that day I judge one-off tools differently. Not by how few rows they touch, but by what they do when conditions do not match. A tool that stops on its own is the one I am willing to run again.
Sources:
Sources
[1] https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
[2] https://owasp.org/Top10/2021/A07_2021-Identification_and_Authentication_Failures
[3] https://12factor.net/config
[4] https://raw.githubusercontent.com/golang/go/master/src/os/env.go
[5] https://raw.githubusercontent.com/golang/crypto/master/bcrypt/bcrypt.go
[6] https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Testing_Automation_Cheat_Sheet.html