Skip to content

A Guard That Refuses Before the Payload Is Read

Adityo Guni Waluyo

The organisation module finally has an owner: one guard in the service layer, one fixed 403, and one sidebar group that follows the owner.

TL;DR

Commit 96e93ae adds a guard function to check write permissions. The function runs before validation and returns an error for unauthorized bidang. A test ensures the guard's position and prevents unauthorized repository access.

A guard that refuses before the payload is read

The trigger was one row in the module ownership map: the row named Kelembagaan had no owning bidang. As long as that row stayed empty, no place in the system could answer who is allowed to write the module. Commit 96e93ae closes the gap, but not by sprinkling permission checks everywhere. The change is one small function in the service layer plus one navigation item that moved.

The service file of the organisation module now exposes a function called guardWrite. It accepts exactly two states: a caller from the sports bidang, or a global actor. Anything else is refused with a prepared error. What matters is not the body of the function but the position of its call. On the module's write operations the guard runs first, before the validation routine ever touches the payload.

var ErrForbidden = errors.New("kelembagaan hanya dikelola bidang olahraga")

// guardWrite runs BEFORE validation and before any repo access.
func guardWrite(bidang string) error {
	if bidang == scope.Olahraga || scope.IsGlobal(bidang) {
		return nil
	}
	return ErrForbidden
}

func (s *Service) Create(ctx context.Context, bidang string, req UpsertRequest) (*OrganizationResponse, error) {
	if err := guardWrite(bidang); err != nil {
		return nil, err
	}
	if err := validateUpsert(&req); err != nil {
		return nil, err
	}
	// ... sisanya tidak pernah tercapai untuk pemanggil yang ditolak
}

The ordering is not cosmetic. [4] The principle of deny by default requires the access decision to be made up front, and [1] an access check is only effective when it lives in server-side code the caller cannot modify. Run validation first and a forbidden caller can still probe for different error messages by mutating the request body. Such a caller learns the shape of valid data from messages that were never meant for them. Putting the guard in front closes that channel: the payload is never parsed, so the response carries nothing to learn from.

The admin handler matches the error with errors.Is from [3] and returns one fixed sentence: only the sports bidang may manage Kelembagaan. The caller's bidang is not echoed, the module is not restated, and the submitted payload never appears in the response.

The status code itself sits in the right place. [6] A 403 means the server understood the request but [2] refuses to fulfil it, and the refusal is about the caller's authority, not about the request's contents. Unlike a 400, which marks a malformed payload, a 403 stays a 403 even when the body really is broken. That is exactly what the unit test pins down: an invalid payload plus a forbidden bidang still yields the forbidden error rather than a validation error.

On the first read, the reasonable guess was that the guard sat after validation, since that is the more common writing order. The code does the opposite. The consequence becomes clear on a second pass: with the order reversed, a caller from another bidang could send an empty body and receive a validation message, then iterate toward a payload that parses. Deny by default leaves no room for that negotiation.

A test that counts repository calls

The service unit test file adds a repoCalls counter to the mock repository. Every write path bumps it, from row lookups to member writes. After the guard refuses, the test asserts the counter is still zero.

func TestService_WriteGuard_Bidang(t *testing.T) {
	for _, bidang := range []string{"budaya", "", "pariwisata", "kepemudaan", "ekraf"} {
		_, err := svc.Create(ctx, bidang, req)
		if !errors.Is(err, organisasi.ErrForbidden) {
			t.Errorf("bidang %q: want ErrForbidden, got %v", bidang, err)
		}
	}
	if repo.repoCalls != 0 {
		t.Errorf("repo tersentuh %d kali pada jalur tolak", repo.repoCalls)
	}
}

That assertion is the most useful part of the commit. It turns the guard's position into a property that can fail, not just an intention in the author's head. Anyone who later moves the guard below validation will not get a vague error message; the test will show that the repository was already touched. An ordering rule that used to live only in comments and commit prose now has its own keeper.

One boundary deserves a note: the test uses an in-file mock, so it proves the call order inside the service, not the behaviour of the database behind it. If the guard is dropped from the handler, the HTTP path still needs its own test to catch that the error no longer maps to a 403.

One ownership map, three consumers

The second half of the commit lives in the frontend. The Kelembagaan navigation item in the admin layout used to carry no module marker, so it was always treated as cross-bidang and rendered with the read-only eye for everyone. After this commit the item carries a sports module marker, so for a sports account it lands in the "your bidang" group, while other bidang still see it as read-only.

The navigation group that held a single item, Menu Navigasi, disappears and its item joins the System group. The count drops by one. [5] Navigation landmarks are meant to be used sparingly and each surviving landmark should carry its own label, so removing a group that carried one link reduces noise without losing anything.

What makes the two halves one story is their source of data. The module ownership map now feeds three things at once: the write guard in the service, the 403 the handler emits, and the group the item lands in. Before this commit the sidebar could promise write access the API refused, and no test kept the two sides in sync. An admin navigation end-to-end test closes that hole with a simple pair of assertions: the Kelembagaan link is visible for a sports account, and the accessible name with the read-only marker occurs exactly zero times. That marker is what separates an owned item from a cross-bidang one, so the assertion also locks the ownership decision in place.

The lesson travels to other projects: when one ownership decision must be read by more than one part of a system, do not keep two lists. Keep one map and let the guard, the status code, and the navigation read the same map. Adding a second permission check is quick, but nothing will tell you when the two begin to disagree.

Sources

Related articles