Roles and the audit log

Two enterprise features live on this page: roles beyond the community edition’s Admin and Member, including a role on a single project, need a licence listing rbac; the audit log needs one listing audit-log. Licence keys are not on sale yet (Enterprise), so everything below describes how these features work once a key can be applied. The community edition’s two roles and the Settings → Members screen exist today, in both editions.

Roles

Can do this Org admin Project admin (enterprise) Maintainer Viewer (enterprise)
Read the project: code, issues, quality gate, quality profile, AI answers ✓ ✓ ✓ ✓
Upload analyses, triage issues (change a status, override a severity), use the AI assistant ✓ ✓ ✓
Change project settings (except its GitLab/GitHub mapping), manage its analysis tokens, delete branches and merge requests, run the SonarQube status import ✓ ✓
Manage the organisation: members, project-level roles, projects, quality gates and profiles, webhooks, GitLab/GitHub connections and each project’s mapping to a repository, the audit log ✓
Delete the project ✓
  • Today’s two community roles keep their names: Admin is admin (org admin above), and Member is member (Maintainer above). With an enterprise licence they appear under their fuller names, and Project admin and Viewer become available.
  • Instance admins have every permission in every organisation, whatever their memberships, as today.
  • A role can be granted at organisation level, where it applies to every project in it, or on a single project (Project → Access, enterprise), where it applies to that project only — Org admin cannot be granted on a project. A project role adds to the organisation role; it never takes anything away. For example, an organisation Viewer who is also given Maintainer on one project can triage issues there, and stays read-only everywhere else.
  • Someone who has a role only on some projects of an organisation, not in the organisation itself, sees that organisation read-only — its name, quality gates, quality profiles, rules and AI summary — and only the projects they were given a role on, nothing else in it.
  • Mapping a project to a repository needs an org admin. A Project admin can change the project’s other settings, but a PATCH /projects/<id> that sets scmConnectionId or scmProjectRef answers 403 FORBIDDEN unless the caller is an org admin: the mapping decides which repository the organisation’s GitLab or GitHub credentials act on.

Managing members

Settings → Members (both editions, org admins) lists the organisation’s members and their roles. Add someone by their exact user name (looked up with GET /users/lookup, which needs the same permission), change a role, or remove a member. Community offers Admin and Member; with rbac, all four roles. An organisation always keeps at least one Admin: demoting or removing its last one answers 409 LAST_ADMIN, in both editions (an instance admin does not count unless they hold the Admin role in that organisation). Make someone else Admin first.

Project → Access (enterprise, org admins only — a Project admin cannot grant roles) does the same for a role on one project: give someone a role by exact user name, change it, or remove it. The tab reminds you that organisation roles apply on top of these. A project accepts at most 1 000 roles granted directly on it; beyond that, PUT answers 409 PROJECT_GRANT_LIMIT_REACHED.

Both screens use the same API:

# organisation level (role: "admin", "project_admin", "member" or "viewer"; project_admin and viewer need rbac)
curl -fsS -X PUT -H "Authorization: Bearer $QUALOR_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"role":"viewer"}' "$QUALOR_URL/api/v0/organizations/<org id>/members/<user id>"

# one project only (enterprise; role: "project_admin", "member" or "viewer")
curl -fsS -X PUT -H "Authorization: Bearer $QUALOR_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"role":"member"}' "$QUALOR_URL/api/v0/ee/rbac/projects/<project id>/members/<user id>"

GET /auth/me lists the caller’s own organisation memberships (with their effective permissions) and, with rbac, their own projectGrants across every organisation — at most 1 000 of them, ordered by project key; someone who somehow holds more sees only the first 1 000.

When the licence ends

Nothing is deleted, and nothing is rewritten. A stored role keeps working exactly as far as it still safely can:

  • a Viewer stays read-only — a restriction is never lifted by a lapse;
  • an organisation-level Project admin acts as a Maintainer: it keeps reading, uploading, triaging issues and using the AI assistant, but loses project settings, tokens, branch deletion and the SonarQube import;
  • a role granted on one project stops applying: someone whose only access to a project was such a role no longer sees it.

Settings → Members shows the stored role with a note (“Project admin (acts as Maintainer until the licence is renewed)”, “Viewer (read-only)”) so an org admin knows what a name means. Applying a renewed key and restarting restores every role and grant exactly as it was, with no data lost.

When audit-log lapses, recording stops: no new event is written until a renewed key is applied. The events already recorded stay, and retention keeps removing old ones at the period last saved (each removal is still recorded as audit.pruned, the one event written without the feature). Until the renewal, Settings → Audit log and Settings → Audit settings are not available, and every /api/v0/ee/audit route answers 403 FEATURE_NOT_LICENSED (as every /api/v0/ee/rbac route does once rbac lapses). The SIEM stream pauses, and carries on from where it stopped once the key is renewed.

The audit log

With an audit-log licence, Qualor records a defined catalogue of security-relevant events, in order, so they cannot be inserted, changed or quietly removed without it showing.

What is recorded, grouped in plain words (the raw name behind each group, such as member.role_changed, is what Settings → Audit log’s Actions filter and the API’s action parameter match, exactly or as a prefix ending in .*, for example issue.*):

  • Signing in and out: a sign-in, a failed attempt (see below), a sign-out, a password change.
  • Users and personal tokens: a user created or changed, a personal token created or revoked.
  • Organisations and their members: an organisation created; a member added, its role changed, or removed; with rbac, the same three for a role on a single project.
  • Projects: created, changed, deleted; a quality profile assigned to it; a branch deleted; a project’s own analysis tokens created or revoked.
  • Quality gates and profiles: created, changed, deleted, copied, made the default; a condition or a rule changed on one.
  • Issues: a status change — one event per issue, even for a bulk transition of 500 — a severity override, and one summary event for a qualor import sonarqube status import (never one per issue).
  • Integrations: a GitLab or GitHub connection created, changed or deleted; a webhook created, changed, deleted, its secret regenerated, or a delivery resent.
  • The licence: a key uploaded or removed (never the key itself).
  • The AI assistant: its settings changed (whether a key was set, removed or kept — never the key), a request sent to the provider, a fix suggestion queued to post.
  • The audit log itself: its retention or SIEM stream settings changed, the stream’s secret regenerated, an export started, and retention’s own removal of old events.

