# 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](https://qualor.dev/docs/enterprise.md)), 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:

```sh
# 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):

```sh
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:

```js
// 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](https://qualor.dev/docs/webhooks-and-api.md#webhooks)).

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](https://qualor.dev/docs/webhooks-and-api.md#webhooks)). 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.