~/projects/syskey-rbac

Server-Authoritative RBAC

The permission system behind a live game, built on the assumption that every request might be a forgery.
"The client is untrusted territory. It REQUESTS; the server AUTHORIZES and ACTS. A client-side permission check is UX, never security."

The trust boundary

When a remote fires, the engine hands the server the real player as an argument that can't be spoofed. Everything else in the payload is attacker-controlled. So the rule is exact: trust the identity, validate everything else.

untrusted · player's machine

client fires a request

Remote:FireServer(
  "kick", target
)

any action, any payload: all attacker-controlled

trust boundary

server · authoritative

  1. 1engine injects the real Player: unspoofable identity
  2. 2validateShape(req): is this even a possible request?
  3. 3can(actor, capability): the actual allow / deny
  4. 4perform the action: only if it returned true

Two axes, not one ladder

Most permission bugs come from modelling privilege as a single number. This system keeps paid perks and staff powers on separate axes and unions them per player.

perk tier · paid, inherits upward

  1. user
  2. vip
  3. vip+
  4. mvp
  5. mvp+

staff track · separate, never purchasable

  1. mod
  2. admin
  3. owner

A real player carries one perk tier and, optionally, one staff role. The resolver unions both; a moderator isn't a superset of mvp+, so privilege is a set of named capabilities, never a single number.

In the code

The gateway: deny vs. impossible

Every privileged request goes through one function. First it checks whether the payload is even shaped like something a real client could send. A well-formed request that's denied is a WARN. Your own UI or a lagged replay can cause it. A structurally impossible payload is an ALERT: no legitimate client produces it, so it's high-confidence forgery. And notice it only logs and flags. It never bans. The deny already won; auto-banning would false-positive on real players.

PermissionService.lua · authorize()
-- Is the request even SHAPED like something a real client sends?
-- A false here means "no legitimate client produced this".
local function validateShape(req: AuthRequest): (boolean, string?)
	if typeof(req.actor) ~= "Instance" or not req.actor:IsA("Player") then
		return false, "actor is not a Player"
	end
	if type(req.capability) ~= "string" then
		return false, "capability is not a string"
	end
	if req.requiresTarget then
		if typeof(req.target) ~= "Instance" or not req.target:IsA("Player") then
			return false, "action requires a Player target but none was given"
		end
	end
	return true, nil
end

function PermissionService.authorize(req: AuthRequest): boolean
	-- Identity comes from the ENGINE-PROVIDED actor only, never the payload.

	-- 1) Shape validation -- forgery detection.
	local shapeOk, reason = validateShape(req)
	if not shapeOk then
		forgeryFlags[actorId] = (forgeryFlags[actorId] or 0) + 1
		AuditLog.record({
			severity = AuditLog.Severity.Alert,
			event    = "forged_payload",
			-- + actorId, capability, and a "forgery flag #N" detail
		})
		return false
	end

	-- 2) The actual permission check.
	local allowed = PermissionService.can(req.actor, req.capability)

	-- 3) Permitted -> INFO; denied-but-well-formed -> WARN (never punished).
	AuditLog.record({
		severity = allowed and AuditLog.Severity.Info or AuditLog.Severity.Warn,
		event    = allowed and "authorized" or "denied",
	})

	return allowed
end

Inheritance, resolved once and fail-closed

A role's capabilities are the union of everything up its inheritance chain. It's resolved once and cached, guarded against cycles, and an unknown role returns nil, which means you get nothing. The safe default is no permissions, never a crash and never an accidental grant.

PermissionService.lua · resolveRole()
-- Unions capabilities up the inheritsFrom chain. Resolved once and cached;
-- cycle-guarded; unknown role -> nil (you get nothing -- fail closed).
local function resolveRole(roleName: string): ResolvedRole?
	local cached = resolvedCache[roleName]
	if cached then return cached end

	local def = Roles.Definitions[roleName]
	if not def then
		return nil -- unknown role grants nothing
	end
	if resolving[roleName] then
		warn("cyclic inheritance at role '" .. roleName .. "'")
		return nil
	end
	resolving[roleName] = true

	local capabilities, perks = {}, {}
	if def.inheritsFrom then
		local parent = resolveRole(def.inheritsFrom)
		if parent then
			for cap in parent.capabilities do capabilities[cap] = true end
			for k, v in parent.perks do perks[k] = v end
		end
	end
	for cap, granted in def.capabilities do
		if granted then capabilities[cap] = true end
	end
	for k, v in def.perks do perks[k] = v end

	resolving[roleName] = nil
	resolvedCache[roleName] = { capabilities = capabilities, perks = perks }
	return resolvedCache[roleName]
end

A purchase is a claim until the server confirms it

The client never tells the server what it bought. The server asks the marketplace directly, and every check is wrapped so it fails closed: a dropped network call leaves the player where they were instead of wrongly upgrading them. A purchase can raise your tier; it can never lower it.

PermissionService.lua · syncPurchasedTier()
-- A purchase is a CLAIM until the server confirms it. We never trust a client
-- saying "I bought MVP" -- we ask MarketplaceService, server-side.
-- Each check is pcall'd and fails CLOSED: a network error upgrades no one.
function PermissionService.syncPurchasedTier(player: Player)
	for passId, tierName in Roles.GamePassToTier do
		local ok, owns = pcall(function()
			return MarketplaceService:UserOwnsGamePassAsync(player.UserId, passId)
		end)
		if ok and owns then
			-- upgradeTierIfHigher: a purchase can never DOWNGRADE a player
			upgradeTierIfHigher(player, tierName)
		end
	end
end

A sink you can repoint without touching a caller

Every decision flows through one entry point. Today it prints structured JSON you can grep for ALERT and persists each record under a unique key. Shipping logs to a real external database later is a single HTTP POST added here; nothing that calls record() ever has to know the destination changed.

AuditLog.lua · record()
-- One record() entry point. The destination can change with no caller knowing.
function AuditLog.record(entry: LogEntry)
	entry.timestamp   = os.time()
	entry.serverJobId = game.JobId

	-- 1) Structured stdout line -- the parseable surface (grep for "ALERT").
	local ok, encoded = pcall(function() return HttpService:JSONEncode(entry) end)
	if ok then print("[AUDIT] " .. encoded) end

	-- 2) Persist under a unique key, fire-and-forget so it never blocks.
	if store then
		local key = nextKey()
		task.spawn(function()
			pcall(function() store:SetAsync(key, entry) end)
		end)
	end

	-- 3) (Future) external DB: a single pcall'd HttpService:PostAsync here is
	--    the ONLY change needed. Nothing else would know.
end

the restraint

The gateway logs and flags, but it never bans. The security goal, the action not running, is already met by the deny. Banning is a separate decision for a human or a higher-confidence pipeline. Auto-banning on a denied check would false-positive on my own players and my own testing.