A failed sign-in never stores the name that was typed — only when it happens to match an existing user’s name is that user’s id and name stored, so a person who mistypes their password into the user name field leaves nothing behind.

Never recorded: any password, token, token hash, webhook or stream secret, SCM token, GitHub private key, licence key, the AI provider’s key, a session id, a comment’s text, or an AI prompt or answer. Also not recorded: analysis uploads and their processing, every read except an export, a refused request other than a sign-in attempt, an issue change Qualor itself makes (closing, reopening or mirroring during ingestion, a gate re-evaluation), and GitHub’s own webhook deliveries to Qualor.

Each event also keeps the caller’s IP address and browser (cut to 256 characters) for as long as retention keeps the event — worth knowing for your own privacy notice.

Who can read it: instance admins read and export every event, including instance-wide ones (sign-ins, user and licence changes, AI and audit settings). Organisation admins read and export only their own organisation’s events, in Settings → Audit log; naming another organisation, or one that does not exist, answers 404, the same as elsewhere in the API.

Retention

Settings → Audit settings sets how long events are kept: from 30 days to 100 years (36 500 days), 365 days by default. The oldest events past that period are removed once a day. The removal is itself recorded (as audit.pruned, with what it removed) so the rest of the log stays one unbroken, verifiable chain. A very large one-time backlog (past 50 000 events) is removed over several days rather than all at once.

Export and verification

Settings → Audit log → Export JSON Lines downloads the filtered period as JSON Lines (one event per line, oldest first), or call the API directly with a personal token that has the Admin scope (every /api/v0/ee/audit route needs it):

curl -fsS -H "Authorization: Bearer $QUALOR_ADMIN_TOKEN" \
  "$QUALOR_URL/api/v0/ee/audit/export?from=2027-01-01T00:00:00Z&to=2027-02-01T00:00:00Z" > audit.jsonl

Each line carries its own hash and the previous line’s hash, so anyone can check it, with this script:

// verify-audit.mjs: node verify-audit.mjs audit.jsonl
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

const canonical = (v) =>
  v === null || typeof v !== 'object'
    ? JSON.stringify(v)
    : Array.isArray(v)
      ? `[${v.map(canonical).join(',')}]`
      : `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;

let previous = null;
let n = 0;
for (const line of readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean)) {
  const { prevHash, hash, ...record } = JSON.parse(line);
  const expected = createHash('sha256').update(`qualor-audit-v1\n${prevHash}\n${canonical(record)}`).digest('hex');
  if (expected !== hash) throw new Error(`event ${record.seq}: the hash does not match its content`);
  if (previous && prevHash !== previous) console.warn(`event ${record.seq}: not linked to the line before (a filtered export, or a gap)`);
  previous = hash;
  n += 1;
}
console.log(`${n} events verified`);

A few things worth knowing:

  • An export is not a snapshot. It streams events as it reads them; if retention removes events partway through a long export (an export covers at most 366 days, and retention runs once a day, so this is rare), those events are missing from the file even though they fall inside the period you asked for.
  • A truncated download shows up as a failure when you verify it. If the download is interrupted — a lost connection, a closed browser tab — the file you are left with is incomplete. Verifying it either fails outright, because the cut-off line is not valid JSON, or reports fewer events than you expected; neither means the audit log itself is damaged. Export the period again.
  • Exporting is itself recorded, as audit.exported. Scoped to one organisation, the event belongs to that organisation, so its own admins can see who exported its events; an unscoped export (instance admins only) is an instance-level event.
  • The response is Cache-Control: no-store: no proxy or browser keeps a cached copy of the audit log.
  • Settings → Audit log → Verify chain (instance admins), or GET /api/v0/ee/audit/verify, checks the chain in the database — each event’s hash and its link to the one before — and names the first event where it breaks. It checks the table as it is now; the script above checks a file you keep.
  • The chain head (GET /api/v0/ee/audit/head, or Settings → Audit log, instance admins) is the newest event’s seq and hash. Note it somewhere outside Qualor now and then: a chain that still verifies but no longer continues from a head you noted earlier means the newest events were removed, which the table alone cannot show. The same is true of a SIEM receiver’s own record, or an earlier export.

Streaming to a SIEM

Settings → Audit settings configures one stream per instance: a URL, whether it is active, and Send test. The stream starts at the chain head when it is first saved or its URL changes: the receiver gets the events from then on, not the history before it (export that instead). Saving a new stream generates a signing secret, shown once — the same rules as a webhook URL apply (https, a public address, unless the instance allows otherwise; see Webhooks and REST API).

A delivery is a POST of up to 500 events at once, with X-Qualor-Event: audit.events and the same timestamp and signature headers as a webhook — verify it exactly the same way (see Webhooks and REST API). Each event in the batch carries its own seq; deduplicate on that seq, not on the delivery id, because a batch can be resent after a timeout.

A failing receiver is retried with backoff (1 minute, doubling up to an hour) until it accepts a batch again; nothing is lost until retention removes the waiting events, which Settings → Audit settings then counts under Removed by retention before they were sent (skipped in the API). There is no syslog output: an HTTP receiver is the only way to stream events out live.