waylog
Core Systems

Permissions (RBAC & FGAC)

The 64-bit BigInt permission architecture.

Waylog requires an organizational structure capable of supporting thousands of VTC members with granular control. We implemented a system heavily inspired by Discord, combining Role-Based Access Control (RBAC) with Fine-Grained Access Control (FGAC).

The Bitfield Architecture

Instead of utilizing rigid string enumerations (e.g., "ADMIN", "MODERATOR"), permissions in Waylog are mapped to the bits of a 64-bit integer (BigInt).

Currently, 17 of the 64 available bits are allocated across 8 logical categories:

CategoryPermissions
GeneralVIEW_VTC, MANAGE_VTC
MembersVIEW_MEMBERS, MANAGE_MEMBERS, INVITE_MEMBERS
Roles & PermissionsVIEW_ROLES, MANAGE_ROLES, ASSIGN_ROLES
ApplicationsVIEW_APPLICATIONS, MANAGE_APPLICATIONS
JobsVIEW_JOBS
Stats & ReportsVIEW_MEMBER_STATS, VIEW_VTC_REPORTS, EXPORT_DATA, VIEW_AUDIT_LOG
IntegrationsMANAGE_API_KEYS
AdminADMINISTRATOR

INVITE_MEMBERS, EXPORT_DATA and VIEW_AUDIT_LOG are allocated but not yet enforced — their features are on the roadmap. All bitwise constants live in the shared @waylog/permissions package and are the single source of truth:

export const VTC_PERMISSIONS = {
  VIEW_VTC: 1n << 0n,
  MANAGE_VTC: 1n << 1n,
  VIEW_MEMBERS: 1n << 2n,
  // …
  ADMINISTRATOR: 1n << 15n,
  MANAGE_API_KEYS: 1n << 16n,
} as const;

A Note on the Numbering

The values are persisted in vtcRole.permissions, vtc.basePermissions and vtcMember.permissionOverrides / permissionDenials as bigint columns, so the numbering is a storage contract, not an implementation detail:

  • Allocate the next free bit (currently 17). Never renumber or reuse an existing bit.
  • Retiring a permission is a breaking data change: write a migration that clears stored masks, then drop the constant. Flag it waylog:requires-write-freeze if the old server must be stopped first.
  • Allocating a bit is a promise to enforce it. Bits for jobs/dispatch, convoys, announcements and fleet were retired on 2026-10-18 because Waylog is a telemetry tracker, not a multiplayer platform.

Because the catalog is final and dense, the REST layer rejects any submitted mask that is not a subset of ALL_PERMISSIONS, so a caller cannot set a bit that means nothing today and something else tomorrow.

Owner, Baseline, and Roles

Owner and @everyone are internal states, not role rows:

  • Owner is whoever vtc.ownerId names. Ownership is identity: the owner resolves to ALL_PERMISSIONS and sits above every role and permission level. Transferring ownership changes that one column; both members keep their custom roles and FGAC data untouched.
  • @everyone is vtc.basePermissions, a stored mask inherited by every active member with no role assignment. It is edited on the roles screen and gated like a role write.
  • Custom roles in vtc_role are the only rows in the role system, and every one is authored by the VTC owner — creating a VTC seeds no roles at all. Ownership transfer never creates, removes, or assigns roles.

Administrator vs Owner

