Skip to content

Enum and Slug: The Two Identifiers That Keep a Gate Shut

Adityo Guni Waluyo

The moderation gate never rejected anyone: an unmapped enum simply compared false forever, and read-only won by default.

TL;DR

A read-only badge appeared on all reviews because the frontend passed raw module enums like PARIWISATA into a gate expecting slugs like wisata, so the check silently failed and defaulted to read-only. The fix centralized enum-to-slug mapping in one TypeScript Record. Server-side, guardModule now enforces deny-by-default checks with tests locking in 403s and 404s.

Opening the reviews moderation list in the KotaPortal admin panel shows a read-only badge on every row, even for a staff member whose bidang owns one of the modules under review. The interface appears to lock access for no reason at all.

The first guess pointed at the permission service. Maybe authorization stopped validating roles, or privileges dropped unexpectedly somewhere in the database layer. The authentication logs showed nothing unusual.

The backend turned out to be fine. The defect lived in the frontend: the interface fed the raw uppercase module enum, such as PARIWISATA, into a gate that expects content slugs, such as wisata. The comparison was always false and failed silently, so the interface fell back to read-only mode as its safe default. No error was thrown anywhere.

An identifier mismatch between layers was the root cause, and it produced a security-shaped symptom without being a security bug. The fix landed on two levels.

Centralized Mapping in the Interface Layer

The first step was a single mapping table built with the TypeScript Record utility in the RBAC module. MODULE_TO_SLUG converts each module enum to its content slug. TypeScript defines Record<Keys, Type> as a type whose property keys are Keys and whose property values are Type [4], which keeps the conversion compile-time safe: every valid enum has exactly one slug pair. Enums without a public menu map to an empty string, so an unmapped module can never accidentally pass the gate.

Every view now reads from this one map. Before the change, each component kept its own conversion logic, and the reviews view simply forgot to convert. One dictionary, one place to update when a module is renamed.

The Resource-Level Guard on the Server Side

A correct interface is not a security boundary, so the second step sits in the service layer. guardModule runs before any moderation action: it first checks whether the requester holds the global scope. If not, it looks up the entity module for the review id through a JOIN, then compares it against the modules the requester's bidang may manage.

All four moderation actions pass through the same guard, so no second action offers a shortcut. An unknown bidang gets no neutral treatment: the request is rejected with 403. This is the deny-by-default principle from OWASP: an application cannot remain neutral when no rule matches, it must explicitly decide to deny or permit [2]. Broken Access Control is ranked the most concerning web security vulnerability in OWASP's 2021 Top 10 [2].

Authorization logic like this is not something a passive DAST scan can validate; unit and integration tests must carry that weight [1]. The scope matrix tests lock the behavior down: a narrow-scope actor reads across modules but receives 403 when moderating outside its bidang, and a missing id maps to 404 so the existence of data in another module never leaks.

The pattern generalizes. When two layers must agree on an identifier, keep exactly one conversion point and fail closed on anything unmapped. A badge that stays read-only is annoying; the same mismatch in the write path would have been a vulnerability.

Sources

[1] ZAP Baseline Scan documentation

[2] OWASP Authorization Cheat Sheet

[4] TypeScript Handbook: Utility Types

Related articles