Skip to content

Access Control and Audit Log

The Hub layers a per-site access control list on top of the existing QIE user management. Anyone who can log into the Hub dashboard can see the Sites Dashboard, but only operators with an ACL grant for a given site can open that site's admin UI through the proxy.

Every meaningful action (login, proxy open/close, site lifecycle, bundle generation, cert revocation, ACL changes) is recorded both in the hub_audit_entry table and in a dedicated hub-audit.log file for off-host shipping.

Per-site access control

The ACL model

Three tables back the model:

  • The existing QIE user table is the source of truth for who can log in to the Hub (local, LDAP, or OIDC users, the same auth path as engine mode).
  • The hub_site_acl table maps (site_id, username) → permission_level, a grant to an individual user.
  • The hub_site_role_acl table maps (site_id, role_id) → permission_level, a grant to a QIE role. Every user assigned to that role inherits access.

Access is the additive union of the two. An operator can open a site through the proxy if they are a Hub administrator, or they have a direct user grant for the site, or the role they are assigned to has a grant for the site. The two grant types are independent. Revoking one does not affect the other. (A QIE user is assigned at most one role, so role resolution is a single lookup.)

The permission model in v1 is simple: a row in either table grants PROXY permission. Operators with no matching grant for a site cannot open that site through the proxy and the site is filtered out of their dashboard view.

Permission Level What it grants
PROXY The right to open this site's admin UI through the proxy. Authentication to the remote QIE itself is unchanged. The operator still has to log in to the remote QIE separately

A VIEW-only level (dashboard visibility without proxy access) is planned for a future release. In v1 every grant is PROXY.

Manage Access dialog

Manage Access dialog for Foo Hospital: the Grant Access controls above a Current Access list holding one user grant with PROXY permission.

From the Sites Dashboard, select a site and click Manage Access in the toolbar. The dialog title shows the site display name:

Manage Access — Foo Hospital

The dialog has:

  • A Grant to toggle that switches the picker between User and Role.
  • A picker dropdown listing every QIE user (or role) that does not already have a grant for this site (subjects with an existing grant are filtered out).
  • A Grant button (enabled when a subject is selected).
  • A grid of current grants with a Type column (User or Role) and the subject name.
  • A Revoke button (enabled when a grid row is selected).
  • A Close button.

Granting access to a user

  1. Open the Manage Access dialog for the target site.
  2. Leave Grant to on User.
  3. Select a user from the dropdown and click Grant.

The Hub writes a hub_site_acl row and emits an ACL_GRANTED audit event (with subjectType: USER). The dropdown re-populates with the user removed and the grid refreshes with the new row visible.

Granting access to a role

  1. Open the Manage Access dialog for the target site.
  2. Set Grant to to Role.
  3. Select a role from the dropdown and click Grant. A confirmation prompt notes that every user assigned to the role gains access.

The Hub writes a hub_site_role_acl row and emits an ACL_GRANTED audit event (with subjectType: ROLE plus the roleId/roleName). Every user currently or later assigned to that role can open the site through the proxy, and no per-user grant is needed.

Revoking access

  1. Open the Manage Access dialog.
  2. Click the row to revoke in the grid (user or role).
  3. Click Revoke. A confirmation prompt is shown. For a role grant it notes that users who only have access via that role lose it.

The Hub deletes the matching hub_site_acl or hub_site_role_acl row and emits an ACL_REVOKED audit event. Affected users immediately lose access. Any open proxy tab continues to render already-loaded content but the next request returns 403 (unless they still have access via the other grant path).

Tip

Hub administrators (users with the existing QIE administrator role) bypass the ACL check and can proxy to any site by default. The ACL applies to non-administrator users. To restrict administrator access as well, demote the user to a non-admin role and grant ACL rows explicitly.

What "no access" looks like

A user without an ACL row for a given site sees:

  • The site is filtered out of their Sites Dashboard view.
  • Direct navigation to site-<label>.hub.example.com:8080 returns HTTP 403 with a clear "no access" page (not a generic Spring error).
  • A SITE_PROXY_OPENED audit event with an allowed: false field is recorded.

