Quick navigation

Go to

Networking & access

Public identity, server commands, and ACL

Exporting a public identity, whitelisting a player, and declaring restricted server commands behind the access control list.

7 min read1,444 wordsMarkdown
On this page4 sections

Register server commands with RegisterCommand and control access through ACL rules. Commands entered in the in-game developer terminal (Β²) are forwarded over the authenticated session when they are not handled locally.

Use this guide to whitelist a player, restrict a command to specific people, and understand what identity the server actually trusts.

Exporting your public identity

In the Open77 terminal:

identity.dump

The reply gives the absolute path and the SHA-256 fingerprint:

OK identity_public_dumped path=".../red4ext/plugins/Open77/exports/identity-<uuid>.json" fingerprint=sha256:...

The file holds the userId, the P-256 public key, its fingerprint, and a ready-to-copy aclPrincipal object. It never holds the private key, the DPAPI blob, the session proof, or the %LOCALAPPDATA%/Open77/identity-v1.dat file. Do not copy that last one to the server.

Whitelisting a player

The server loads the relative file configured in server.jsonc:

"accessControl": {
  "file": "acl.jsonc"
}

Copy the exported aclPrincipal value into principals:

{
  "version": 1,
  "principals": [
    {
      "name": "owner",
      "userId": "00000000-0000-0000-0000-000000000000",
      "publicKey": "base64...",
      "permissions": [
        "command.loot.*"
      ]
    }
  ]
}

Restart the server, or type acl.reload in its administration console. acl.list reports the path actually loaded; acl.check <playerId> <permission> is there for diagnosis.

The comparison is made on the 64 bytes of public key certified during the handshake. If the entry also carries a userId, that must match too. The displayed nickname and the temporary playerId never take part in authorisation.

Changing the ACL from a resource, at runtime

The read half above is the whole story for a server whose rights are decided once and edited by hand. A job or gang model is not that server: "this player is on shift as police dispatch" is a fact that changes ten times an evening, and an operator should not be in the loop for any of them.

Open77.acl therefore has a write half β€” native-parity row A12, FiveM's add_ace, add_principal and remove_ace.

permissions {
    "acl.read",                -- the existing read half
    "acl.grant:job.police.*",  -- may hand out rights under this prefix
    "acl.define:job.police.*", -- may define roles whose rights are under it
}
local userId = Open77.players.identity(source).userId

assert(Open77.acl.definePermission("dispatcher", { "job.police.dispatch" }))
assert(Open77.acl.addRole(userId, "dispatcher"))
assert(Open77.acl.grant(userId, "job.police.impound"))

-- Off shift.
assert(Open77.acl.removeRole(userId, "dispatcher"))
assert(Open77.acl.revoke(userId, "job.police.impound"))
Function Capability Meaning
Open77.acl.grant(userId, permission) acl.grant: Add one permission.
Open77.acl.revoke(userId, permission) acl.grant: Take it back.
Open77.acl.addRole(userId, role) acl.grant: Put the user in a role.
Open77.acl.removeRole(userId, role) acl.grant: Take them out of it.
Open77.acl.definePermission(role, permissions) acl.define: Define, retune, or (with nil) delete a runtime role.
Open77.acl.grants(userId) acl.read What the runtime layer holds for one user.

Every writer answers true, or nil, reason. A grant takes effect on the very next isAllowed β€” there is no reload and no restart, because the swap is in-process.

The subject is a userId, never a playerId

The read half takes a playerId because it asks a question about a seat that is occupied right now. The write half takes the master-issued userId, the string Open77.players.identity(playerId).userId answers, because a grant outlives the session that earned it and a seat number does not. It also means you can promote somebody who is offline.

The delegation scope is the security model

There is no bare acl.grant or acl.define. The capability is the pattern, and a resource may hand out exactly what is inside it:

permission "acl.grant:job.police.*"

Open77.acl.grant(user, "job.police.dispatch")  -- true
Open77.acl.grant(user, "command.kick")         -- nil, "permission_not_delegable"
Open77.acl.grant(user, "*")                    -- nil, "permission_not_delegable"

This is the answer to the question A12 exists to raise: may a resource grant a permission it does not itself hold? It may grant whatever the operator wrote into its manifest, and nothing else β€” and the operator reads that delegation off the manifest before installing the resource, the same way runtime.commands and resources.control are readable there.

acl.grant:* is not a wildcard scope. It is dropped, and a resource whose every entry was * is answered scope_not_delegable. Owner-equivalence comes from acl.jsonc or it does not come at all.

Four more refusals close the escalations that follow from the first:

