Skip to content

Roles & permissions

Trackarr uses a many-to-many role model. A user can hold several roles at once — typically one auto-assigned by the engine plus zero or more manually attached by an admin. Roles drive both permission grants (e.g. "bypass moderation") and visual badges on the public profile.

Schema

users ─┐
       │ many-to-many via user_roles
roles ─┘

roles

ColumnNotes
nameUnique. Surfaces as the badge label and in moderation logs.
colorHex string (e.g. #9ca3af). Drives the badge tint.
iconPhosphor icon id (e.g. ph:shield-check). Optional; falls back to a tag glyph.
showAsBadgeWhen true, the role is rendered as a public chip on the profile.
priorityHigher first. Drives badge ordering and permission resolution.
assignmentMode'manual' or 'auto'. Auto roles are owned by the engine — see below.
rulesjsonb rule tree consumed by the auto-engine. null or empty = no match (safety).
canUploadWithoutModerationIf true, uploads from anyone carrying the role land directly as accepted.

user_roles (junction)

Composite primary key (userId, roleId). Carries two relationship fields:

ColumnNotes
assignedAtWhen the role landed (engine sweep or admin attach). Shown in the profile chip tooltip.
assignedManuallytrue for admin-attached rows. The engine never deletes these, even when the user no longer matches the rules.

Permission resolution

Permissions are the union of every attached role's grants. The flag set in code is small today:

FlagEffect
canUploadWithoutModerationUpload bypasses the moderation queue, the row lands as accepted.

isAdmin and isModerator live on users directly (not on roles). They drive /admin and /mod access respectively, and they short-circuit a few rules (notably staffBypass on Upload Rules).

Auto-assignment engine

Auto roles are evaluated by apps/api/utils/roleRules.ts. The engine runs in three places:

  1. POST /api/admin/roles/recompute — full fleet sweep, attaches/detaches across every user.
  2. reevaluateUserRole(userId) — single-user recompute, called automatically after upload approvals, rejections, reports, and HnR transitions.
  3. Cron pluginrole-evaluator.ts runs periodically to catch users whose stats changed silently (e.g. seed-time accumulating).

The engine inserts matching auto rows that don't already exist and deletes auto rows where the user no longer matches. Manual rows are never touched.

Rule tree shape

json
{
  "combinator": "and",
  "conditions": [
    { "field": "ratio",         "comparator": "gte", "value": 1.0 },
    { "field": "approvedUploads","comparator": "gte", "value": 5 },
    { "field": "hnrCount",      "comparator": "eq",  "value": 0 }
  ]
}
FieldDriven by
approvedUploadscount(torrents WHERE moderation_status='accepted' AND uploader=user)
totalUploadscount(torrents WHERE uploader=user)
ratiousers.uploaded / users.downloaded (+Inf when downloaded = 0).
uploadedBytesusers.uploaded.
downloadedBytesusers.downloaded.
accountAgeDaysfloor((now - users.createdAt) / 1 day).
hnrCountcount(hnr_tracking WHERE isHnr=true AND user=user).
completedSeedscount(hnr_tracking WHERE completedAt IS NOT NULL AND user=user).
ComparatorMeaning
gte
gt>
lte
lt<
eq==
CombinatorMeaning
andEvery condition must be true.
orAny condition must be true.

Empty rules don't match

A rule tree with no conditions is treated as no match, so an auto role that was created without configured rules doesn't accidentally win every comparison.

Example: "Power User"

json
{
  "name": "Power User",
  "color": "#22c55e",
  "icon": "ph:lightning-bold",
  "showAsBadge": true,
  "priority": 50,
  "assignmentMode": "auto",
  "canUploadWithoutModeration": false,
  "rules": {
    "combinator": "and",
    "conditions": [
      { "field": "completedSeeds", "comparator": "gte", "value": 50 },
      { "field": "ratio",          "comparator": "gte", "value": 1.5 },
      { "field": "hnrCount",       "comparator": "eq",  "value": 0 }
    ]
  }
}

Example: "Uploader" (bypass moderation)

json
{
  "name": "Uploader",
  "color": "#0ea5e9",
  "icon": "ph:cloud-arrow-up-bold",
  "showAsBadge": true,
  "priority": 75,
  "assignmentMode": "auto",
  "canUploadWithoutModeration": true,
  "rules": {
    "combinator": "and",
    "conditions": [
      { "field": "approvedUploads", "comparator": "gte", "value": 25 }
    ]
  }
}

A user holding this role uploads bypass the Moderation queue entirely. The flag is cached in Redis for 5 minutes; the cache is invalidated automatically when the role membership or the role flag itself changes.

Manual vs auto

AspectManualAuto
Attached byAdmin from /admin/users (or role manage dialog).Engine on recompute / user-event hook / cron.
assignedManuallytruefalse
Engine deletes?Never — manual is a freeze against the engine.Yes, when the user no longer matches.
Use caseCustom labels, contributors, sponsors, probation.Automatic tiering ("Member" → "Power User" → "VIP").

Mixing both is safe: a user can be auto-attached to "Power User" and manually attached to "Sponsor" at the same time.

UI

Profile chips

The /users/:id and /me pages render every role with showAsBadge = true, sorted by priority descending. The chip carries the role colour + icon and shows a tooltip with assignedAt + (auto vs manual).

Admin views

PagePurpose
/admin/rolesCRUD on the role catalogue + rule editor + bulk recompute button.
/admin/usersPer-user role attach/detach dialog. Manual attaches are marked as "freeze the engine".

API surface

MethodPathAuthNotes
GET/api/admin/rolesadminList with attached-user counts.
POST/api/admin/rolesadminCreate.
PUT/api/admin/roles/:idadminEdit (name, color, rules, permissions).
DELETE/api/admin/roles/:idadminRemove (cascades the junction rows).
POST/api/admin/roles/recomputeadminSweep every user against every auto rule.
GET/api/admin/users/:id/rolesadminList a user's attached roles.
POST/api/admin/users/:id/rolesadminManual attach. Sets assignedManually = true.
DELETE/api/admin/users/:id/roles/:roleIdadminDetach. Engine may re-attach on its next sweep if the rules still match.

Compatibility shim

PUT /api/admin/users/:id/role is the legacy single-role endpoint and remains for older admin UIs. New code should use the /roles collection above.

Caveats & gotchas

  • +Inf ratio: a user with downloaded = 0 and uploaded > 0 has ratio +Infinity. The rule evaluator compares as numbers, so ratio gte 999999 matches them. If your intent is "demonstrated activity", combine with downloadedBytes gte X.
  • completedSeeds vs seedTime: the field counts rows that reached completion at any point, not currently-seeded torrents. Don't use it as a "currently seeding" proxy.
  • Recompute on big fleets: the sweep is O(users × rules) with cheap indexed counts per user. Tens of thousands of users finish in seconds.

Implementation reference

ConcernFile
Schemapackages/db/src/schema.ts (roles, user_roles)
Rule engineapps/api/utils/roleRules.ts
Stats aggregation per usercomputeUserStats() in the same file
Cron sweepapps/api/plugins/role-evaluator.ts
Admin CRUD routesapps/api/routes/api/admin/roles/*, users/[id]/roles/*
Web admin pagesapps/web/app/pages/admin/roles.vue, users.vue

Released under the MIT License.