Audit log

The Hub writes an audit entry for every notable action. Audit entries are persisted twice:

  • In the hub_audit_entry table for in-application querying.
  • In hub-audit.log via a dedicated log4j2 appender for off-host shipping.

Both writes are best-effort independent: a database failure does not suppress the file log, and vice versa. The intent is that investigators can reconstruct the full event timeline even if the database is unavailable or has been tampered with.

Event types

Event Type Emitted when
HUB_LOGIN_SUCCESS A user authenticates to the Hub dashboard
HUB_LOGIN_FAILURE An authentication attempt against the Hub dashboard fails
SITE_PROXY_OPENED A user opens a proxy session to a site
SITE_PROXY_CLOSED A proxy session ends (tab closed, session timed out)
SITE_LOGIN_ATTEMPT_THROTTLED The login brute-force throttle rejected a request on the throttle → reject edge
BUNDLE_GENERATED An operator generated a .qcb bundle file
BUNDLE_REISSUED An operator generated a recovery / re-enrollment .qcb for an existing site
BUNDLE_CONSUMED A bundle was successfully redeemed at /register (first-time enrollment or re-enrollment)
BUNDLE_REUSE_REJECTED A /register attempt was rejected because of the bundle's own state. The reason detail says which: already-consumed, lost-commit-race (consumed by a rival registration during this one), or unknown (no such bundle id)
BUNDLE_EXPIRED_REJECTED A /register attempt against a bundle past expires_at was rejected
BUNDLE_REVOKED An operator cancelled an issued bundle before it was redeemed
BUNDLE_REVOKED_REJECTED A /register attempt against a cancelled bundle was rejected
CERT_REVOKED A cert was revoked (operator action, rotation, suspected compromise, decommission)
SITE_REGISTERED A hub_site row was created, paired with BUNDLE_CONSUMED for first-time enrollment
SITE_REKEYED An existing site's certificate was replaced in place via re-enrollment / recovery, paired with BUNDLE_CONSUMED; identity and history preserved
SITE_DELETED An operator deleted a site row
SITE_REGISTRATION_REJECTED A site's registration was refused. The reason detail says which: license-site-limit or failed-check-in-lockdown for a licensing refusal, subdomain-already-registered when the label is taken, registration-already-in-progress when another registration holds the mutex, site-changed-during-reenrollment, reenroll-site-not-found, or bundle-not-found. The bundle is untouched and stays redeemable once the cause clears, except for bundle-not-found, where the bundle row itself is gone and a new bundle is needed
SITE_DISABLED An operator disabled a site
SITE_ENABLED An operator re-enabled a previously disabled site
ACL_GRANTED A hub_site_acl (user) or hub_site_role_acl (role) row was created; details_json.subjectType distinguishes
ACL_REVOKED A hub_site_acl (user) or hub_site_role_acl (role) row was deleted; details_json.subjectType distinguishes
TUNNEL_CONNECTED A remote QIE established a tunnel
TUNNEL_DISCONNECTED A tunnel was closed (heartbeat loss, idle timeout, explicit close)
TUNNEL_TAKEOVER_DETECTED A site connected while it already had an open tunnel. The new connection wins, the old is forcibly closed. Repeated takeovers are a security signal (see below)

Entry shape

Each row in hub_audit_entry:

Column Notes
id PK
timestamp Indexed
event_type One of the enum values above
actor_username The user who performed the action (nullable when the action is unattributed, e.g. a /register POST, where the actor is a not-yet-trusted remote QIE)
actor_site_id The site that initiated the action, for site-initiated events (TUNNEL_CONNECTED, BUNDLE_CONSUMED, etc.)
target_site_id The site the action affects (SITE_DELETED, CERT_REVOKED, ACL_GRANTED, etc.)
target_username The user the action affects (ACL_GRANTED, ACL_REVOKED, HUB_LOGIN_*)
source_ip The originating IP, as the Hub saw it
details_json Event-specific fields, see below

