kasus /

Who gets in: roles, tenants, and the audit trail

memeriksa

16 Oktober 2026 · go · sqlite · buka demo ↗

Demo langsung

Layar penuh ↗

Demo berjalan sebagai aplikasinya sendiri. Kalau gagal dimuat di sini, berarti memang sedang mati — buka layar penuh untuk memastikan, atau kembali lagi nanti. Penjelasan di bawah tetap berdiri sendiri.

buka demo ↗

Problem

Every multi-tenant service eventually answers the same question under pressure: this identity wants to do this thing to that object — allow or deny? I have watched this check grow from a three-line if into a maze of role flags, tenant comparisons, and special cases scattered across handlers, until nobody could say with confidence what a given key could open. The failure mode is silent: the service keeps returning 200s while one confused check leaks one tenant's documents to another tenant's admin, and nothing records that it happened.

Context

The demo models the smallest system where this problem is real: 3 roles (admin, editor, viewer), 2 tenants (acme, globex), tenant-scoped documents, and 3 actions (read, write, delete). That combination already produces 36 distinct attempts (3 × 2 × 2 × 3), and I wanted all 36 to be executable live, each returning a verdict plus a stable reason code, with every attempt — allow or deny — appended to an audit log. The constraint that shaped everything: tenant isolation is absolute. No identity touches another tenant's data, period — not even an admin. I made that a deliberate product rule rather than an emergent property, because "admin can read across tenants for support" is exactly how leaks get designed in.

Alternatives Considered

The first option was per-handler checks: each endpoint compares the caller's tenant against the resource's tenant and then switches on the role. I rejected it because the policy would exist in N copies, and the demo's whole point is that the matrix is enumerable — with per-handler checks there is no single place to point at and ask "what are the rules?"

The second option was a policy-query sidecar in the style of Open Policy Agent: ship the attempt to an external evaluator over HTTP. That is the right answer at real scale, with versioned policy bundles and decision logs. But for a disposable demo it doubles the moving parts (second process, network hop, its own failure modes) while the policy itself fits in one function. I noted the seam, though: my decision function takes plain values and returns plain values, so it could be lifted behind an RPC boundary later without changing its contract.

The third option was role-based access control with a permissions table in SQLite — roles mapped to permission rows, joined at request time. That is flexible and data-driven, but for 3 roles and 3 actions it hides a trivially enumerable matrix behind a schema, and it invites the demo visitor to wonder whether the database or the code is the source of truth. I chose the opposite: the code is the policy, the database is only the audit trail.

Solution

I put the entire policy in one pure function, Decide, evaluated by a thin HTTP handler. The handler decodes the attempt, calls Decide, appends the verdict to the SQLite audit log, and returns the verdict plus the stored entry as JSON. GET /api/audit serves the newest entries first (default 30, capped at 100). The UI is five dropdowns — role, your tenant, target tenant, document, action — an Attempt button, a large allow/deny verdict showing the human message and the reason code, and the audit table refreshing underneath. Unknown enum values deny with a named reason (for example unknown_role) instead of returning 400; only malformed bodies are 400s, because the demo is more instructive when a bad key produces a visible denial than when it produces an error page.

The ordering inside Decide is the design: input validation first, the tenant-isolation check second, and the role matrix last, so the strongest rule can never be skipped by the weaker ones:

func Decide(role, tenant, resourceTenant, resource, action string) (allowed bool, reason, message string) {
	role = norm(role)
	tenant = norm(tenant)
	resourceTenant = norm(resourceTenant)
	action = norm(action)
	if !contains(Roles, role) {
		return false, ReasonUnknownRole, "unknown role — no permissions granted"
	}
	if !contains(Tenants, tenant) {
		return false, ReasonUnknownTenant, "unknown tenant — identity not recognized"
	}
	if !contains(Tenants, resourceTenant) {
		return false, ReasonUnknownResTenant, "unknown resource tenant — target not recognized"
	}
	if !resourceName.MatchString(resource) {
		return false, ReasonInvalidResource, "invalid resource name"
	}
	if tenant != resourceTenant {
		return false, ReasonCrossTenantDenied,
			"cross-tenant access denied: " + tenant + " identities cannot touch " + resourceTenant + " data"
	}
	switch role {
	case "admin":
		return true, ReasonAdminAllow, "admin: full access within own tenant"
	case "editor":
		if action == "delete" {
			return false, ReasonEditorNoDelete, "editor: cannot delete — admins only"
		}
		return true, ReasonEditorAllow, "editor: read and write within own tenant"
	default:
		if action == "read" {
			return true, ReasonViewerAllow, "viewer: read-only access within own tenant"
		}
		return false, ReasonViewerReadOnly, "viewer: read-only — write and delete denied"
	}
}

I verified the matrix by sweeping all 36 combinations against the running demo over loopback: 12 allowed, 24 denied, in 807 milliseconds wall-clock for the whole sweep including curl overhead. A single POST /api/attempt consistently answers in about 8 milliseconds (five samples: 7.7 to 10.4 ms), which is exactly what I would expect from one pure function plus one SQLite insert. The seed audit log holds 4 rows demonstrating both outcomes, and POST /reset restores those 4 rows idempotently. Six Go tests — the contract smoke plus a full-matrix test, an audit-contents test, and input-validation tests — pass with go test -race.

Trade-offs

A pure function as policy is a ceiling as much as a floor. There is no per-resource ownership (no "editors can modify their own drafts"), no deny-overrides, no delegation, and no time-bounded grants — the matrix answers only what role × tenant × action permits. The moment a product needs "user X shares document Y with user Z", this design has to grow into per-object grants, and the function signature changes with it. I accepted that because the demo's claim is narrower: tenant isolation plus a role floor, stated in one place.

The audit log is a plain SQLite table with no tamper protection — anyone with the database file can rewrite history. For a disposable demo that is fine (the data is reseeded on reset anyway), and I say so openly in the UI copy. The reason codes are strings, not an enum type shared between Go and the frontend; the UI treats them as opaque display values, which keeps the contract loose but means a typo in a new code would only be caught by tests, not by the compiler. The behavior test pins every current code, which is the backstop I chose.

Production Notes

Nothing here survives contact with production unchanged, and the demo is honest about that. First, the audit trail would need append-only guarantees — write-once storage or hash-chained rows — plus a retention policy and a pipeline into whatever SIEM the org uses; a local SQLite table is where decisions go to be forgotten. Second, a real decision path needs observability I deliberately omitted: counters per reason code (a spike in cross_tenant_denied is either an attack or a broken client, and both deserve an alert), plus request-scoped logging without PII. Third, latency: 8 ms per decision is invisible in a demo but would dominate a hot path at scale, which is why production evaluators cache compiled policy and keep the check in-process rather than behind a network call. Finally, the failure mode matters more than the happy path — if the policy source is unreachable, the system must fail closed (deny by default) and say so loudly, whereas my demo's policy cannot be unreachable because it is a function call. Anyone promoting this pattern should start from that question: when the thing that says "no" breaks, what does the system do?