Reason What it stops
role_exceeds_scope addRole is checked against the role's entire reach, not its name. Without it a resource scoped to job.police.* would collect command.* by adding your moderator role β€” the same escalation one indirection later.
operator_principal No runtime call may name a subject your document binds to * or to the owner role, in either direction.
role_defined_by_operator A role you defined in acl.jsonc is yours. A resource cannot retune what it means.
role_reserved owner and admin are the server's, under any scope.

The capability that gates granting is a manifest permission, and manifest permissions are not in the ACL. So there is no string a resource can grant to anyone, itself included, that yields acl.grant. That one is structural rather than a check.

Runtime grants live in a sibling, not in your file

Writes go to acl.runtime.jsonc, next to acl.jsonc, written atomically through a temporary so a crash mid-write cannot truncate it. Your document is never touched.

That is a security property, not tidiness. The overlay is additive only β€” it has no subtractive form at all β€” so no sequence of calls a resource can make, not even a compromised one holding the widest scope you can write, takes a right away from your document. A revoke that names one of your grants answers operator_grant instead of appearing to work. Had runtime grants been merged into acl.jsonc, a resource able to write that file would also be able to rewrite the owner's principal, and the whole model would rest on the refusals above being exhaustive rather than on the layout making the attack unrepresentable.

Three consequences worth knowing:

  • Effective rights are the union. isAllowed checks your document first and the overlay second, and the overlay can only ever say yes.
  • A complete reset is rm acl.runtime.jsonc. One file, machine-owned, with a header saying so. Nothing of yours is caught in it.
  • The overlay cannot bind a public key. A runtime grant binds the master-issued user id only β€” the same strength as a userId-only principal in your document, which it already permits, and it keeps anything key-shaped out of a machine-managed file. A runtime role that collides with one you later define in acl.jsonc is dropped rather than merged: yours wins.

onAclChanged

Every accepted mutation publishes one host-wide event, on a reserved name a resource cannot forge.

AddEventHandler("onAclChanged", function(revision, by, operation)
    -- revision: a counter, 1 per accepted mutation
    -- by:       the resource that caused it
    -- operation: "grant" | "revoke" | "addRole" | "removeRole" | "definePermission"
end)

It names neither the subject nor the permission, and that is deliberate: the event reaches every running resource, so a payload carrying either would tell the whole server what the ACL says while bypassing acl.read. A listener that only needs to drop a cache has everything it needs in the revision; one that is entitled to the detail calls Open77.acl.isAllowed, which is gated.

One boundary to know: the event is published by the resource's own VM, so a grant made in a resource's top-level chunk β€” before the host has activated that VM on the event bus β€” persists and takes effect, but publishes nothing. Grant from a thread or an event handler if a listener has to see it. The same rule governs Open77.environment.publishChange.

The Warden panel and the acl.reload console command write and reload acl.jsonc itself and do not publish onAclChanged; the event is about the runtime layer.

Declaring a restricted command

RegisterCommand(name, handler, true) declares a command that only a player holding the ACL right command.<name> may run; the server refuses the others before the handler runs, and the dedicated console is always allowed. Grant the right in acl.jsonc (command.settime, or command.*), or let a resource grant it at runtime as described above. The refusal is reported to the caller through open77:command:result; a resource that wants to phrase its own refusal in chat registers the command unrestricted and rechecks Open77.acl.isAllowed(source, "command.<name>") itself, as below.

Reading effective rights from a server resource

Declare permission "acl.read" in the resource manifest, then use:

local allowed, error = Open77.acl.isAllowed(source, "command.admin.moderate.kick")
local roles, error = Open77.acl.roles(source)

These APIs are server-only and read the compiled ACL for an authenticated active session. isAllowed accepts a concrete permission, not a wildcard; it returns false for an absent resolver/session. Invalid IDs (including 0), invalid names and missing manifest capability return false, reason. roles returns the matching role labels, or an empty array for an unknown session. Neither writes roles or grants rights. Continue registering privileged commands as restricted.

The standalone open77_admin resource uses these bindings for /admin and rechecks commands at execution. See its README and role examples for deployment. Its /pos and /rot utilities intentionally require no admin ACL.

RegisterCommand("garage.delete", function(source, args, rawCommand)
    -- source is the playerId of the authenticated session, or 0 for the server console.
end, true)

From a client, this command requires command.garage.delete. An exact permission, *, or a trailing wildcard such as command.garage.* will grant it. The dedicated console uses source=0 and stays authorised for local administration.

The transport caps a line at 32 tokens and each token at 256 UTF-8 bytes, validates the command name, and reuses the network limit of 32 events per second. Refusals and results come back to the Open77 terminal through open77:command:result.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try