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 ismember(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 setsscmConnectionIdorscmProjectRefanswers 403FORBIDDENunless 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 sonarqubestatus 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’sseqand 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.