The details_json column is an nvarchar(max) JSON object holding event-specific fields. Examples:

  • SITE_PROXY_OPENED includes the resolved subdomainLabel, the initial request path, and an allowed: true|false flag for the ACL decision.
  • CERT_REVOKED includes the cert serial, revocation reason, and free-text details.
  • BUNDLE_REUSE_REJECTED includes the rejected bundleId.
  • ACL_GRANTED / ACL_REVOKED include subjectType (USER or ROLE) and the subject (username for a user grant, or roleId plus roleName for a role grant), and the level (PROXY).

Log file

The dedicated appender writes one JSON document per line:

${qie.home}/logs/hub-audit.log
  • Format: %m%n. The message is the bare JSON document, no prefixed timestamp or log level (those are inside the JSON).
  • Rolling policy: size-based, 50 MB per file, up to 20 files retained.
  • Logger: com.qvera.qie.hub.audit, additivity=false. The audit logger does not echo anywhere else. Audit lines never appear in qie.log.

The 50 MB × 20 = 1 GB ceiling is intentional. Long retention happens off-host by shipping the file to a SIEM / log warehouse. Holding years of audit history on the Hub host itself is not the recommended deployment. It concentrates risk on a host that is already a tier-1 admin target. See Security Guidance.

Shipping off-host

The recommended deployment ships hub-audit.log to a SIEM or log warehouse continuously. The Hub itself does not embed a shipping client. The file format is intentionally simple so any of the common log shippers work:

Tool Recommended config
filebeat One input pointed at the file, JSON parser enabled, send to the SIEM of your choice
rsyslog imfile module reading the file, forward over TLS to a central rsyslog or syslog-ng
vector file source, json transform, any sink
fluent-bit / fluentd tail input plugin, JSON parser, forward output

Direct DB-to-SIEM streaming is not currently supported; the file is the integration point.

A built-in syslog appender configurable from the Hub UI is a near-term post-v1 item.

What Is NOT Audited

  • Request/response bodies are never logged anywhere. They may contain PHI.
  • Header values are not logged by default. The proxy logs the path but not the headers.
  • Internal listener restart / Spring lifecycle events live in qie.log, not the audit log. The audit log is restricted to operator-visible actions and security events.

Querying historical events

The audit table has composite indexes on:

  • (event_type, timestamp) for "all bundle generations this month" style queries.
  • (target_site_id, timestamp) for per-site timelines.
  • (actor_username, timestamp) for per-user activity reports.

The Hub dashboard includes a built-in Audit Log viewer (admin-only) that queries this table with server-side paging. It filters by time range (From, To), Event, User and Source IP.

Audit Log page: the From, To, Event, User and Source IP filters above a grid of sign-in and tunnel events. Customers running their own SIEM can additionally ship the audit log file off-box as a streaming query surface, with the database (and this viewer) as the investigation-time consistency check.

Tunnel takeover detection

TUNNEL_TAKEOVER_DETECTED events deserve special attention. They fire when:

  1. A hub_site already has an open tunnel.
  2. A new tunnel arrives presenting the same client cert (same subject CN).
  3. The Hub accepts the new tunnel and force-closes the old one (the "newer always wins" semantic).

The legitimate cause is a dirty disconnect: a TCP-level interruption that did not close the WebSocket cleanly, followed by a quick reconnect before the heartbeat-loss timer expired. This happens routinely and is not by itself an alarm signal.

What is an alarm signal: rapid repeated takeover storms, e.g. two takeovers within 60 seconds, repeating every few minutes. This pattern indicates two QIE installations presenting the same client cert, alternately taking over the tunnel. The most likely explanation is cert exfiltration: an attacker has obtained the client cert and is connecting from a different host than the legitimate site.

Recommended response: revoke the cert immediately with reason SUSPECTED_COMPROMISE. The legitimate site fails to reconnect and its administrator reports the outage; you can then investigate the source of the leak before re-enrolling.

This signal is reliable because the Hub always knows the source IP of every incoming tunnel. The takeover IPs are recorded in source_ip on every event and a SIEM rule on the pattern is the recommended detection.