ADMINISTRATOR is a grantable permission on ordinary custom roles — not a second kind of ownership:

  • It grants all regular VTC permissions. It does not change a member's role position and does not make them the owner.
  • Hierarchy still applies. A member's highest assigned custom-role position governs whom they may act on. An Administrator acts freely below that position and cannot touch equals, higher roles, or the owner.
  • One grant rule, everywhere (Discord model): an actor may grant only permissions they hold. An Administrator holds everything, so they may delegate downward; anyone else is stopped at the bits they lack. No grantor relationship is recorded — if roles are reordered later, subsequent actions follow the new hierarchy.
  • The baseline follows the same rule. An authorized actor may place ADMINISTRATOR in vtc.basePermissions, and then every active member inherits it. The roles screen shows an explicit impact warning whenever it is selected, and saving a newly granted ADMINISTRATOR asks for confirmation first, because the server treats setting it as deliberate.
  • Editing the baseline is a hierarchy action too. The baseline sits below every custom role (position zero), so a non-owner needs at least one assigned role to save it. Rank comes from custom roles only - an FGAC override or the baseline itself never confers it - so a roleless member whose MANAGE_ROLES comes from the baseline cannot rewrite that baseline.

Custom Roles & Assignment

VTC Owners can create unlimited custom roles, establishing a hierarchy using the position integer.

Members can be assigned multiple roles; their baseline permissions are the combined total (Bitwise OR) of the VTC baseline plus all assigned roles.

Granting Is Authority-Bearing

Creating, editing or assigning a role is a grant, not just an edit, and is guarded accordingly:

  • You cannot grant a bit you do not hold yourself. Position checks (below your highest role) apply on top, to everyone except the owner — holding ADMINISTRATOR says what you may hand out, never whom you may act on.
  • The same rules apply to the REST and tRPC surfaces. The tRPC surface is the first-party client API; the REST surface additionally scopes third-party API keys (wlk_…) by fixed scopes rather than internal permission bits.

Fine-Grained Access Control (FGAC)

Custom roles are powerful, but sometimes a specific user needs a unique exception. The vtcMember record includes two dedicated fields:

  • permissionOverrides (BigInt): Explicitly grants specific permissions to a user, regardless of their roles.
  • permissionDenials (BigInt): Explicitly revokes specific permissions from a user, overriding baseline and roles.

Computing Effective Permissions

To determine a user's actual capabilities, the server evaluates their "effective" permissions on the fly:

function computeEffectivePermissions(
  basePermissions: bigint,
  rolePermissions: bigint[],
  overrides: bigint,
  denials: bigint,
  isOwner: boolean
): bigint {
  if (isOwner) return ALL_PERMISSIONS;

  // Baseline, then custom roles (OR), then overrides (OR), then denials (AND NOT)
  const effective =
    (basePermissions | roleUnion | overrides) & ~denials;

  // ADMINISTRATOR is a master key for regular permissions - not ownership,
  // not rank. Hierarchy and owner-protection checks stay independent of it.
  if ((effective & VTC_PERMISSIONS.ADMINISTRATOR) === VTC_PERMISSIONS.ADMINISTRATOR) {
    return ALL_PERMISSIONS;
  }

  return effective;
}

Inactive members are rejected before this runs, so the baseline is never granted to them. In the Waylog API, checkVtcPermission / checkVtcActorAccess (from @waylog/api) perform this computation server-side, rejecting unauthorized requests at the service layer. Both accept an any-of form (userAnyPermission) for gates where either half of a split permission is sufficient — for example the stats endpoint accepts VIEW_MEMBER_STATS or VIEW_VTC_REPORTS.

Split Permissions

Some bits deliberately split a single page into halves:

  • VIEW_MEMBER_STATS — individual driver performance (the top-drivers ranking).
  • VIEW_VTC_REPORTS — VTC-wide aggregates and trends.

They are not interchangeable: holding VIEW_VTC_REPORTS no longer implies access to individual member performance, which is the more sensitive half.

The any-of gate applies to the page, not to the individual-driver data inside it. The top-drivers ranking is read and returned only for a caller holding VIEW_MEMBER_STATS; for a report-only viewer the query is not run and the field is null. This holds on every surface that carries the ranking — the tRPC stats endpoint, the REST /api/v1/vtcs/:vtcId/stats endpoint, and the dashboard. An API key with the stats:read scope gets the VTC-wide aggregates only: a key carries scopes, not permission bits, so it never reaches the individual-driver half.

On this page