An Empty Scope Is Not Everything: Fail-Closed Choreography
An empty scope column used to mean ALL for legacy tokens. The four-step choreography that flipped the middleware to deny 403 BIDANG_UNSET.
TL;DR
An empty bidang scope was silently treated as ALL, a classic failing-open bug that let unscoped accounts pass authorization. Retiring it took choreography: backfill data, kill the shim's producers, prove a census of zero empty rows, then flip middleware to deny with 403 BIDANG_UNSET. Two denial layers and one documented exception (system actor userID 0) make the deny-by-default contract auditable.
Request logs on an administrative route showed a pattern that should not exist: an account whose users.bidang column is empty was never rejected. The missing value was treated as the broadest scope, ALL. The question that started the change sounds simple: what does an unset scope mean? Absence of data, or full trust?
The first guess: removing an implicit grant is a one-line change. Delete the fallback and the system is safe.
The Shim and Its Retirement Choreography
Empty-means-ALL was a compatibility shim: tokens issued before backfill migration 000057 had to keep working. Retiring it took choreography in a fixed order, not a single commit. Ship the backfill migration until every row holds a valid value; remove the shim's last legitimate producer, so nothing generates new empty values; run a live census proving zero rows with an empty bidang; only then flip the middleware to deny with 403 BIDANG_UNSET. Authorization semantics turned out to be release engineering.
An easily missed detail: this commit shipped no migration at all. The census had already counted zero empty rows, so the semantic change lived entirely in code. Characterization tests that once locked the old behavior were flipped to the new contract, additional fail-closed tests were added, and the OpenAPI 403 descriptions were extended so that the BIDANG_UNSET code is documented for API consumers.
Descending to a Less Secure State Has a Name
An empty scope is absence of data, not a universal grant. Treating it as ALL falls into the pattern CWE names Not Failing Securely, or failing open: when the product encounters an error condition or failure, a poor design falls back to a state less secure than the available options, such as the most permissive access control; the consequence is that intended access restrictions can be bypassed [1]. The umbrella category is improper authorization, where the product does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action [3].
Missing data also has several faces. An empty string, NULL, and a row abandoned by a half-finished onboarding all encode the same thing, no value, and each has a different producer in the system's history: an old default from before a migration, a manual SQL adjustment, an account created half-way. The live census that becomes the decisive step has to count all of them at once, not just one variant. Without that measurement the middleware flip runs on belief, and belief is not an auditable foundation.
The remediation follows the rule OWASP formulates for authorization: adopt a deny-by-default mentality during initial development and whenever new functionality is exposed, and prefer explicit configuration over relying on framework or library defaults [2]. For this case it condenses to a one-sentence contract: the scope must hold an explicit value, ALL or a bidang code, and it must never be inferred from absence of data.
Two Layers of Denial, One Deliberate Exception
Enforcement runs on two layers. The first sits in middleware: ResolveBidang rejects every admin route with 403 BIDANG_UNSET when bidang is empty, and also when loading the value fails. The second sits in the service layer: DenyUnscopedActor re-checks every real actor, with one exception stated openly in the code, the system actor userID 0 keeps ALL for internal operations that represent no human. Two layers mean a single gap on one path does not immediately become access.
There is also the question of who may hold an exception. The system actor userID 0 does need the full range for scheduled jobs and background work, and that exception is written in the code comments as a documented decision, not a side effect discovered later. Separating real actors from the system actor at the service level keeps the exception from being borrowed: DenyUnscopedActor compares bidang against the actor identity already attached to the request, so a real account without a bidang has no silent path to ALL. An exception with a name, a comment, and a test is a contract; a silent exception is audit material.
An earlier article covered failing closed when the in-use census errors, the error side where the answer is 503. This case is its sneakier sibling: the data loads fine, it is empty, and a system that reads empty as everything grants it anyway. Both deny; the doors are just different.
Sources
[1] CWE-636: Not Failing Securely (Failing Open)
[2] OWASP Authorization Cheat Sheet
[3] CWE-285: Improper Authorization