A Rental Package CRUD That Knows Its Scope
A scope-aware rental package CRUD: nil versus empty slice for fail-closed, five domain errors, one mapError translating them to HTTP.
TL;DR
Rental package CRUD centralizes authorization in a service layer instead of scattered handler checks, returning five domain errors mapped to precise HTTP codes. A sentinel pair makes it fail-closed: nil means global access, empty slice means deny-all. Tests verify status codes and deny-by-default behavior, so unrecognized departments never slip through.
**Meta (untuk sistem):** A technical exploration of how centralizing scope contracts and fail-closed sentinels prevents unauthorized access in a rental package CRUD system. **Slug (untuk sistem):** rental-package-scope-crud-en On the KotaPortal admin screen, two staff members from different departments view the same rental package table. One manages only PARIWISATA module packages, while the other handles EKONOMI_KREATIF. A service type with a NULL category is cross-departmental, meaning only global actors should manage it. Seeing the same list without proper boundaries creates an immediate risk of unauthorized modifications.
The Access Problem in One List
Scattered if-checks in HTTP handlers are fragile. When authorization logic is duplicated across multiple endpoints, a single missed condition exposes the entire module. The fix centralizes the scope contract in a dedicated service layer. This layer evaluates the actor's permissions against the requested resource and returns one of five specific domain errors. A single mapError function in the handler then translates these domain errors into standardized HTTP responses, keeping the controller clean and the security boundary absolute.
The Sentinel Pair Behind Fail-Closed
The core of this design relies on how the system interprets empty states. The listModules function returns nil to indicate no filter (global access) versus an empty slice to indicate deny-all (unknown bidang). The repository layer checks this sentinel pair and answers with an empty list before ever touching the database.
// nil = no filter; empty = deny-all fail-closed
if q.Modules != nil && len(q.Modules) == 0 {
return "", nil // answer empty without touching the DB
}
This fail-closed mechanism ensures that an unrecognized department identifier never defaults to a permissive query.
Translating Service Errors to HTTP Responses
The service layer maps five distinct errors to precise HTTP status codes: ErrInvalidInput and ErrServiceTypeNotFound to 400, ErrForbidden to 403, ErrNotFound to 404, and ErrInUse to 409. A 403 Forbidden status indicates the server understood the request but refused to authorize it, directly tied to insufficient permissions [1]. A 409 Conflict occurs when attempting to delete a package still referenced by active submissions. The MySQL RESTRICT rule rejects the parent-row delete [5]. The service detects this engine error and returns ErrInUse. The handler then answers with a 409 CONFLICT, advising the user to deactivate the package instead of deleting it, because 409 marks a conflict with the current state of the resource [2].
Verification and Deny-by-Default
Automated tests assert both the correct HTTP status codes and the absence of leaked input data in the response bodies. Security is not an afterthought. According to the OWASP Authorization Cheat Sheet, when no rule matches, the application must still make a definitive decision, which should always be to deny access [4]. This deny-by-default posture ensures that every unverified action is safely rejected before it reaches the database.
Sources