From bb90c0c740d9ccea39d2ee03347b640c9fb17944 Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 16:27:19 +0400
Subject: [PATCH 1/4] feat(AF-938): record the calling application on requests
and audit rows
---
README.md | 2 +-
.../accessflow/audit/api/AuditLogQuery.java | 15 +-
.../internal/AuditLogSpecifications.java | 8 +
.../audit/internal/sink/AuditExportEvent.java | 16 +-
.../internal/sink/AuditExportEventWriter.java | 15 +-
.../audit/internal/sink/CefFormatter.java | 2 +
.../internal/web/AdminAuditLogController.java | 12 +-
.../core/api/ApplicationNameSource.java | 12 ++
.../core/api/ClientApplication.java | 16 ++
.../accessflow/core/api/QueryDetailView.java | 30 +++-
.../accessflow/core/api/QueryListFilter.java | 10 +-
.../core/api/QueryListItemView.java | 19 ++-
.../core/api/SubmitQueryCommand.java | 18 ++-
.../DefaultQueryRequestLookupService.java | 8 +-
...DefaultQueryRequestPersistenceService.java | 6 +
.../internal/QueryRequestSpecifications.java | 4 +
.../entity/QueryRequestEntity.java | 12 ++
.../mcp/internal/tools/McpToolService.java | 5 +-
.../security/api/ApiKeyAuthentication.java | 8 +
.../security/api/ApiKeyService.java | 12 +-
.../accessflow/security/api/ApiKeyView.java | 17 ++-
.../api/RequestApplicationService.java | 21 +++
.../security/api/ResolvedApiKey.java | 9 +-
.../ApplicationAuditMetadataContributor.java | 33 +++++
.../DefaultRequestApplicationService.java | 44 ++++++
.../internal/apikey/DefaultApiKeyService.java | 17 ++-
.../filter/ApiKeyAuthenticationFilter.java | 3 +-
.../filter/ApiKeyAuthenticationToken.java | 11 ++
.../persistence/entity/ApiKeyEntity.java | 8 +
.../internal/web/ApiKeysController.java | 2 +-
.../web/model/ApiKeyCreateRequest.java | 4 +-
.../internal/web/model/ApiKeyResponse.java | 6 +-
.../api/IssueServiceAccountKeyCommand.java | 11 +-
.../api/RotateServiceAccountKeyCommand.java | 8 +-
.../api/ServiceAccountKeyView.java | 10 +-
.../DefaultServiceAccountAdminService.java | 12 +-
.../web/IssueServiceAccountKeyRequest.java | 7 +-
.../web/RotateServiceAccountKeyRequest.java | 8 +-
.../web/ServiceAccountKeyResponse.java | 6 +-
.../workflow/api/BreakGlassService.java | 16 +-
.../workflow/api/QueryReplayService.java | 15 +-
.../workflow/api/QuerySubmissionService.java | 19 ++-
.../internal/DefaultBreakGlassService.java | 3 +-
.../internal/DefaultQueryReplayService.java | 3 +-
.../DefaultQuerySubmissionService.java | 3 +-
.../internal/web/BreakGlassController.java | 5 +-
.../internal/web/QueryDetailResponse.java | 11 +-
.../workflow/internal/web/QueryListItem.java | 11 +-
.../internal/web/QueryReadController.java | 13 +-
.../internal/web/QueryReplayController.java | 5 +-
.../web/QuerySubmissionController.java | 5 +-
.../migration/V188__add_application_name.sql | 17 +++
.../main/resources/i18n/messages.properties | 1 +
.../resources/i18n/messages_de.properties | 1 +
.../resources/i18n/messages_es.properties | 1 +
.../resources/i18n/messages_fr.properties | 1 +
.../resources/i18n/messages_hy.properties | 1 +
.../resources/i18n/messages_ru.properties | 1 +
.../resources/i18n/messages_zh_CN.properties | 1 +
.../internal/AuditLogSpecificationsTest.java | 25 ++++
.../sink/AuditExportEventWriterTest.java | 26 ++++
.../audit/internal/sink/CefFormatterTest.java | 13 ++
...dminAuditLogControllerIntegrationTest.java | 42 ++++++
.../core/api/ClientApplicationTest.java | 21 +++
.../DefaultQueryRequestLookupServiceTest.java | 10 ++
...ultQueryRequestPersistenceServiceTest.java | 32 ++++
.../QueryRequestSpecificationsTest.java | 15 ++
.../config/McpServerConfigurationTest.java | 1 +
.../internal/tools/McpToolServiceTest.java | 4 +-
...plicationAuditMetadataContributorTest.java | 45 ++++++
.../DefaultRequestApplicationServiceTest.java | 139 ++++++++++++++++++
.../apikey/DefaultApiKeyServiceTest.java | 24 +++
.../ApiKeyAuthenticationFilterTest.java | 4 +-
.../filter/ApiKeyAuthenticationTokenTest.java | 8 +
.../web/ApiKeysControllerIntegrationTest.java | 26 ++++
...DefaultServiceAccountAdminServiceTest.java | 43 +++++-
.../web/ServiceAccountControllerTest.java | 4 +-
.../web/ServiceAccountWebModelsTest.java | 12 +-
.../web/BreakGlassControllerTest.java | 4 +-
.../internal/web/QueryDetailResponseTest.java | 19 +++
.../internal/web/QueryListItemTest.java | 15 ++
.../QueryReadControllerIntegrationTest.java | 3 +-
...rySubmissionControllerIntegrationTest.java | 20 +++
docs/03-data-model.md | 7 +-
docs/04-api-spec.md | 43 ++++--
docs/05-backend.md | 2 +
docs/06-frontend.md | 12 ++
docs/07-security.md | 21 +++
docs/13-mcp.md | 6 +
e2e/tests/admin-audit-log.spec.ts | 81 ++++++++++
e2e/tests/profile-api-keys.spec.ts | 38 +++++
e2e/tests/query-list.spec.ts | 37 +++++
frontend/src/api/admin.test.ts | 4 +
frontend/src/api/admin.ts | 2 +
frontend/src/api/queries.test.ts | 2 +
frontend/src/api/queries.ts | 3 +
.../common/ClientApplicationTag.tsx | 43 ++++++
.../__tests__/ClientApplicationTag.test.tsx | 25 ++++
.../serviceaccounts/ServiceAccountKeysTab.tsx | 20 +++
frontend/src/locales/de.json | 18 +++
frontend/src/locales/en.json | 18 +++
frontend/src/locales/es.json | 18 +++
frontend/src/locales/fr.json | 18 +++
frontend/src/locales/hy.json | 18 +++
frontend/src/locales/ru.json | 18 +++
frontend/src/locales/zh-CN.json | 18 +++
frontend/src/pages/admin/AuditLogPage.tsx | 55 ++++++-
.../admin/__tests__/AuditLogPage.test.tsx | 34 ++++-
.../ServiceAccountSettingsPage.test.tsx | 7 +-
.../__tests__/createFormParity.test.ts | 3 +-
.../service-accounts/serviceAccountForm.ts | 1 +
.../pages/profile/sections/ApiKeysSection.tsx | 30 +++-
.../__tests__/ApiKeysSection.test.tsx | 31 ++++
.../pages/queries/QueryDetailPage.test.tsx | 14 ++
.../src/pages/queries/QueryDetailPage.tsx | 12 ++
frontend/src/pages/queries/QueryListPage.tsx | 15 +-
frontend/src/types/api.ts | 18 +++
frontend/src/utils/enumLabels.ts | 4 +
help-corpus/corpus.jsonl | 9 +-
help-corpus/manifest.json | 18 +--
website/README.md | 1 +
.../configuration/audit-compliance/index.html | 26 +++-
.../docs/configuration/users-roles/index.html | 8 +-
123 files changed, 1787 insertions(+), 126 deletions(-)
create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/ApplicationNameSource.java
create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/ClientApplication.java
create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/api/RequestApplicationService.java
create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributor.java
create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java
create mode 100644 backend/src/main/resources/db/migration/V188__add_application_name.sql
create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/api/ClientApplicationTest.java
create mode 100644 backend/src/test/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributorTest.java
create mode 100644 backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java
create mode 100644 frontend/src/components/common/ClientApplicationTag.tsx
create mode 100644 frontend/src/components/common/__tests__/ClientApplicationTag.test.tsx
diff --git a/README.md b/README.md
index 9112e8dbe..f4af77b37 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,7 @@ A glance at the day-to-day flows engineers and approvers actually use.
- **Decision traces for API calls and deployments (#967)** — the same explainer for the other two governed request kinds. `POST /admin/api-call-simulations` walks the connector gate, the read/write classification, the schema catalog, the effective connector permission, every routing policy, the review requirement, the eligible reviewers and the masking rules that would rewrite the response — never contacting the governed API. `POST /admin/deployment-simulations` reports the trigger grant, the freeze window, routing, the environment policy, the approvers, the deferred-release moment and the fail-closed gate's own verdict, computed by the very function the CI job blocks on — and accepts an optional `at`, so "would a release this Friday evening be held?" is answerable today. Both are read-only, gated by the permission that already governs their kind, and audited, and each has a **Simulate** tab on its connector or pipeline settings page (#1066) — the deployment one leads with the gate's releasable verdict.
- **Privileged-access report (#968)** — the standing answer to *who can reach data without a permission row*: every `QUERY_ADMIN` holder (system `ADMIN` or a custom role carrying it) and every break-glass grantee in the organization, one row each, with the role that carries the bypass, the break-glass datasources and their expiry, and how often — and how recently — the user has actually submitted queries. The paths no permission screen can show, at `/admin/privileged-access` for admins and auditors, audited on every read and advisory only.
- **External secrets managers** — keep datasource credentials in **HashiCorp Vault**, **AWS Secrets Manager**, or **Azure Key Vault** instead of the built-in encryption layer: store a secret reference (`vault:/#`, `aws:[#jsonField]`, `azure:`) in place of the password and AccessFlow resolves it through the store at connection time — enabling central rotation, cloud-native identity (IRSA, workload identity, Vault AppRole/Kubernetes auth with automatic token renewal), and a per-resolve audit trail. Local AES-256-GCM encryption remains the default and fallback.
-- **Tamper-evident audit log** — INSERT-only table chained with HMAC-SHA256; INSERT-only DB grants make after-the-fact rewrites detectable.
+- **Tamper-evident audit log** — INSERT-only table chained with HMAC-SHA256; INSERT-only DB grants make after-the-fact rewrites detectable. Every entry can name the **calling application** — trusted when it comes from an application name set on the API key, marked *untrusted* when it comes from the caller's `X-AccessFlow-Application` header — and the audit log and query list filter on it.
- **SIEM audit streaming & WORM archival** — stream the audit log to the tools your SOC already watches: **Splunk HEC**, **syslog/CEF** (TCP/TLS), and HMAC-**signed HTTPS** batches, plus periodic digitally-signed JSONL segments archived to **S3 Object Lock** under a WORM retention lock. Delivery is at-least-once off a durable per-sink cursor — a dead sink never blocks audit writes — with per-sink health (lag, last error, next retry) on the admin page, and every exported event carries its hash-chain links so an exported window verifies independently.
- **Backup, restore & disaster recovery** — the Helm chart ships an opt-in nightly `pg_dump` CronJob (retention-pruned PVC, optional rclone upload to S3/GCS/anything) and a one-shot restore Job that preserves the audit-role ownership split; a startup flag re-verifies **every organization's audit HMAC chain** after a restore, and a documented DR runbook covers backup, restore, and failover.
- **Compliance reporting** — pre-built reports over a period for audit evidence: a **classified-data-access** report (which executed queries touched PII/PCI/PHI/GDPR/FINANCIAL/SENSITIVE objects) and a **regulatory audit trail** of DDL/DELETE operations with approver names, computed from the immutable query snapshots. Reports export as **digitally signed** PDF/CSV (verifiable offline with the published public key) whose hash is chained into the tamper-evident audit log. A dedicated read-only **Auditor** role exposes the auditor dashboard.
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditLogQuery.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditLogQuery.java
index 5538a424d..33ac5b665 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditLogQuery.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditLogQuery.java
@@ -8,6 +8,8 @@
* "no filter on this field". {@code onBehalfOfUserId} (#875) matches the
* {@code metadata.on_behalf_of_user_id} key an API-key caller stamps when it acts for a named
* person (#874) — the rows a human is attributed on without being the actor.
+ * {@code applicationName} (#938) exactly matches the {@code metadata.application_name} key the
+ * calling-application contributor stamps.
*/
public record AuditLogQuery(
UUID actorId,
@@ -16,15 +18,22 @@ public record AuditLogQuery(
UUID resourceId,
Instant from,
Instant to,
- UUID onBehalfOfUserId) {
+ UUID onBehalfOfUserId,
+ String applicationName) {
+
+ /** Legacy shape without the calling-application filter (#938). */
+ public AuditLogQuery(UUID actorId, AuditAction action, AuditResourceType resourceType, UUID resourceId,
+ Instant from, Instant to, UUID onBehalfOfUserId) {
+ this(actorId, action, resourceType, resourceId, from, to, onBehalfOfUserId, null);
+ }
/** Legacy shape without the on-behalf-of filter (#875). */
public AuditLogQuery(UUID actorId, AuditAction action, AuditResourceType resourceType, UUID resourceId,
Instant from, Instant to) {
- this(actorId, action, resourceType, resourceId, from, to, null);
+ this(actorId, action, resourceType, resourceId, from, to, null, null);
}
public static AuditLogQuery empty() {
- return new AuditLogQuery(null, null, null, null, null, null, null);
+ return new AuditLogQuery(null, null, null, null, null, null, null, null);
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecifications.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecifications.java
index 75b105f17..96d7fcc92 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecifications.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecifications.java
@@ -13,6 +13,7 @@
final class AuditLogSpecifications {
static final String ON_BEHALF_OF_KEY = "on_behalf_of_user_id";
+ static final String APPLICATION_NAME_KEY = "application_name";
private AuditLogSpecifications() {
}
@@ -61,6 +62,13 @@ static Specification forQuery(UUID organizationId, AuditLogQuery
root.get("metadata"), cb.literal(ON_BEHALF_OF_KEY)),
query.onBehalfOfUserId().toString()));
}
+ if (query.applicationName() != null && !query.applicationName().isBlank()) {
+ // Stamped into the JSONB metadata by the calling-application contributor (#938).
+ predicates.add(cb.equal(
+ cb.function("jsonb_extract_path_text", String.class,
+ root.get("metadata"), cb.literal(APPLICATION_NAME_KEY)),
+ query.applicationName().strip()));
+ }
return cb.and(predicates.toArray(new Predicate[0]));
};
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEvent.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEvent.java
index fe62fbd5f..e4fffa528 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEvent.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEvent.java
@@ -7,7 +7,9 @@
* The canonical exported form of one {@code audit_log} row (#628). {@code metadataJson} is the
* raw stored JSONB (embedded as an object on the wire, not a string); the hashes are lowercase
* hex (the audit CSV-export convention) so any exported window is independently
- * chain-verifiable against the in-DB HMAC chain.
+ * chain-verifiable against the in-DB HMAC chain. {@code applicationName} /
+ * {@code applicationNameSource} (#938) are lifted out of the metadata so SIEM consumers get the
+ * calling application as a first-class field; both null when the row names none.
*/
public record AuditExportEvent(
UUID id,
@@ -21,5 +23,15 @@ public record AuditExportEvent(
String userAgent,
Instant createdAt,
String previousHash,
- String currentHash) {
+ String currentHash,
+ String applicationName,
+ String applicationNameSource) {
+
+ public AuditExportEvent(UUID id, UUID organizationId, UUID actorId, String action,
+ String resourceType, UUID resourceId, String metadataJson,
+ String ipAddress, String userAgent, Instant createdAt,
+ String previousHash, String currentHash) {
+ this(id, organizationId, actorId, action, resourceType, resourceId, metadataJson, ipAddress,
+ userAgent, createdAt, previousHash, currentHash, null, null);
+ }
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriter.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriter.java
index 2ba5f2dba..1aec46e15 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriter.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriter.java
@@ -22,7 +22,11 @@ public class AuditExportEventWriter {
private final ObjectMapper objectMapper;
+ static final String APPLICATION_NAME_KEY = "application_name";
+ static final String APPLICATION_NAME_SOURCE_KEY = "application_name_source";
+
public AuditExportEvent toEvent(AuditLogEntity row) {
+ var metadata = metadataNode(row.getMetadata());
return new AuditExportEvent(
row.getId(),
row.getOrganizationId(),
@@ -35,7 +39,9 @@ public AuditExportEvent toEvent(AuditLogEntity row) {
row.getUserAgent(),
row.getCreatedAt(),
hexOrNull(row.getPreviousHash()),
- hexOrNull(row.getCurrentHash()));
+ hexOrNull(row.getCurrentHash()),
+ textOrNull(metadata, APPLICATION_NAME_KEY),
+ textOrNull(metadata, APPLICATION_NAME_SOURCE_KEY));
}
/** One event as a single-line JSON object. */
@@ -53,6 +59,8 @@ public String toJson(AuditExportEvent event) {
fields.put("created_at", event.createdAt() == null ? null : event.createdAt().toString());
fields.put("previous_hash", event.previousHash());
fields.put("current_hash", event.currentHash());
+ fields.put("application_name", event.applicationName());
+ fields.put("application_name_source", event.applicationNameSource());
return objectMapper.writeValueAsString(fields);
}
@@ -81,6 +89,11 @@ private JsonNode metadataNode(String metadataJson) {
}
}
+ private static String textOrNull(JsonNode metadata, String key) {
+ var value = metadata.get(key);
+ return value != null && value.isString() ? value.asString() : null;
+ }
+
private static String hexOrNull(byte[] bytes) {
return bytes == null ? null : HexFormat.of().formatHex(bytes);
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatter.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatter.java
index 18518c6b7..ae37d5513 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatter.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatter.java
@@ -58,6 +58,8 @@ private String cef(AuditExportEvent event, int severity) {
event.resourceId() == null ? null : event.resourceId().toString());
labeled(sb, "cs3", "current_hash", event.currentHash());
labeled(sb, "cs4", "previous_hash", event.previousHash());
+ labeled(sb, "cs5", "application_name", event.applicationName());
+ labeled(sb, "cs6", "application_name_source", event.applicationNameSource());
return sb.toString().stripTrailing();
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
index ef281e3a8..a1b845f4d 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
@@ -78,6 +78,8 @@ AuditLogPageResponse list(
@RequestParam(required = false) Instant to,
@Parameter(description = "Filter by the person an API-key caller acted on behalf of (#874)")
@RequestParam(required = false) UUID onBehalfOfUserId,
+ @Parameter(description = "Filter by the recorded calling application, exact match (#938)")
+ @RequestParam(required = false) String applicationName,
@AuthenticationPrincipal(expression = "organizationId") UUID organizationId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
@@ -87,7 +89,7 @@ AuditLogPageResponse list(
validateSort(pageable.getSort());
var resourceTypeEnum = parseResourceType(resourceType);
var filter = new AuditLogQuery(actorId, action, resourceTypeEnum, resourceId, from, to,
- onBehalfOfUserId);
+ onBehalfOfUserId, applicationName);
PageResponse page = auditLogService.query(organizationId, filter,
SpringPageableAdapter.toPageRequest(pageable));
Map users = lookupUsers(organizationId, page);
@@ -123,13 +125,15 @@ void exportCsv(
@RequestParam(required = false) Instant to,
@Parameter(description = "Filter by the person an API-key caller acted on behalf of (#874)")
@RequestParam(required = false) UUID onBehalfOfUserId,
+ @Parameter(description = "Filter by the recorded calling application, exact match (#938)")
+ @RequestParam(required = false) String applicationName,
@AuthenticationPrincipal(expression = "organizationId") UUID organizationId,
@AuthenticationPrincipal(expression = "userId") UUID callerUserId,
RequestAuditContext auditContext,
HttpServletResponse response) throws IOException {
var resourceTypeEnum = parseResourceType(resourceType);
var filter = new AuditLogQuery(actorId, action, resourceTypeEnum, resourceId, from, to,
- onBehalfOfUserId);
+ onBehalfOfUserId, applicationName);
long matched = auditLogCsvService.count(organizationId, filter);
boolean truncated = matched > AuditLogCsvService.MAX_EXPORT_ROWS;
@@ -224,6 +228,10 @@ private void recordExportAudit(UUID organizationId, UUID callerUserId, AuditLogQ
// become a row claiming the admin exported on bob's behalf.
metadata.put("filter_on_behalf_of_user_id", filter.onBehalfOfUserId().toString());
}
+ if (filter.applicationName() != null) {
+ // Not "application_name": that key names the application that made THIS request.
+ metadata.put("filter_application_name", filter.applicationName());
+ }
if (filter.from() != null) {
metadata.put("from", filter.from().toString());
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/ApplicationNameSource.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/ApplicationNameSource.java
new file mode 100644
index 000000000..bdaf26b49
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/ApplicationNameSource.java
@@ -0,0 +1,12 @@
+package com.bablsoft.accessflow.core.api;
+
+/**
+ * Where a request's calling-application name came from (#938). {@link #API_KEY} is trustworthy —
+ * the name is stored on the key and cannot be forged without it. {@link #HEADER} is the
+ * caller-supplied {@code X-AccessFlow-Application} header and is entirely client-controlled, so it
+ * must never be the sole basis of a permissive decision.
+ */
+public enum ApplicationNameSource {
+ API_KEY,
+ HEADER
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/ClientApplication.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/ClientApplication.java
new file mode 100644
index 000000000..832b12eae
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/ClientApplication.java
@@ -0,0 +1,16 @@
+package com.bablsoft.accessflow.core.api;
+
+import java.util.Objects;
+
+/** The calling application recorded on a request (#938): its name and how it was learned. */
+public record ClientApplication(String name, ApplicationNameSource source) {
+
+ public ClientApplication {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(source, "source");
+ }
+
+ public boolean trusted() {
+ return source == ApplicationNameSource.API_KEY;
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryDetailView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryDetailView.java
index 931183993..9f799408b 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryDetailView.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryDetailView.java
@@ -46,7 +46,35 @@ public record QueryDetailView(
Instant updatedAt,
/** The human an API-key submitter acted for (#874); null for a human submission. */
UUID onBehalfOfUserId,
- String onBehalfOfEmail) {
+ String onBehalfOfEmail,
+ /** The calling application (#938) and how it was learned; both null when unknown. */
+ String applicationName,
+ ApplicationNameSource applicationNameSource) {
+
+ /** Backward-compatible constructor without the #938 calling application. */
+ public QueryDetailView(UUID id, UUID datasourceId, String datasourceName, DbType dbType,
+ UUID organizationId, UUID submittedByUserId, String submittedByEmail,
+ String submittedByDisplayName, String sqlText, QueryType queryType,
+ QueryStatus status, String justification, AiAnalysisDetail aiAnalysis,
+ CostEstimateDetail costEstimate,
+ ApprovalPredictionDetail approvalPrediction, Long rowsAffected,
+ Integer durationMs, String errorMessage, UUID previousRunId,
+ UUID approvedByGrantId, String reviewPlanName,
+ Integer approvalTimeoutHours, Instant escalatedAt,
+ Integer escalationAfterHours, List reviewDecisions,
+ Instant scheduledFor, String recurrenceRule, Instant recurrenceUntil,
+ Instant recurrenceNextRunAt, String recurrenceHaltedReason,
+ UUID recurringParentId, Instant createdAt, Instant updatedAt,
+ UUID onBehalfOfUserId, String onBehalfOfEmail) {
+ this(id, datasourceId, datasourceName, dbType, organizationId, submittedByUserId,
+ submittedByEmail, submittedByDisplayName, sqlText, queryType, status, justification,
+ aiAnalysis, costEstimate, approvalPrediction, rowsAffected, durationMs,
+ errorMessage, previousRunId, approvedByGrantId, reviewPlanName,
+ approvalTimeoutHours, escalatedAt, escalationAfterHours, reviewDecisions,
+ scheduledFor, recurrenceRule, recurrenceUntil, recurrenceNextRunAt,
+ recurrenceHaltedReason, recurringParentId, createdAt, updatedAt, onBehalfOfUserId,
+ onBehalfOfEmail, null, null);
+ }
/** Backward-compatible constructor without the #874 on-behalf-of principal. */
public QueryDetailView(UUID id, UUID datasourceId, String datasourceName, DbType dbType,
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListFilter.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListFilter.java
index 85d98917d..d38ae8e22 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListFilter.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListFilter.java
@@ -6,6 +6,7 @@
/**
* Filter parameters for {@link QueryRequestLookupService#findForOrganization}. All fields are
* optional except {@code organizationId}; non-null fields are AND-combined.
+ * {@code applicationName} (#938) is an exact match on the recorded calling application.
*/
public record QueryListFilter(
UUID organizationId,
@@ -14,5 +15,12 @@ public record QueryListFilter(
QueryStatus status,
QueryType queryType,
Instant from,
- Instant to) {
+ Instant to,
+ String applicationName) {
+
+ /** Backward-compatible constructor without the #938 application filter. */
+ public QueryListFilter(UUID organizationId, UUID submittedByUserId, UUID datasourceId,
+ QueryStatus status, QueryType queryType, Instant from, Instant to) {
+ this(organizationId, submittedByUserId, datasourceId, status, queryType, from, to, null);
+ }
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListItemView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListItemView.java
index e844a4892..e727e359e 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListItemView.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryListItemView.java
@@ -7,7 +7,8 @@
* Cross-module DTO for a row of {@code GET /queries}: enough fields for the list view's
* table (status pill, risk pill, submitter chip, datasource name) without loading the full
* SQL text or AI issue list. {@code recurring} is true for a recurring-series parent (#627);
- * {@code recurringParentId} is set on occurrence rows.
+ * {@code recurringParentId} is set on occurrence rows. {@code applicationName} /
+ * {@code applicationNameSource} are the calling application (#938), null when unknown.
*/
public record QueryListItemView(
UUID id,
@@ -24,7 +25,21 @@ public record QueryListItemView(
Instant scheduledFor,
boolean recurring,
UUID recurringParentId,
- Instant createdAt) {
+ Instant createdAt,
+ String applicationName,
+ ApplicationNameSource applicationNameSource) {
+
+ /** Backward-compatible constructor without the #938 calling application. */
+ public QueryListItemView(UUID id, UUID datasourceId, String datasourceName,
+ UUID submittedByUserId, String submittedByEmail,
+ String submittedByDisplayName, QueryType queryType,
+ QueryStatus status, RiskLevel aiRiskLevel, Integer aiRiskScore,
+ boolean aiFailed, Instant scheduledFor, boolean recurring,
+ UUID recurringParentId, Instant createdAt) {
+ this(id, datasourceId, datasourceName, submittedByUserId, submittedByEmail,
+ submittedByDisplayName, queryType, status, aiRiskLevel, aiRiskScore, aiFailed,
+ scheduledFor, recurring, recurringParentId, createdAt, null, null);
+ }
/** Backward-compatible constructor without the #627 recurrence fields (defaults to absent). */
public QueryListItemView(UUID id, UUID datasourceId, String datasourceName,
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/SubmitQueryCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/SubmitQueryCommand.java
index efef685db..118071576 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/SubmitQueryCommand.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/SubmitQueryCommand.java
@@ -18,7 +18,21 @@ public record SubmitQueryCommand(
String recurrenceRule,
Instant recurrenceUntil,
Instant recurrenceNextRunAt,
- UUID onBehalfOfUserId) {
+ UUID onBehalfOfUserId,
+ /** The calling application (#938); null when unknown. */
+ ClientApplication application) {
+
+ /** Backward-compatible constructor without the #938 calling application. */
+ public SubmitQueryCommand(UUID datasourceId, UUID submittedByUserId, String sqlText,
+ QueryType queryType, boolean transactional, String justification,
+ Instant scheduledFor, SubmissionReason submissionReason,
+ String submittedIp, String submittedUserAgent, boolean ciCdOrigin,
+ String recurrenceRule, Instant recurrenceUntil,
+ Instant recurrenceNextRunAt, UUID onBehalfOfUserId) {
+ this(datasourceId, submittedByUserId, sqlText, queryType, transactional, justification,
+ scheduledFor, submissionReason, submittedIp, submittedUserAgent, ciCdOrigin,
+ recurrenceRule, recurrenceUntil, recurrenceNextRunAt, onBehalfOfUserId, null);
+ }
/** Backward-compatible constructor without the #874 on-behalf-of principal. */
public SubmitQueryCommand(UUID datasourceId, UUID submittedByUserId, String sqlText,
@@ -39,6 +53,6 @@ public SubmitQueryCommand(UUID datasourceId, UUID submittedByUserId, String sqlT
String submittedIp, String submittedUserAgent, boolean ciCdOrigin) {
this(datasourceId, submittedByUserId, sqlText, queryType, transactional, justification,
scheduledFor, submissionReason, submittedIp, submittedUserAgent, ciCdOrigin,
- null, null, null, null);
+ null, null, null, (UUID) null);
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupService.java
index 3f443aeea..5c1892425 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupService.java
@@ -301,7 +301,9 @@ private QueryListItemView toListItemView(QueryRequestEntity entity) {
entity.getScheduledFor(),
entity.getRecurrenceRule() != null,
entity.getRecurringParentId(),
- entity.getCreatedAt());
+ entity.getCreatedAt(),
+ entity.getApplicationName(),
+ entity.getApplicationNameSource());
}
private QueryDetailView toDetailView(QueryRequestEntity entity) {
@@ -358,7 +360,9 @@ private QueryDetailView toDetailView(QueryRequestEntity entity) {
entity.getCreatedAt(),
entity.getUpdatedAt(),
entity.getOnBehalfOfUserId(),
- onBehalfOfEmail(entity.getOnBehalfOfUserId()));
+ onBehalfOfEmail(entity.getOnBehalfOfUserId()),
+ entity.getApplicationName(),
+ entity.getApplicationNameSource());
}
private String onBehalfOfEmail(UUID onBehalfOfUserId) {
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceService.java
index 36a572ef2..315e991a6 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceService.java
@@ -54,6 +54,10 @@ public UUID submit(SubmitQueryCommand command) {
entity.setSubmittedUserAgent(command.submittedUserAgent());
entity.setCiCdOrigin(command.ciCdOrigin());
entity.setOnBehalfOfUserId(command.onBehalfOfUserId());
+ if (command.application() != null) {
+ entity.setApplicationName(command.application().name());
+ entity.setApplicationNameSource(command.application().source());
+ }
entity.setRecurrenceRule(command.recurrenceRule());
entity.setRecurrenceUntil(command.recurrenceUntil());
entity.setRecurrenceNextRunAt(command.recurrenceNextRunAt());
@@ -85,6 +89,8 @@ public Optional createRecurringOccurrence(UUID parentId, Instant expectedN
child.setSubmittedBy(parent.getSubmittedBy());
// An occurrence is still "for" whoever the series was submitted for (#874).
child.setOnBehalfOfUserId(parent.getOnBehalfOfUserId());
+ child.setApplicationName(parent.getApplicationName());
+ child.setApplicationNameSource(parent.getApplicationNameSource());
child.setSqlText(parent.getSqlText());
child.setQueryType(parent.getQueryType());
child.setTransactional(parent.isTransactional());
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecifications.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecifications.java
index 62a721886..6f91a210e 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecifications.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecifications.java
@@ -38,6 +38,10 @@ static Specification forFilter(QueryListFilter filter) {
if (filter.to() != null) {
predicates.add(cb.lessThan(root.get("createdAt"), filter.to()));
}
+ if (filter.applicationName() != null && !filter.applicationName().isBlank()) {
+ predicates.add(cb.equal(root.get("applicationName"),
+ filter.applicationName().strip()));
+ }
return cb.and(predicates.toArray(new Predicate[0]));
};
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/QueryRequestEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/QueryRequestEntity.java
index 588baad4e..1db2d7440 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/QueryRequestEntity.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/QueryRequestEntity.java
@@ -1,5 +1,6 @@
package com.bablsoft.accessflow.core.internal.persistence.entity;
+import com.bablsoft.accessflow.core.api.ApplicationNameSource;
import com.bablsoft.accessflow.core.api.QueryStatus;
import com.bablsoft.accessflow.core.api.QueryType;
import com.bablsoft.accessflow.core.api.SubmissionReason;
@@ -140,6 +141,17 @@ public class QueryRequestEntity {
@Column(name = "on_behalf_of_user_id")
private UUID onBehalfOfUserId;
+ // The calling application (#938) — identification and audit only, never an authorization
+ // input. API_KEY = the name stored on the authenticating key (trustworthy); HEADER = the
+ // caller-supplied X-AccessFlow-Application header (client-controlled).
+ @Column(name = "application_name", length = 100)
+ private String applicationName;
+
+ @Enumerated(EnumType.STRING)
+ @JdbcType(PostgreSQLEnumJdbcType.class)
+ @Column(name = "application_name_source", columnDefinition = "application_name_source")
+ private ApplicationNameSource applicationNameSource;
+
@Version
@Column(name = "updated_at", nullable = false)
private Instant updatedAt = Instant.now();
diff --git a/backend/src/main/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolService.java b/backend/src/main/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolService.java
index 05a65db8b..f7e0066b2 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolService.java
@@ -17,6 +17,7 @@
import com.bablsoft.accessflow.mcp.internal.tools.dto.McpQueryResult;
import com.bablsoft.accessflow.mcp.internal.tools.dto.McpQuerySubmission;
import com.bablsoft.accessflow.mcp.internal.tools.dto.McpQuerySummary;
+import com.bablsoft.accessflow.security.api.RequestApplicationService;
import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService;
import com.bablsoft.accessflow.workflow.api.QueryLifecycleService;
import com.bablsoft.accessflow.workflow.api.QuerySubmissionService;
@@ -50,6 +51,7 @@ public class McpToolService {
private final QuerySubmissionService querySubmissionService;
private final QueryLifecycleService queryLifecycleService;
private final OnBehalfOfPrincipalService onBehalfOfPrincipalService;
+ private final RequestApplicationService requestApplicationService;
private final AuditLogService auditLogService;
@Tool(name = "list_datasources",
@@ -158,7 +160,8 @@ public McpQuerySubmission submitQuery(
datasourceId, sql, justification,
claims.userId(), claims.organizationId(), currentUser.isAdmin(), null, null,
null, null, false, null, null,
- onBehalfOfPrincipalService.current().orElse(null));
+ onBehalfOfPrincipalService.current().orElse(null),
+ requestApplicationService.current().orElse(null));
var result = querySubmissionService.submit(input);
recordSubmitted(claims.organizationId(), claims.userId(), result.id(), datasourceId);
return new McpQuerySubmission(result.id(), result.status().name());
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyAuthentication.java b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyAuthentication.java
index b6db675c6..273e04c91 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyAuthentication.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyAuthentication.java
@@ -16,4 +16,12 @@ public interface ApiKeyAuthentication {
/** The {@code api_keys.id} of the key presented on this request. */
UUID apiKeyId();
+
+ /**
+ * The calling application stored on the presented key (#938), or {@code null} when the key
+ * names none. Trustworthy — unlike the {@code X-AccessFlow-Application} header.
+ */
+ default String applicationName() {
+ return null;
+ }
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyService.java b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyService.java
index 38d37705b..07c52294c 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyService.java
@@ -15,7 +15,17 @@
*/
public interface ApiKeyService {
- IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant expiresAt);
+ /**
+ * Issues a key. {@code applicationName} (#938) is the calling application the key identifies,
+ * recorded on every request it authenticates; {@code null} for none. Set here only — there is
+ * no update path.
+ */
+ IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant expiresAt,
+ String applicationName);
+
+ default IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant expiresAt) {
+ return issue(userId, organizationId, name, expiresAt, null);
+ }
/**
* Stores a caller-supplied raw key (rather than generating one) for declarative provisioning —
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyView.java b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyView.java
index 6a0275a2e..79070df9e 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyView.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/api/ApiKeyView.java
@@ -6,7 +6,8 @@
/**
* {@code bootstrapDeclared} (#871) marks the one key the bootstrap reconciler declared for a
* service account — the key an admin cannot revoke or rotate, because a changed reconcile would
- * reactivate it.
+ * reactivate it. {@code applicationName} (#938) is the calling application the key identifies, or
+ * {@code null}.
*/
public record ApiKeyView(
UUID id,
@@ -18,5 +19,15 @@ public record ApiKeyView(
Instant lastUsedAt,
Instant expiresAt,
Instant revokedAt,
- boolean bootstrapDeclared
-) {}
+ boolean bootstrapDeclared,
+ String applicationName
+) {
+
+ /** Backward-compatible constructor without the #938 application name. */
+ public ApiKeyView(UUID id, UUID userId, UUID organizationId, String name, String keyPrefix,
+ Instant createdAt, Instant lastUsedAt, Instant expiresAt, Instant revokedAt,
+ boolean bootstrapDeclared) {
+ this(id, userId, organizationId, name, keyPrefix, createdAt, lastUsedAt, expiresAt,
+ revokedAt, bootstrapDeclared, null);
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/api/RequestApplicationService.java b/backend/src/main/java/com/bablsoft/accessflow/security/api/RequestApplicationService.java
new file mode 100644
index 000000000..0031f21ab
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/api/RequestApplicationService.java
@@ -0,0 +1,21 @@
+package com.bablsoft.accessflow.security.api;
+
+import com.bablsoft.accessflow.core.api.ClientApplication;
+
+import java.util.Optional;
+
+/**
+ * Resolves the calling application of the current HTTP request (#938). The name stored on the
+ * presented API key wins ({@code API_KEY}, trustworthy); otherwise the caller-supplied
+ * {@value #HEADER} header is used ({@code HEADER}, client-controlled). Empty when neither is present
+ * or when called off the request thread. Identification and audit only — never an authorization
+ * input.
+ */
+public interface RequestApplicationService {
+
+ String HEADER = "X-AccessFlow-Application";
+
+ int MAX_LENGTH = 100;
+
+ Optional current();
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/api/ResolvedApiKey.java b/backend/src/main/java/com/bablsoft/accessflow/security/api/ResolvedApiKey.java
index ad92be898..6b8560321 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/api/ResolvedApiKey.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/api/ResolvedApiKey.java
@@ -7,5 +7,12 @@
* {@code apiKeyId} is the row's id, {@code userId} its owner. Carrying the key id (rather than
* only the owner) is what lets later features attribute a request to the credential that made
* it — the owner alone cannot tell two keys of the same service account apart.
+ * {@code applicationName} (#938) is the calling application stored on the key, or {@code null}.
*/
-public record ResolvedApiKey(UUID apiKeyId, UUID userId) {}
+public record ResolvedApiKey(UUID apiKeyId, UUID userId, String applicationName) {
+
+ /** Backward-compatible constructor for a key without an application name. */
+ public ResolvedApiKey(UUID apiKeyId, UUID userId) {
+ this(apiKeyId, userId, null);
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributor.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributor.java
new file mode 100644
index 000000000..bd8866ecd
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributor.java
@@ -0,0 +1,33 @@
+package com.bablsoft.accessflow.security.internal;
+
+import com.bablsoft.accessflow.audit.api.AuditMetadataContributor;
+import com.bablsoft.accessflow.security.api.RequestApplicationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Stamps every audit row written on a request that names a calling application (#938) with
+ * {@code application_name} and {@code application_name_source} ({@code api_key} or {@code header}).
+ * The keys ride in {@code audit_log.metadata}, already inside the HMAC chain — no schema change.
+ */
+@Component
+@RequiredArgsConstructor
+class ApplicationAuditMetadataContributor implements AuditMetadataContributor {
+
+ static final String APPLICATION_NAME = "application_name";
+ static final String APPLICATION_NAME_SOURCE = "application_name_source";
+
+ private final RequestApplicationService requestApplicationService;
+
+ @Override
+ public Map contribute() {
+ return requestApplicationService.current()
+ .
Configure it. Manage sinks at /admin/audit-sinks (requires
diff --git a/website/docs/configuration/users-roles/index.html b/website/docs/configuration/users-roles/index.html
index decf8a708..801dc58c1 100644
--- a/website/docs/configuration/users-roles/index.html
+++ b/website/docs/configuration/users-roles/index.html
@@ -727,10 +727,14 @@
Service accounts
owner — the person accountable for the account, shown to attestation reviewers.
Issue a key. On the API keys tab, Issue key shows the
plaintext exactly once — copy it into the pipeline or agent's secret store. Keys are the
- account's only credential.
+ account's only credential. Give each key an optional Application name (for
+ example reporting-service) so the audit log shows which application used
+ it; see Which
+ application made a request?
Rotate rather than revoke.Rotate issues a replacement and
keeps the old key working for a grace period (24 hours by default, or the number of hours
- you enter), so a consumer can be updated without an outage. Revoke cuts access
+ you enter), so a consumer can be updated without an outage. The replacement keeps the old
+ key's application name unless you enter a new one. Revoke cuts access
immediately.
Restrict its MCP tools. The MCP tools tab limits which tools
the account's keys may call on the MCP server. The server still
From f37a068d56e37fc9f9710a5e894e3f9cf9e8e0d5 Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 16:34:46 +0400
Subject: [PATCH 2/4] =?UTF-8?q?fix(AF-938):=20address=20review=20=E2=80=94?=
=?UTF-8?q?=20control-char=20validation,=20tests,=20doc=20order?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/web/AdminAuditLogController.java | 2 +-
.../DefaultRequestApplicationService.java | 5 +++-
.../web/model/ApiKeyCreateRequest.java | 2 ++
.../web/IssueServiceAccountKeyRequest.java | 2 ++
.../web/RotateServiceAccountKeyRequest.java | 2 ++
.../main/resources/i18n/messages.properties | 1 +
.../resources/i18n/messages_de.properties | 1 +
.../resources/i18n/messages_es.properties | 1 +
.../resources/i18n/messages_fr.properties | 1 +
.../resources/i18n/messages_hy.properties | 1 +
.../resources/i18n/messages_ru.properties | 1 +
.../resources/i18n/messages_zh_CN.properties | 1 +
.../internal/tools/McpToolServiceTest.java | 3 ++
.../DefaultRequestApplicationServiceTest.java | 10 +++++++
.../web/ApiKeysControllerIntegrationTest.java | 10 +++++++
.../DefaultBreakGlassServiceTest.java | 5 +++-
.../DefaultQueryReplayServiceTest.java | 5 +++-
.../DefaultQuerySubmissionServiceTest.java | 16 +++++++++++
docs/04-api-spec.md | 2 +-
docs/05-backend.md | 2 +-
e2e/tests/profile-api-keys.spec.ts | 8 +++---
.../serviceaccounts/ServiceAccountKeysTab.tsx | 6 +++-
frontend/src/locales/de.json | 5 +---
frontend/src/locales/en.json | 5 +---
frontend/src/locales/es.json | 5 +---
frontend/src/locales/fr.json | 5 +---
frontend/src/locales/hy.json | 5 +---
frontend/src/locales/ru.json | 5 +---
frontend/src/locales/zh-CN.json | 5 +---
.../admin/__tests__/AuditLogPage.test.tsx | 8 ++++++
.../ServiceAccountSettingsPage.test.tsx | 28 +++++++++++++++++++
.../__tests__/serviceAccountForm.test.ts | 10 +++++++
.../service-accounts/serviceAccountForm.ts | 3 ++
.../pages/profile/sections/ApiKeysSection.tsx | 6 +++-
frontend/src/utils/enumLabels.ts | 4 ---
.../configuration/audit-compliance/index.html | 26 ++++++++---------
36 files changed, 150 insertions(+), 57 deletions(-)
diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
index a1b845f4d..576edf482 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogController.java
@@ -228,7 +228,7 @@ private void recordExportAudit(UUID organizationId, UUID callerUserId, AuditLogQ
// become a row claiming the admin exported on bob's behalf.
metadata.put("filter_on_behalf_of_user_id", filter.onBehalfOfUserId().toString());
}
- if (filter.applicationName() != null) {
+ if (filter.applicationName() != null && !filter.applicationName().isBlank()) {
// Not "application_name": that key names the application that made THIS request.
metadata.put("filter_application_name", filter.applicationName());
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java
index a8601ca5e..d071a8ac2 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java
@@ -39,6 +39,9 @@ static String sanitize(String raw) {
if (value.isEmpty() || value.chars().anyMatch(Character::isISOControl)) {
return null;
}
- return value.length() > MAX_LENGTH ? value.substring(0, MAX_LENGTH) : value;
+ if (value.codePointCount(0, value.length()) <= MAX_LENGTH) {
+ return value;
+ }
+ return value.substring(0, value.offsetByCodePoints(0, MAX_LENGTH));
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyCreateRequest.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyCreateRequest.java
index 028965c68..e64df198c 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyCreateRequest.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyCreateRequest.java
@@ -1,6 +1,7 @@
package com.bablsoft.accessflow.security.internal.web.model;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.Instant;
@@ -11,5 +12,6 @@ public record ApiKeyCreateRequest(
String name,
Instant expiresAt,
@Size(max = 100, message = "{validation.api_key.application_name.size}")
+ @Pattern(regexp = "[^\\p{Cntrl}]*", message = "{validation.api_key.application_name.pattern}")
String applicationName
) {}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/IssueServiceAccountKeyRequest.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/IssueServiceAccountKeyRequest.java
index 85d62cea0..ea5802aaf 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/IssueServiceAccountKeyRequest.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/IssueServiceAccountKeyRequest.java
@@ -2,6 +2,7 @@
import com.bablsoft.accessflow.serviceaccounts.api.IssueServiceAccountKeyCommand;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.Instant;
@@ -14,6 +15,7 @@ public record IssueServiceAccountKeyRequest(
Instant expiresAt,
@Size(max = 100, message = "{validation.api_key.application_name.size}")
+ @Pattern(regexp = "[^\\p{Cntrl}]*", message = "{validation.api_key.application_name.pattern}")
String applicationName
) {
public IssueServiceAccountKeyCommand toCommand() {
diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/RotateServiceAccountKeyRequest.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/RotateServiceAccountKeyRequest.java
index ec973dbc2..04dc8ca8f 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/RotateServiceAccountKeyRequest.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/RotateServiceAccountKeyRequest.java
@@ -3,6 +3,7 @@
import com.bablsoft.accessflow.serviceaccounts.api.RotateServiceAccountKeyCommand;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.Duration;
@@ -23,6 +24,7 @@ public record RotateServiceAccountKeyRequest(
Duration gracePeriod,
@Size(max = 100, message = "{validation.api_key.application_name.size}")
+ @Pattern(regexp = "[^\\p{Cntrl}]*", message = "{validation.api_key.application_name.pattern}")
String applicationName
) {
@AssertTrue(message = "{validation.service_account_key_grace.positive}")
diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties
index d4a8161bf..37064527a 100644
--- a/backend/src/main/resources/i18n/messages.properties
+++ b/backend/src/main/resources/i18n/messages.properties
@@ -327,6 +327,7 @@ help_agent.test.success=Embedding model and vector store are reachable
validation.api_key.name.required=API key name is required
validation.api_key.name.size=API key name must be between 1 and 100 characters
validation.api_key.application_name.size=Application name must be at most 100 characters
+validation.api_key.application_name.pattern=Application name must not contain control characters
error.api_key.not_found=API key not found
error.api_key.duplicate_name=An API key with that name already exists. Pick a different name.
error.api_key.bootstrap_declared=This API key is declared in the bootstrap configuration and cannot be revoked here: the next restart would reactivate it. Rotate the secret in the bootstrap source, then restart.
diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties
index 9d8f97b3e..2d690c607 100644
--- a/backend/src/main/resources/i18n/messages_de.properties
+++ b/backend/src/main/resources/i18n/messages_de.properties
@@ -333,6 +333,7 @@ validation.saml_exchange.code.max=Der Austauschcode ist zu lang
validation.api_key.name.required=Der Name des API-Schlüssels ist erforderlich
validation.api_key.name.size=Der Name des API-Schlüssels muss zwischen 1 und 100 Zeichen lang sein
validation.api_key.application_name.size=Der Anwendungsname darf höchstens 100 Zeichen lang sein
+validation.api_key.application_name.pattern=Der Anwendungsname darf keine Steuerzeichen enthalten
error.api_key.not_found=API-Schlüssel nicht gefunden
error.api_key.duplicate_name=Es existiert bereits ein API-Schlüssel mit diesem Namen. Bitte einen anderen Namen wählen.
error.api_key.bootstrap_declared=Dieser API-Schlüssel ist in der Bootstrap-Konfiguration deklariert und kann hier nicht widerrufen werden: der nächste Neustart würde ihn reaktivieren. Rotieren Sie das Secret in der Bootstrap-Quelle und starten Sie anschließend neu.
diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties
index 725aa5ecb..e50c5c381 100644
--- a/backend/src/main/resources/i18n/messages_es.properties
+++ b/backend/src/main/resources/i18n/messages_es.properties
@@ -333,6 +333,7 @@ validation.saml_exchange.code.max=El código de intercambio es demasiado largo
validation.api_key.name.required=El nombre de la clave de API es obligatorio
validation.api_key.name.size=El nombre de la clave de API debe tener entre 1 y 100 caracteres
validation.api_key.application_name.size=El nombre de la aplicación debe tener como máximo 100 caracteres
+validation.api_key.application_name.pattern=El nombre de la aplicación no debe contener caracteres de control
error.api_key.not_found=Clave de API no encontrada
error.api_key.duplicate_name=Ya existe una clave de API con ese nombre. Elige un nombre distinto.
error.api_key.bootstrap_declared=Esta clave de API está declarada en la configuración de bootstrap y no puede revocarse aquí: el próximo reinicio la reactivaría. Rota el secreto en la fuente de bootstrap y reinicia.
diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties
index a1fcb57e7..568eb190a 100644
--- a/backend/src/main/resources/i18n/messages_fr.properties
+++ b/backend/src/main/resources/i18n/messages_fr.properties
@@ -335,6 +335,7 @@ validation.saml_exchange.code.max=Le code d'échange est trop long
validation.api_key.name.required=Le nom de la clé d'API est obligatoire
validation.api_key.name.size=Le nom de la clé d'API doit comporter entre 1 et 100 caractères
validation.api_key.application_name.size=Le nom de l'application doit comporter au plus 100 caractères
+validation.api_key.application_name.pattern=Le nom de l'application ne doit pas contenir de caractères de contrôle
error.api_key.not_found=Clé d'API introuvable
error.api_key.duplicate_name=Une clé d'API portant ce nom existe déjà. Choisissez un autre nom.
error.api_key.bootstrap_declared=Cette clé d'API est déclarée dans la configuration de bootstrap et ne peut pas être révoquée ici : le prochain redémarrage la réactiverait. Faites tourner le secret dans la source de bootstrap, puis redémarrez.
diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties
index 701c84412..22def1099 100644
--- a/backend/src/main/resources/i18n/messages_hy.properties
+++ b/backend/src/main/resources/i18n/messages_hy.properties
@@ -333,6 +333,7 @@ validation.saml_exchange.code.max=Փոխանակման կոդը շատ երկա
validation.api_key.name.required=API բանալիի անունը պարտադիր է
validation.api_key.name.size=API բանալիի անունը պետք է լինի 1-ից 100 նիշ
validation.api_key.application_name.size=Հավելվածի անունը պետք է լինի առավելագույնը 100 նիշ
+validation.api_key.application_name.pattern=Հավելվածի անունը չպետք է պարունակի կառավարման նիշեր
error.api_key.not_found=API բանալին չի գտնվել
error.api_key.duplicate_name=Այդ անունով API բանալի արդեն գոյություն ունի։ Ընտրեք այլ անուն։
error.api_key.bootstrap_declared=Այս API բանալին հայտարարված է bootstrap կազմաձևում և այստեղ չի կարող չեղարկվել․ հաջորդ վերագործարկումը այն կրկին կակտիվացնի։ Փոխեք գաղտնիքը bootstrap աղբյուրում, ապա վերագործարկեք։
diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties
index b87402d97..6f298024b 100644
--- a/backend/src/main/resources/i18n/messages_ru.properties
+++ b/backend/src/main/resources/i18n/messages_ru.properties
@@ -333,6 +333,7 @@ validation.saml_exchange.code.max=Код обмена слишком длинн
validation.api_key.name.required=Имя API-ключа обязательно
validation.api_key.name.size=Имя API-ключа должно содержать от 1 до 100 символов
validation.api_key.application_name.size=Имя приложения должно содержать не более 100 символов
+validation.api_key.application_name.pattern=Имя приложения не должно содержать управляющих символов
error.api_key.not_found=API-ключ не найден
error.api_key.duplicate_name=API-ключ с таким именем уже существует. Выберите другое имя.
error.api_key.bootstrap_declared=Этот API-ключ объявлен в bootstrap-конфигурации, и его нельзя отозвать здесь: следующий перезапуск снова активирует его. Смените секрет в источнике bootstrap и перезапустите приложение.
diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties
index 794bbb752..2018be11f 100644
--- a/backend/src/main/resources/i18n/messages_zh_CN.properties
+++ b/backend/src/main/resources/i18n/messages_zh_CN.properties
@@ -333,6 +333,7 @@ validation.saml_exchange.code.max=交换码过长
validation.api_key.name.required=API 密钥名称为必填项
validation.api_key.name.size=API 密钥名称长度必须在 1 到 100 个字符之间
validation.api_key.application_name.size=应用名称最多 100 个字符
+validation.api_key.application_name.pattern=应用名称不能包含控制字符
error.api_key.not_found=未找到 API 密钥
error.api_key.duplicate_name=已存在同名的 API 密钥,请选择其他名称。
error.api_key.bootstrap_declared=此 API 密钥在引导配置中声明,无法在此处吊销:下次重启会重新激活它。请在引导配置源中轮换密钥,然后重启。
diff --git a/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolServiceTest.java
index a5915e6f3..e06dee05f 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/tools/McpToolServiceTest.java
@@ -179,6 +179,8 @@ void get_query_result_rejects_non_executed_status() {
void submit_query_delegates_to_submission_service() {
var queryId = UUID.randomUUID();
var dsId = UUID.randomUUID();
+ var app = new com.bablsoft.accessflow.core.api.ClientApplication("reporting", com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY);
+ when(requestApplicationService.current()).thenReturn(java.util.Optional.of(app));
when(querySubmissionService.submit(any(QuerySubmissionService.SubmissionInput.class)))
.thenReturn(new QuerySubmissionService.QuerySubmissionResult(queryId, QueryStatus.PENDING_AI));
@@ -192,6 +194,7 @@ void submit_query_delegates_to_submission_service() {
assertThat(result.queryRequestId()).isEqualTo(queryId);
assertThat(result.status()).isEqualTo("PENDING_AI");
assertThat(captor.getValue().onBehalfOfUserId()).isNull();
+ assertThat(captor.getValue().application()).isEqualTo(app);
// The MCP surface writes QUERY_SUBMITTED itself (#874) — the REST controller does the same.
var audit = ArgumentCaptor.forClass(AuditEntry.class);
verify(auditLogService).record(audit.capture());
diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java
index c0fc8cf8d..97c2a3154 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java
@@ -98,6 +98,16 @@ void sanitizeDropsBlankAndControlCharactersAndTruncates() {
.hasSize(RequestApplicationService.MAX_LENGTH);
}
+ @Test
+ void truncationNeverSplitsASurrogatePair() {
+ var name = "a".repeat(RequestApplicationService.MAX_LENGTH - 1) + "\uD83D\uDE00tail";
+
+ var result = DefaultRequestApplicationService.sanitize(name);
+
+ assertThat(result.codePointCount(0, result.length())).isEqualTo(RequestApplicationService.MAX_LENGTH);
+ assertThat(result).endsWith("\uD83D\uDE00");
+ }
+
@Test
void headerWithControlCharactersIsIgnored() {
request.addHeader(RequestApplicationService.HEADER, "evil\u0007");
diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/ApiKeysControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/ApiKeysControllerIntegrationTest.java
index d191bb442..552168d40 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/ApiKeysControllerIntegrationTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/ApiKeysControllerIntegrationTest.java
@@ -132,6 +132,16 @@ void overlong_application_name_returns_400() {
assertThat(create).hasStatus(400);
}
+ @Test
+ void application_name_with_a_control_character_returns_400() {
+ var create = mvc.post().uri("/api/v1/me/api-keys")
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"reports\",\"application_name\":\"bad\\tname\"}")
+ .exchange();
+ assertThat(create).hasStatus(400);
+ }
+
@Test
void duplicate_name_returns_409() {
mvc.post().uri("/api/v1/me/api-keys")
diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java
index db1646221..87f78136c 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java
@@ -113,6 +113,7 @@ void breakGlassExecutesImmediatelyAndOpensRetroReview() {
var cmd = ArgumentCaptor.forClass(SubmitQueryCommand.class);
verify(queryRequestPersistenceService).submit(cmd.capture());
assertThat(cmd.getValue().submissionReason()).isEqualTo(SubmissionReason.EMERGENCY_ACCESS);
+ assertThat(cmd.getValue().application()).isEqualTo(APP);
verify(eventPublisher, never()).publishEvent(any(QuerySubmittedEvent.class));
// Force-approve then execute.
@@ -295,9 +296,11 @@ void openDeploymentBreakGlassReviewDefaultsNullJustification() {
assertThat(entity.getValue().getJustification()).isEqualTo("(none)");
}
+ private static final com.bablsoft.accessflow.core.api.ClientApplication APP = new com.bablsoft.accessflow.core.api.ClientApplication("reporting", com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY);
+
private BreakGlassInput input(String sql, boolean isAdmin) {
return new BreakGlassInput(datasourceId, sql, "prod is down", userId, organizationId,
- isAdmin, "10.0.0.1", "agent");
+ isAdmin, "10.0.0.1", "agent", null, APP);
}
private void stubParse(String sql, QueryType type, Set tables) {
diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java
index 8ff43a7cf..7b7bf99ee 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayServiceTest.java
@@ -69,9 +69,11 @@ void setUp() {
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
}
+ private static final com.bablsoft.accessflow.core.api.ClientApplication APP = new com.bablsoft.accessflow.core.api.ClientApplication("reporting", com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY);
+
private ReplayCommand command(boolean isAdmin) {
return new ReplayCommand(originalQueryId, targetDsId, userId, orgId, isAdmin,
- "1.2.3.4", "curl");
+ "1.2.3.4", "curl", null, APP);
}
private QuerySnapshotView snapshot(DbType dbType, List referenced) {
@@ -123,6 +125,7 @@ void replaysThroughWorkflowWithCallerAsSubmitter() {
assertThat(input.organizationId()).isEqualTo(orgId);
assertThat(input.scheduledFor()).isNull();
assertThat(input.submissionReason()).isEqualTo(SubmissionReason.USER_SUBMITTED);
+ assertThat(input.application()).isEqualTo(APP);
assertThat(input.ciCdOrigin()).isFalse();
assertThat(input.submittedIp()).isEqualTo("1.2.3.4");
assertThat(input.submittedUserAgent()).isEqualTo("curl");
diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java
index d8ec701b1..ded8e60ca 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java
@@ -464,6 +464,22 @@ void persistsClientContextOntoCommand() {
assertThat(cmd.ciCdOrigin()).isTrue();
}
+ @Test
+ void persistsTheCallingApplicationOntoCommand() {
+ stubParse("SELECT 1", QueryType.SELECT);
+ stubActiveDatasourceForUser();
+ stubPermission(true, false, false, null);
+ stubPersist();
+ var app = new com.bablsoft.accessflow.core.api.ClientApplication("reporting", com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY);
+
+ service.submit(new SubmissionInput(datasourceId, "SELECT 1", "ticket-42",
+ userId, organizationId, false, null, null, null, null, true, null, null, null, app));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SubmitQueryCommand.class);
+ verify(queryRequestPersistenceService).submit(captor.capture());
+ assertThat(captor.getValue().application()).isEqualTo(app);
+ }
+
@Test
void propagatesAiSuggestionSubmissionReason() {
stubParse("SELECT 1", QueryType.SELECT);
diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md
index 814ba5f66..d80269377 100644
--- a/docs/04-api-spec.md
+++ b/docs/04-api-spec.md
@@ -3692,7 +3692,7 @@ Rotation must not kill a running agent: it issues a **replacement** key and sets
| `name` | `@NotBlank`, `@Size(max=100)` — the replacement key's name; must not collide with an existing key of the account |
| `expires_at` | Optional expiry of the **replacement** key |
| `grace_period` | Optional ISO-8601 duration, must be positive. Defaults to `ACCESSFLOW_SERVICEACCOUNTS_ROTATION_GRACE` (`PT24H`, see [09-deployment.md](09-deployment.md)) |
-| `application_name` | Optional, `@Size(max=100)` (#938). Absent or `null` = the replacement **inherits** the superseded key's application name, so a rotation never silently drops the attribution |
+| `application_name` | Optional, `@Size(max=100)` (#938). Absent or `null` = the replacement **inherits** the superseded key's application name, so a rotation never silently drops the attribution; an explicit blank string clears it |
**Response 201:**
```json
diff --git a/docs/05-backend.md b/docs/05-backend.md
index 303aceaf4..44c894300 100644
--- a/docs/05-backend.md
+++ b/docs/05-backend.md
@@ -3894,7 +3894,7 @@ Mechanics:
- **Durable keyset cursor, at-least-once.** Each `audit_sinks` row carries a `(cursor_created_at, cursor_id)` keyset cursor over the append-only `audit_log` (`created_at` is not unique — same rationale as the `grant_usage_watermark`, V135; the range read rides `idx_audit_log_org_created_id`). The cursor advances only after a successful delivery, so receivers dedupe on the immutable event `id`.
- **Clustered-safe drain.** `AuditSinkDrainJob` (`audit/internal/scheduled/`, `@SchedulerLock(name = "auditSinkDrainJob", lockAtMostFor = "PT10M", lockAtLeastFor = "PT20S")`, cadence `accessflow.audit.sinks.drain-interval`, default `PT30S`) drains, per enabled+due sink, up to `max-batches-per-tick` (default 5) batches of `batch-size` (default 500) rows through the sink's deliverer.
- **Failure isolation, retry forever.** Export is strictly downstream of the synchronous audit write path — a dead sink never blocks audit writes, and per-sink failures are isolated. On failure: `last_error` recorded (truncated to 500 chars), `consecutive_failures` incremented, retry with backoff 30 s → 2 min → 10 min, then every 10 min forever; the durable cursor makes retry-forever safe (no exhaustion state). Health (cursor position, last success, last error, consecutive failures, next retry, capped behind-count) is embedded in the admin list response.
-- **Canonical event.** Every sink type serializes the same canonical audit event JSON — all `audit_log` columns, metadata embedded, ISO-8601 microsecond `created_at`, lowercase-hex `previous_hash`/`current_hash` — so any exported window is independently chain-verifiable.
+- **Canonical event.** Every sink type serializes the same canonical audit event JSON — all `audit_log` columns, metadata embedded, ISO-8601 microsecond `created_at`, lowercase-hex `previous_hash`/`current_hash` — so any exported window is independently chain-verifiable. Since #938 it also carries two **derived** top-level fields, `application_name` / `application_name_source`, copied out of `metadata` for SIEM convenience (CEF `cs5` / `cs6`); they are not `audit_log` columns and not part of the HMAC canonical form, so an external verifier must ignore them and re-hash from `metadata`.
- **WORM segments.** The S3 deliverer flushes a segment when the batch is full or the oldest pending row exceeds `segment_max_age` (default `PT15M`): key `/audit----.jsonl`, uploaded with the Object Lock retention (`retention_mode` default `COMPLIANCE`), plus a sibling `.sig` holding the base64 SHA256withRSA signature of the segment bytes (the JWT RSA key — same as compliance exports, verifiable via `GET /admin/compliance/signing-certificate`). The segment's last line carries the org chain head, so the signature covers the chain head — combined with the in-DB HMAC chain, an externally verifiable WORM copy.
- **Test endpoint.** `POST /admin/audit-sinks/{id}/test` synchronously delivers one synthetic event through the sink's deliverer (for S3: a small test segment **without** a retention lock, named `test/…`); destination rejection maps to 502 `AUDIT_SINK_TEST_FAILED`.
- **Audit of the sinks themselves.** Admin CRUD writes best-effort `AUDIT_SINK_CREATED`/`_UPDATED`/`_DELETED` rows (resource `audit_sink`; metadata name + type, never secrets). Deliveries are deliberately not audited per batch — that would feed back into the stream being drained.
diff --git a/e2e/tests/profile-api-keys.spec.ts b/e2e/tests/profile-api-keys.spec.ts
index 2561a1624..d813f97b6 100644
--- a/e2e/tests/profile-api-keys.spec.ts
+++ b/e2e/tests/profile-api-keys.spec.ts
@@ -378,22 +378,22 @@ test.describe.serial('AF-286 — /profile API keys CRUD', () => {
.click();
await expect(issuedDialog).toBeHidden({ timeout: 5_000 });
- // Columns: Name, Prefix, Created, Last used, Expires, Status, actions. Assert
+ // Columns: Name, Application, Prefix, Created, Last used, Expires, Status, actions. Assert
// the header index before using it, so a column reorder fails loudly here
// rather than silently moving the cell assertion onto the wrong column.
const table = page.getByRole('table', { name: 'API keys' });
- await expect(table.locator('thead th').nth(4)).toHaveText('Expires');
+ await expect(table.locator('thead th').nth(5)).toHaveText('Expires');
const row = table.locator('tr').filter({ hasText: KEY_NAME_EXP });
await expect(row).toBeVisible({ timeout: 10_000 });
// The Expires cell renders through Intl.DateTimeFormat, whose exact wording
// is locale-dependent — assert the year and day-of-month it must contain
// rather than the placeholder it must not be.
- const expiresCell = row.locator('td').nth(4);
+ const expiresCell = row.locator('td').nth(5);
await expect(expiresCell).toContainText(String(target.getFullYear()));
await expect(expiresCell).toContainText(String(target.getDate()));
// Still Active: the expiry is 30 days out, not passed.
- await expect(row.locator('td').nth(5)).toHaveText('Active');
+ await expect(row.locator('td').nth(6)).toHaveText('Active');
});
test.afterAll(async ({ request }) => {
diff --git a/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx b/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx
index 73962b9c5..9c81b06c0 100644
--- a/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx
+++ b/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx
@@ -34,6 +34,7 @@ import { serviceAccountErrorMessage } from '@/utils/apiErrors';
import { showApiError } from '@/utils/showApiError';
import {
KEY_FORM_CONSTRAINTS,
+ NO_CONTROL_CHARACTERS,
fieldRules,
gracePeriodOf,
keyStatus,
@@ -230,7 +231,10 @@ export function ServiceAccountKeysTab({ account }: { account: ServiceAccount })
extra={
form === 'rotate' ? t('client_application.rotate_help') : t('client_application.key_help')
}
- rules={fieldRules(t, KEY_FORM_CONSTRAINTS.application_name)}
+ rules={[
+ ...fieldRules(t, KEY_FORM_CONSTRAINTS.application_name),
+ { pattern: NO_CONTROL_CHARACTERS, message: t('client_application.key_control_chars') },
+ ]}
>
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json
index c56aec6fe..587048c43 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "Feste Rate",
"CRON": "Cron",
"ONE_TIME": "Einmalig"
- },
- "application_name_source": {
- "API_KEY": "API-Schlüssel",
- "HEADER": "Anfrage-Header"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "z. B. reporting-service",
"key_help": "Optional. Wird bei jeder Anfrage dieses Schlüssels erfasst, sodass das Audit-Log zeigt, welche Anwendung aufgerufen hat.",
"key_size": "Der Anwendungsname darf höchstens 100 Zeichen lang sein.",
+ "key_control_chars": "Der Anwendungsname darf keine Steuerzeichen enthalten.",
"rotate_help": "Leer lassen, um den Anwendungsnamen des aktuellen Schlüssels beizubehalten.",
"column": "Anwendung"
}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 623507eb2..a103bd754 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -3761,10 +3761,6 @@
"FIXED_RATE": "Fixed rate",
"CRON": "Cron",
"ONE_TIME": "One time"
- },
- "application_name_source": {
- "API_KEY": "API key",
- "HEADER": "Request header"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "e.g. reporting-service",
"key_help": "Optional. Recorded on every request this key makes, so the audit log shows which application called.",
"key_size": "Application name must be at most 100 characters.",
+ "key_control_chars": "Application name must not contain control characters.",
"rotate_help": "Leave empty to keep the current key's application name.",
"column": "Application"
}
diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json
index 5ddbaae1d..668b75897 100644
--- a/frontend/src/locales/es.json
+++ b/frontend/src/locales/es.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "Frecuencia fija",
"CRON": "Cron",
"ONE_TIME": "Única"
- },
- "application_name_source": {
- "API_KEY": "Clave de API",
- "HEADER": "Cabecera de la solicitud"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "p. ej. reporting-service",
"key_help": "Opcional. Se registra en cada solicitud que hace esta clave, para que el registro de auditoría muestre qué aplicación llamó.",
"key_size": "El nombre de la aplicación debe tener como máximo 100 caracteres.",
+ "key_control_chars": "El nombre de la aplicación no debe contener caracteres de control.",
"rotate_help": "Déjalo vacío para conservar el nombre de aplicación de la clave actual.",
"column": "Aplicación"
}
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 9ed0f0655..4e6edff5f 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "Fréquence fixe",
"CRON": "Cron",
"ONE_TIME": "Unique"
- },
- "application_name_source": {
- "API_KEY": "Clé d'API",
- "HEADER": "En-tête de requête"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "ex. reporting-service",
"key_help": "Facultatif. Enregistré sur chaque requête effectuée avec cette clé, pour que le journal d'audit indique quelle application a appelé.",
"key_size": "Le nom de l'application doit comporter au plus 100 caractères.",
+ "key_control_chars": "Le nom de l'application ne doit pas contenir de caractères de contrôle.",
"rotate_help": "Laissez vide pour conserver le nom d'application de la clé actuelle.",
"column": "Application"
}
diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json
index b6b2ab489..9d32acae7 100644
--- a/frontend/src/locales/hy.json
+++ b/frontend/src/locales/hy.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "Ֆիքսված հաճախականություն",
"CRON": "Cron",
"ONE_TIME": "Միանվագ"
- },
- "application_name_source": {
- "API_KEY": "API բանալի",
- "HEADER": "Հարցման վերնագիր"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "օր.՝ reporting-service",
"key_help": "Ոչ պարտադիր։ Գրանցվում է այս բանալիով յուրաքանչյուր հարցման մեջ, որպեսզի աուդիտի մատյանը ցույց տա, թե որ հավելվածն է կանչել։",
"key_size": "Հավելվածի անունը պետք է լինի առավելագույնը 100 նիշ։",
+ "key_control_chars": "Հավելվածի անունը չպետք է պարունակի կառավարման նիշեր։",
"rotate_help": "Թողեք դատարկ՝ ընթացիկ բանալու հավելվածի անունը պահպանելու համար։",
"column": "Հավելված"
}
diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json
index 726000649..dfa54fe13 100644
--- a/frontend/src/locales/ru.json
+++ b/frontend/src/locales/ru.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "Фиксированная частота",
"CRON": "Cron",
"ONE_TIME": "Однократно"
- },
- "application_name_source": {
- "API_KEY": "API-ключ",
- "HEADER": "Заголовок запроса"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "например, reporting-service",
"key_help": "Необязательно. Записывается в каждый запрос этого ключа, чтобы журнал аудита показывал, какое приложение его сделало.",
"key_size": "Имя приложения должно содержать не более 100 символов.",
+ "key_control_chars": "Имя приложения не должно содержать управляющих символов.",
"rotate_help": "Оставьте пустым, чтобы сохранить имя приложения текущего ключа.",
"column": "Приложение"
}
diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json
index eb19ad9c1..f0f83e4fb 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -3736,10 +3736,6 @@
"FIXED_RATE": "固定频率",
"CRON": "Cron",
"ONE_TIME": "一次性"
- },
- "application_name_source": {
- "API_KEY": "API 密钥",
- "HEADER": "请求头"
}
},
"access": {
@@ -5758,6 +5754,7 @@
"key_placeholder": "例如 reporting-service",
"key_help": "可选。此密钥发出的每个请求都会记录该名称,审计日志因此能显示是哪个应用发起的调用。",
"key_size": "应用名称最多 100 个字符。",
+ "key_control_chars": "应用名称不能包含控制字符。",
"rotate_help": "留空则沿用当前密钥的应用名称。",
"column": "应用"
}
diff --git a/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx b/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx
index 8a81a71ca..20b04a2e6 100644
--- a/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx
+++ b/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx
@@ -102,6 +102,14 @@ describe('AuditLogPage — on-behalf-of attribution (#874, #875)', () => {
expect(within(bobRow!).queryByText('reporting-service')).not.toBeInTheDocument();
});
+ it('shows the application in the detail drawer, or a dash when the row has none', async () => {
+ render(wrap());
+
+ fireEvent.click((await screen.findByText('CI bot')).closest('tr')!);
+ expect(await screen.findByTestId('audit-detail-application')).toHaveTextContent('reporting-service');
+ expect(screen.getByTestId('audit-detail-application-untrusted')).toBeInTheDocument();
+ });
+
it('seeds and applies the application filter', async () => {
render(wrap(, '/admin/audit-log?application_name=etl'));
await screen.findByText('CI bot');
diff --git a/frontend/src/pages/admin/service-accounts/__tests__/ServiceAccountSettingsPage.test.tsx b/frontend/src/pages/admin/service-accounts/__tests__/ServiceAccountSettingsPage.test.tsx
index 42819f4f0..580d72527 100644
--- a/frontend/src/pages/admin/service-accounts/__tests__/ServiceAccountSettingsPage.test.tsx
+++ b/frontend/src/pages/admin/service-accounts/__tests__/ServiceAccountSettingsPage.test.tsx
@@ -139,6 +139,34 @@ describe('ServiceAccountSettingsPage', () => {
expect(await screen.findByText('Service account updated')).toBeInTheDocument();
});
+ it('shows each key\'s application and sends a trimmed one on issue (#938)', async () => {
+ getServiceAccount.mockResolvedValue(
+ account({ api_keys: [key({ application_name: 'deploy-pipeline' }), key({ id: 'k-9', name: 'bare' })] }),
+ );
+ issueServiceAccountKey.mockResolvedValue({ api_key: key({ id: 'k-2', name: 'ci' }), raw_key: 'af_raw' });
+ render(wrap(, '/admin/service-accounts/sa-1?tab=api-keys'));
+ await screen.findByRole('heading', { name: 'CI bot' });
+
+ expect(within(panel()).getByText('deploy-pipeline')).toBeInTheDocument();
+
+ fireEvent.click(within(panel()).getByRole('button', { name: 'Issue key' }));
+ const dialog = await screen.findByRole('dialog');
+ expect(within(dialog).getByText(/Recorded on every request this key makes/)).toBeInTheDocument();
+ fireEvent.change(within(dialog).getByLabelText('Key name'), { target: { value: 'ci' } });
+ fireEvent.change(within(dialog).getByLabelText('Application name'), {
+ target: { value: ' reporting ' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Issue key' }));
+
+ await waitFor(() =>
+ expect(issueServiceAccountKey).toHaveBeenCalledWith('sa-1', {
+ name: 'ci',
+ expires_at: null,
+ application_name: 'reporting',
+ }),
+ );
+ });
+
it('issues a key and shows it exactly once', async () => {
issueServiceAccountKey.mockResolvedValue({ api_key: key({ id: 'k-2', name: 'ci' }), raw_key: 'af_raw_secret' });
render(wrap(, '/admin/service-accounts/sa-1?tab=api-keys'));
diff --git a/frontend/src/pages/admin/service-accounts/__tests__/serviceAccountForm.test.ts b/frontend/src/pages/admin/service-accounts/__tests__/serviceAccountForm.test.ts
index 465892145..568168434 100644
--- a/frontend/src/pages/admin/service-accounts/__tests__/serviceAccountForm.test.ts
+++ b/frontend/src/pages/admin/service-accounts/__tests__/serviceAccountForm.test.ts
@@ -4,6 +4,7 @@ import type { ServiceAccount } from '@/types/api';
import {
CREATE_FORM_CONSTRAINTS,
KEY_FORM_CONSTRAINTS,
+ NO_CONTROL_CHARACTERS,
allowedToolCount,
createInputFromForm,
fieldRules,
@@ -237,3 +238,12 @@ describe('keys', () => {
expect(gracePeriodOf(Number.NaN)).toBeUndefined();
});
});
+
+describe('NO_CONTROL_CHARACTERS (#938)', () => {
+ it('accepts printable names and rejects control characters', () => {
+ expect(NO_CONTROL_CHARACTERS.test('reporting-service')).toBe(true);
+ expect(NO_CONTROL_CHARACTERS.test('')).toBe(true);
+ expect(NO_CONTROL_CHARACTERS.test('tab\tname')).toBe(false);
+ expect(NO_CONTROL_CHARACTERS.test('bell\u0007')).toBe(false);
+ });
+});
diff --git a/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts b/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts
index 3e56e4fc7..ca2b892c4 100644
--- a/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts
+++ b/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts
@@ -251,6 +251,9 @@ export function suggestedRotationName(name: string, now: Date = new Date()): str
return `${base.slice(0, KEY_FORM_CONSTRAINTS.name.max - stamp.length - 1)}-${stamp}`;
}
+/** Mirrors the key requests' `@Pattern("[^\\p{Cntrl}]*")` on `application_name` (#938). */
+export const NO_CONTROL_CHARACTERS = /^\P{Cc}*$/u;
+
/** The rotation grace as the API's ISO-8601 duration; `undefined` keeps the deployment default. */
export function gracePeriodOf(hours: number | null | undefined): string | undefined {
if (typeof hours !== 'number' || !Number.isFinite(hours) || hours <= 0) return undefined;
diff --git a/frontend/src/pages/profile/sections/ApiKeysSection.tsx b/frontend/src/pages/profile/sections/ApiKeysSection.tsx
index 2696d8b5e..f7398cf55 100644
--- a/frontend/src/pages/profile/sections/ApiKeysSection.tsx
+++ b/frontend/src/pages/profile/sections/ApiKeysSection.tsx
@@ -22,6 +22,7 @@ import { apiKeysKeys, createApiKey, listApiKeys, revokeApiKey } from '@/api/apiK
import type { ApiKey, CreateApiKeyInput, CreateApiKeyResponse } from '@/types/api';
import { apiErrorTraceId, profileErrorMessage } from '@/utils/apiErrors';
import { TraceIdFooter } from '@/components/common/TraceIdFooter';
+import { NO_CONTROL_CHARACTERS } from '@/pages/admin/service-accounts/serviceAccountForm';
interface CreateFormValues {
name: string;
@@ -227,7 +228,10 @@ export function ApiKeysSection() {
name="application_name"
label={t('client_application.key_label')}
extra={t('client_application.key_help')}
- rules={[{ max: 100, message: t('client_application.key_size') }]}
+ rules={[
+ { max: 100, message: t('client_application.key_size') },
+ { pattern: NO_CONTROL_CHARACTERS, message: t('client_application.key_control_chars') },
+ ]}
>
diff --git a/frontend/src/utils/enumLabels.ts b/frontend/src/utils/enumLabels.ts
index 29bbf9640..8abdc2772 100644
--- a/frontend/src/utils/enumLabels.ts
+++ b/frontend/src/utils/enumLabels.ts
@@ -1,7 +1,6 @@
import type { TFunction } from 'i18next';
import type {
AccessGrantStatus,
- ApplicationNameSource,
SchemaChangePromotionStatus,
SchemaChangeSetStatus,
SchemaDriftBaseline,
@@ -143,9 +142,6 @@ export const OPTIMIZATION_TYPES: readonly OptimizationType[] = ['INDEX', 'REWRIT
export const optimizationTypeLabel = (t: TFunction, v: OptimizationType): string =>
t(`enums.optimization_type.${v}` as const);
-export const applicationNameSourceLabel = (t: TFunction, v: ApplicationNameSource): string =>
- t(`enums.application_name_source.${v}` as const);
-
export const submissionReasonLabel = (t: TFunction, v: SubmissionReason): string =>
t(`enums.submission_reason.${v}` as const);
diff --git a/website/docs/configuration/audit-compliance/index.html b/website/docs/configuration/audit-compliance/index.html
index 4c69dfd8c..a18de074f 100644
--- a/website/docs/configuration/audit-compliance/index.html
+++ b/website/docs/configuration/audit-compliance/index.html
@@ -306,11 +306,18 @@
What makes the AccessFlow audit log tamper-evident?
Verify chain. The Verify chain button re-walks every row's HMAC link in order and surfaces the first mismatch — useful as a recurring auditor check.
Export CSV. Streams the current filter as RFC 4180 CSV with the same columns shown in the UI. Long-running exports respect the same query budget as the table view (use date filters to keep them bounded).
+
+ Tune it. The chain-signing key defaults to a per-deployment value derived
+ from ENCRYPTION_KEY; set AUDIT_HMAC_KEY (hex, ≥ 32 bytes)
+ explicitly when you want to manage or rotate it yourself. Inserts run through a dedicated
+ AUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE
+ rights on the log.
+
Which application made a request?
- Each entry can also record the application that made the request, so you
- can tell "the reporting service" apart from "the billing job" even when both use the same
- service account. There are two ways it gets there:
+ Every audit log entry, and every query, can record the application that
+ made the request, so you can tell "the reporting service" apart from "the billing job" even
+ when both use the same service account. There are two ways it gets there:
From the API key (trusted). Give a key an Application name
@@ -326,13 +333,6 @@
Which application made a request?
and on the query's detail page. Both the audit log and the query list can be filtered by
it. It is for identification only: it never grants or blocks access.
-
- Tune it. The chain-signing key defaults to a per-deployment value derived
- from ENCRYPTION_KEY; set AUDIT_HMAC_KEY (hex, ≥ 32 bytes)
- explicitly when you want to manage or rotate it yourself. Inserts run through a dedicated
- AUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE
- rights on the log.
-
@@ -345,9 +345,9 @@
Audit sinks (SIEM & WORM streaming)
never blocks audit writes, and each sink retries forever with backoff, so nothing is
lost while a receiver is down (receivers de-duplicate on the immutable event id). Every
streamed event carries its hash-chain links, so an exported window can be verified
- independently of the database, plus the calling application (when one was recorded) as
- its own application_name / application_name_source fields —
- cs5 / cs6 in CEF.
+ independently of the database. When a request named its calling application, the event
+ also carries it as separate application_name /
+ application_name_source fields (cs5 / cs6 in CEF).
Configure it. Manage sinks at /admin/audit-sinks (requires
From b772dae8c640cfdb4244f4c5f10e3547d2e5df4b Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 16:36:14 +0400
Subject: [PATCH 3/4] chore(AF-938): renumber migration to V189 after rebase
onto main
---
...ation_name.sql => V189__add_application_name.sql} | 0
docs/03-data-model.md | 6 +++---
help-corpus/corpus.jsonl | 6 +++---
help-corpus/manifest.json | 12 ++++++------
4 files changed, 12 insertions(+), 12 deletions(-)
rename backend/src/main/resources/db/migration/{V188__add_application_name.sql => V189__add_application_name.sql} (100%)
diff --git a/backend/src/main/resources/db/migration/V188__add_application_name.sql b/backend/src/main/resources/db/migration/V189__add_application_name.sql
similarity index 100%
rename from backend/src/main/resources/db/migration/V188__add_application_name.sql
rename to backend/src/main/resources/db/migration/V189__add_application_name.sql
diff --git a/docs/03-data-model.md b/docs/03-data-model.md
index 7814c8585..a69c6f76a 100644
--- a/docs/03-data-model.md
+++ b/docs/03-data-model.md
@@ -123,7 +123,7 @@ permissions exactly — there is no separate scope model.
| `last_used_at` | TIMESTAMPTZ — bumped on each successful authentication |
| `revoked_at` | TIMESTAMPTZ — non-null when the key has been revoked; revoked keys never authenticate |
| `created_at` | TIMESTAMPTZ NOT NULL DEFAULT now() |
-| `application_name` | VARCHAR(100) nullable (#938, V188) — the calling application this key identifies (e.g. `reporting-service`). Set at issue time only (`POST /me/api-keys`, `POST /admin/service-accounts/{id}/api-keys`; a rotation inherits it unless the request names another); there is no update path, and the bootstrap-declared key carries none. Recorded on every request the key authenticates as the **trusted** `API_KEY` application source — it cannot be forged without the key. Identification and audit only, never an authorization input |
+| `application_name` | VARCHAR(100) nullable (#938, V189) — the calling application this key identifies (e.g. `reporting-service`). Set at issue time only (`POST /me/api-keys`, `POST /admin/service-accounts/{id}/api-keys`; a rotation inherits it unless the request names another); there is no update path, and the bootstrap-declared key carries none. Recorded on every request the key authenticates as the **trusted** `API_KEY` application source — it cannot be forged without the key. Identification and audit only, never an authorization input |
| `bootstrap_declared` | BOOLEAN NOT NULL DEFAULT FALSE (#871, V175) — `TRUE` on the one key the bootstrap reconciler declares for a service account (`accessflow.bootstrap.service-accounts[].api-key`). Set by `ApiKeyService.importOrUpdate`, which in the same transaction clears it on the user's other keys, so a renamed `api-key-name` turns the previous row into an ordinary revocable key. A declared key can be neither revoked nor rotated (`409 API_KEY_BOOTSTRAP_DECLARED` on `/me/api-keys`, `409 SERVICE_ACCOUNT_KEY_BOOTSTRAP_DECLARED` on the admin surface): `importOrUpdate` clears `revoked_at` on every changed reconcile, so a revoke would only appear to succeed until the next restart. **Backfill (V175):** every key already owned by a `managed_by = BOOTSTRAP` service account is marked — deliberately conservative, because the reconciler short-circuits on an unchanged fingerprint and would otherwise never re-import (and re-flag) an existing declared key; any extra key such an account already holds (a pre-#868 adopted human's personal key, or one the bot minted for itself via `/me/api-keys`) is over-marked, and the remediation the 409 names — rotate the declared secret at the bootstrap source and restart — is also what fixes it, since that changed reconcile re-flags exactly the declared key and demotes the rest. `ServiceAccountKeyBackfillIntegrationTest` replays the shipped statement |
**Indexes**
@@ -1046,8 +1046,8 @@ The central entity. Represents a single SQL submission through the platform.
| `submitted_ip` | VARCHAR(45) nullable (AF-446, Flyway `V88`) — source IP captured at submission (`X-Forwarded-For` first hop, else remote address). Read by the `source_ip` routing condition (routing runs asynchronously, after submission). |
| `submitted_user_agent` | TEXT nullable (AF-446, `V88`) — the submission `User-Agent` header. Read by the `user_agent` routing condition. |
| `cicd_origin` | BOOLEAN NOT NULL DEFAULT FALSE (AF-446, `V88`) — true when the query was submitted via an API key or with the `X-AccessFlow-CI` header. Read by the `cicd_origin` routing condition. |
-| `application_name` | VARCHAR(100) nullable (#938, V188) — the calling application, resolved at submission by `security.api.RequestApplicationService`: the presenting API key's `application_name` when it has one, else the sanitised `X-AccessFlow-Application` header (trimmed; dropped when blank or containing control characters; truncated to 100 chars), else NULL. Copied onto recurring occurrences. Partial index `idx_query_requests_application_name ON query_requests(application_name) WHERE application_name IS NOT NULL` backs the `application_name` list filter. Identification and audit only — **not** a routing operand (a future operand must fail closed on a `HEADER` source, like `cicd_origin`'s header path) |
-| `application_name_source` | ENUM `application_name_source`: `API_KEY` \| `HEADER`, nullable (#938, V188) — where `application_name` came from. `API_KEY` is trustworthy; `HEADER` is entirely client-controlled and the UI labels it *Untrusted*. NULL exactly when `application_name` is NULL |
+| `application_name` | VARCHAR(100) nullable (#938, V189) — the calling application, resolved at submission by `security.api.RequestApplicationService`: the presenting API key's `application_name` when it has one, else the sanitised `X-AccessFlow-Application` header (trimmed; dropped when blank or containing control characters; truncated to 100 chars), else NULL. Copied onto recurring occurrences. Partial index `idx_query_requests_application_name ON query_requests(application_name) WHERE application_name IS NOT NULL` backs the `application_name` list filter. Identification and audit only — **not** a routing operand (a future operand must fail closed on a `HEADER` source, like `cicd_origin`'s header path) |
+| `application_name_source` | ENUM `application_name_source`: `API_KEY` \| `HEADER`, nullable (#938, V189) — where `application_name` came from. `API_KEY` is trustworthy; `HEADER` is entirely client-controlled and the UI labels it *Untrusted*. NULL exactly when `application_name` is NULL |
| `approved_by_grant_id` | UUID nullable (#582, `V112`) — id of the `access_grant_request` whose pre-approval fast-path auto-approved this query. Bare UUID (no FK, mirroring `granted_permission_id`): the grant's lifecycle (expiry, revocation) is independent of the query's audit trail. Stamped atomically with the `PENDING_AI → APPROVED` transition by `QueryRequestStateService.approveByAccessGrant`; surfaced as `approved_by_grant` on `GET /queries/{id}`. |
| `created_at` | TIMESTAMPTZ |
| `updated_at` | TIMESTAMPTZ |
diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl
index c62f2e186..a782e3dbd 100644
--- a/help-corpus/corpus.jsonl
+++ b/help-corpus/corpus.jsonl
@@ -173,9 +173,9 @@
{"id":"b1e0442ab5a3df46","path":"website/docs/configuration/ai/index.html","url":"https://accessflow.io/docs/configuration/ai/#cfg-help-assistant","anchor":"cfg-help-assistant","title":"In-app help assistant","section":"Reference","order":0,"tokens":761,"text":"AccessFlow Docs > Reference > AI configuration > In-app help assistant (part 1 of 2)\n\nThere is a guide for this. Ask the in-app help assistant\nis the short version: start AccessFlow, bind an AI configuration, and ask it anything about the application. This section is the reference behind it.\n\nWhat it is. A chat panel, opened from a launcher in the bottom-right corner\nof every authenticated screen, that answers questions about how to use AccessFlow from the\ndocumentation bundled with the running release and cites the sections it used. It is\navailable to every signed-in user once an admin enables it, and it is a documentation reader\nonly: no tools, no actions, and no access to queries, results, audit rows, schemas or\ndatasources. The only links it ever shows are the citation chips AccessFlow itself resolves\nfrom the retrieved sections — the model emits section numbers, never URLs.\n\nConfigure it. Admin section → Help assistant (under\nSystem → AI; requires AI_MANAGE). One settings row per\norganization: Enable the help assistant and the AI\nconfiguration it asks — any of the configurations under\nAI configurations. Deleting a bound configuration unbinds the\nassistant and hides the launcher; unlike a datasource binding, it never blocks the delete.\nSaving with the assistant enabled is refused, with the specific reason, when no configuration\nis bound, when retrieval is on but the bound configuration cannot embed (no\nRAG knowledge base, no embedding provider, an Anthropic embedder, the\npgvector extension missing, the vector table skipped by\nACCESSFLOW_RAG_PGVECTOR_ENABLED=false, or an embedding width that does not match\nACCESSFLOW_RAG_PGVECTOR_DIMENSIONS), and when the bundled documentation corpus\ncould not be loaded from the build at all. Every save is audited as\nHELP_AGENT_CONFIG_UPDATED.\n\nTwo answer modes. With Answer from indexed documentation\non, the bound configuration's embedding model indexes the bundled corpus into its vector store\n— kept apart from your own knowledge documents — and each question retrieves\nSections per answer (1–20, default 6) excerpts above the Similarity\nthreshold (0–1, default 0.4), which the model must answer from and cite. With it\noff — a supported mode, and the only one an Anthropic-only install has — the assistant\nanswers from a built-in quick reference instead and cites nothing; the panel labels this\nQuick-reference mode. Retrieval falls back to the quick reference on its own, never\nto an error, when the index is missing, stale after an upgrade, or failed."}
{"id":"4afeb23562bfea78","path":"website/docs/configuration/ai/index.html","url":"https://accessflow.io/docs/configuration/ai/#cfg-help-assistant","anchor":"cfg-help-assistant","title":"In-app help assistant","section":"Reference","order":1,"tokens":757,"text":"AccessFlow Docs > Reference > AI configuration > In-app help assistant (part 2 of 2)\n\nIndexing. Runs in the background on save and, by default, once on every\nstart-up for each organization with the assistant enabled — which is how an upgrade re-indexes\nthe documentation the new build ships. The Documentation corpus panel shows\nthe bundled and indexed revisions, the last indexing time and the last error;\nRe-index documentation forces a pass and Test retrieval\nprobes the embedding model and vector store, reporting the embedding width it detected.\nACCESSFLOW_HELP_AGENT_INDEX_ON_STARTUP (default true) switches the\nstart-up pass off; ACCESSFLOW_HELP_AGENT_INDEX_BATCH_SIZE (64) sets\nhow many chunks go to the embedding model per call, and\nACCESSFLOW_HELP_AGENT_INDEX_LOCK_AT_MOST_FOR (PT30M) bounds the\nper-organization lock that keeps a multi-replica deployment from indexing once per replica.\n\nContext, conversations and limits. Send screen and permission\ncontext (default on) adds the name of the user's current screen and their permission\nnames to the prompt — never a URL, an id or any data — and sends neither when off.\nConversation turns kept (1–50, default 8) bounds how much of the conversation\nis replayed with each question, and Maximum question length (100–10,000\ncharacters, default 2,000) truncates what reaches the model. Conversations are private to\ntheir author and deleted after Keep conversations for (1–3,650 days, default\n90) by a background job that runs every ACCESSFLOW_HELP_AGENT_RETENTION_POLL_INTERVAL\n(PT6H) for every organization that ever configured the assistant. Two rate limits\napply, each counted before the model is called: the organization-wide\nACCESSFLOW_AI_RATE_LIMIT_REQUESTS_PER_MINUTE shared with query analysis, then\nQuestions per user per minute (1–120, default 6). Help tokens count against\nACCESSFLOW_AI_RATE_LIMIT_TOKENS_PER_MONTH alongside analysis; a busy conversation\nat the defaults sends roughly 8,000 prompt tokens per turn.\n\nCorpus updates and air-gaps. The documentation is bundled in the backend and\nread from there, so an install always answers about the version it runs and needs no outbound\ncall. Picking up corrected documentation published between releases is opt-in:\nACCESSFLOW_HELP_CORPUS_REMOTE_REFRESH_ENABLED (default false) checks\nACCESSFLOW_HELP_CORPUS_INDEX_URL for a newer corpus, verifies its pinned checksum,\ncaches it under ACCESSFLOW_HELP_CORPUS_CACHE_DIR and re-indexes;\nACCESSFLOW_HELP_CORPUS_OFFLINE=true keeps every corpus fetch off whatever else is\nset, the way ACCESSFLOW_DRIVERS_OFFLINE does for drivers."}
{"id":"ab67a044a47abd85","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-log","anchor":"cfg-audit-log","title":"Audit log","section":"Reference","order":0,"tokens":136,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit log\n\nWhat it is. A complete, tamper-evident record of everything that happens —\nlogins, query submissions and decisions, datasource changes, channel edits. It's your\nanswer to \"who did what, when\" for security reviews and compliance. Records are\nappend-only and cryptographically chained, so a deleted or altered entry is detectable\nafter the fact (query result data is never stored)."}
-{"id":"a539deb87f6dd981","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/","anchor":"","title":"What makes the AccessFlow audit log tamper-evident?","section":"Reference","order":0,"tokens":325,"text":"AccessFlow Docs > Reference > Audit & compliance > What makes the AccessFlow audit log tamper-evident?\n\nEvery row is append-only and carries an HMAC-SHA256 hash chained to the row before it, so altering or deleting any entry breaks the chain and is detectable. The database role the application uses has no UPDATE or DELETE privilege on the table — a separate writer role only inserts.\n\nConfigure it. Nothing to switch on — it captures automatically. Review it\nat /admin/audit-log:\n\n/admin/audit-log — filter, paginate, verify the HMAC chain, and export to CSV.\n\n- Filter and search. Narrow by action, resource type, actor user id, application, or resource id; an optional start/end date pair scopes the window.\n\n- Verify chain. The Verify chain button re-walks every row's HMAC link in order and surfaces the first mismatch — useful as a recurring auditor check.\n\n- Export CSV. Streams the current filter as RFC 4180 CSV with the same columns shown in the UI. Long-running exports respect the same query budget as the table view (use date filters to keep them bounded)."}
-{"id":"23a01b6702d68649","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-application","anchor":"cfg-audit-application","title":"Which application made a request?","section":"Reference","order":0,"tokens":391,"text":"AccessFlow Docs > Reference > Audit & compliance > Which application made a request?\n\nEach entry can also record the application that made the request, so you\ncan tell \"the reporting service\" apart from \"the billing job\" even when both use the same\nservice account. There are two ways it gets there:\n\n- From the API key (trusted). Give a key an Application name\nwhen you create it — on your profile, or on a service account's API keys tab.\nEvery request made with that key is recorded under that name. Nobody can fake it\nwithout holding the key.\n\n- From a request header (untrusted). A caller without a named key can\nsend an X-AccessFlow-Application header. Because the caller chooses the\nvalue, AccessFlow records it but marks it Untrusted wherever it is shown.\n\nThe application appears under the actor in the audit log, in the entry's detail panel,\nand on the query's detail page. Both the audit log and the query list can be filtered by\nit. It is for identification only: it never grants or blocks access.\n\nTune it. The chain-signing key defaults to a per-deployment value derived\nfrom ENCRYPTION_KEY; set AUDIT_HMAC_KEY (hex, ≥ 32 bytes)\nexplicitly when you want to manage or rotate it yourself. Inserts run through a dedicated\nAUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE\nrights on the log."}
-{"id":"435841c3edb381aa","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-sinks","anchor":"cfg-audit-sinks","title":"Audit sinks (SIEM & WORM streaming)","section":"Reference","order":0,"tokens":778,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit sinks (SIEM & WORM streaming) (part 1 of 2)\n\nWhat it is. External audit sinks stream the tamper-evident audit log to\nthe systems your SOC already watches — a SIEM, a syslog collector, your own HTTPS\nendpoint — and archive it to write-once (WORM) object storage. Delivery is\nat-least-once off a durable per-sink cursor: a slow or dead destination\nnever blocks audit writes, and each sink retries forever with backoff, so nothing is\nlost while a receiver is down (receivers de-duplicate on the immutable event id). Every\nstreamed event carries its hash-chain links, so an exported window can be verified\nindependently of the database, plus the calling application (when one was recorded) as\nits own application_name / application_name_source fields —\ncs5 / cs6 in CEF.\n\nConfigure it. Manage sinks at /admin/audit-sinks (requires\nthe AUDIT_SINK_MANAGE permission; admins hold it). Pick one of four types —\nsecret fields are write-only: encrypted at rest and shown masked as\n******** afterwards:\n\n- Splunk HEC — url (the full HTTP Event Collector endpoint) and token (masked); optional index and source.\n\n- Syslog / CEF — host, port, and protocol (TCP or TLS; TLS validates against the system truststore — there is deliberately no skip-verify option). Events arrive as RFC 5424 syslog frames carrying CEF.\n\n- Signed HTTPS batches — url and secret (masked). Batches are JSON arrays signed with the same X-AccessFlow-Signature HMAC-SHA256 contract as webhook notifications.\n\n- S3 Object Lock (WORM) — bucket, region, access_key_id, secret_access_key (masked), and retention_days; optional prefix, custom S3-compatible endpoint, retention_mode (COMPLIANCE, the immutable default, or GOVERNANCE), and segment_max_age. Audit rows are written as periodic JSONL segments under an Object Lock retention, each with a sibling .sig digital signature you can verify offline against the published signing certificate.\n\nThe list shows per-sink delivery health — cursor position, last success, last error,\nconsecutive failures, next retry, and how many events the sink is behind — and a\nTest button that synchronously pushes one synthetic event through the sink (for\nS3 it uploads a small unlocked test object, so trying a sink never creates immutable\ndata).\n\nTune it. ACCESSFLOW_AUDIT_SINKS_DRAIN_INTERVAL (streaming\ncadence, default PT30S), ACCESSFLOW_AUDIT_SINKS_BATCH_SIZE\n(rows per delivery, default 500), and\nACCESSFLOW_AUDIT_SINKS_MAX_BATCHES_PER_TICK (per-sink catch-up cap per\ntick, default 5)."}
+{"id":"a539deb87f6dd981","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/","anchor":"","title":"What makes the AccessFlow audit log tamper-evident?","section":"Reference","order":0,"tokens":417,"text":"AccessFlow Docs > Reference > Audit & compliance > What makes the AccessFlow audit log tamper-evident?\n\nEvery row is append-only and carries an HMAC-SHA256 hash chained to the row before it, so altering or deleting any entry breaks the chain and is detectable. The database role the application uses has no UPDATE or DELETE privilege on the table — a separate writer role only inserts.\n\nConfigure it. Nothing to switch on — it captures automatically. Review it\nat /admin/audit-log:\n\n/admin/audit-log — filter, paginate, verify the HMAC chain, and export to CSV.\n\n- Filter and search. Narrow by action, resource type, actor user id, application, or resource id; an optional start/end date pair scopes the window.\n\n- Verify chain. The Verify chain button re-walks every row's HMAC link in order and surfaces the first mismatch — useful as a recurring auditor check.\n\n- Export CSV. Streams the current filter as RFC 4180 CSV with the same columns shown in the UI. Long-running exports respect the same query budget as the table view (use date filters to keep them bounded).\n\nTune it. The chain-signing key defaults to a per-deployment value derived\nfrom ENCRYPTION_KEY; set AUDIT_HMAC_KEY (hex, ≥ 32 bytes)\nexplicitly when you want to manage or rotate it yourself. Inserts run through a dedicated\nAUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE\nrights on the log."}
+{"id":"23a01b6702d68649","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-application","anchor":"cfg-audit-application","title":"Which application made a request?","section":"Reference","order":0,"tokens":309,"text":"AccessFlow Docs > Reference > Audit & compliance > Which application made a request?\n\nEvery audit log entry, and every query, can record the application that\nmade the request, so you can tell \"the reporting service\" apart from \"the billing job\" even\nwhen both use the same service account. There are two ways it gets there:\n\n- From the API key (trusted). Give a key an Application name\nwhen you create it — on your profile, or on a service account's API keys tab.\nEvery request made with that key is recorded under that name. Nobody can fake it\nwithout holding the key.\n\n- From a request header (untrusted). A caller without a named key can\nsend an X-AccessFlow-Application header. Because the caller chooses the\nvalue, AccessFlow records it but marks it Untrusted wherever it is shown.\n\nThe application appears under the actor in the audit log, in the entry's detail panel,\nand on the query's detail page. Both the audit log and the query list can be filtered by\nit. It is for identification only: it never grants or blocks access."}
+{"id":"435841c3edb381aa","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-sinks","anchor":"cfg-audit-sinks","title":"Audit sinks (SIEM & WORM streaming)","section":"Reference","order":0,"tokens":785,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit sinks (SIEM & WORM streaming) (part 1 of 2)\n\nWhat it is. External audit sinks stream the tamper-evident audit log to\nthe systems your SOC already watches — a SIEM, a syslog collector, your own HTTPS\nendpoint — and archive it to write-once (WORM) object storage. Delivery is\nat-least-once off a durable per-sink cursor: a slow or dead destination\nnever blocks audit writes, and each sink retries forever with backoff, so nothing is\nlost while a receiver is down (receivers de-duplicate on the immutable event id). Every\nstreamed event carries its hash-chain links, so an exported window can be verified\nindependently of the database. When a request named its calling application, the event\nalso carries it as separate application_name /\napplication_name_source fields (cs5 / cs6 in CEF).\n\nConfigure it. Manage sinks at /admin/audit-sinks (requires\nthe AUDIT_SINK_MANAGE permission; admins hold it). Pick one of four types —\nsecret fields are write-only: encrypted at rest and shown masked as\n******** afterwards:\n\n- Splunk HEC — url (the full HTTP Event Collector endpoint) and token (masked); optional index and source.\n\n- Syslog / CEF — host, port, and protocol (TCP or TLS; TLS validates against the system truststore — there is deliberately no skip-verify option). Events arrive as RFC 5424 syslog frames carrying CEF.\n\n- Signed HTTPS batches — url and secret (masked). Batches are JSON arrays signed with the same X-AccessFlow-Signature HMAC-SHA256 contract as webhook notifications.\n\n- S3 Object Lock (WORM) — bucket, region, access_key_id, secret_access_key (masked), and retention_days; optional prefix, custom S3-compatible endpoint, retention_mode (COMPLIANCE, the immutable default, or GOVERNANCE), and segment_max_age. Audit rows are written as periodic JSONL segments under an Object Lock retention, each with a sibling .sig digital signature you can verify offline against the published signing certificate.\n\nThe list shows per-sink delivery health — cursor position, last success, last error,\nconsecutive failures, next retry, and how many events the sink is behind — and a\nTest button that synchronously pushes one synthetic event through the sink (for\nS3 it uploads a small unlocked test object, so trying a sink never creates immutable\ndata).\n\nTune it. ACCESSFLOW_AUDIT_SINKS_DRAIN_INTERVAL (streaming\ncadence, default PT30S), ACCESSFLOW_AUDIT_SINKS_BATCH_SIZE\n(rows per delivery, default 500), and\nACCESSFLOW_AUDIT_SINKS_MAX_BATCHES_PER_TICK (per-sink catch-up cap per\ntick, default 5)."}
{"id":"a88a11d688bbb7ae","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-audit-sinks","anchor":"cfg-audit-sinks","title":"Audit sinks (SIEM & WORM streaming)","section":"Reference","order":1,"tokens":153,"text":"AccessFlow Docs > Reference > Audit & compliance > Audit sinks (SIEM & WORM streaming) (part 2 of 2)\n\nS3 bucket prerequisite. The bucket must be created with versioning and\nObject Lock enabled (aws s3api create-bucket\n--object-lock-enabled-for-bucket) — Object Lock cannot be enabled on an existing\nplain bucket — and the IAM principal needs s3:PutObject and\ns3:PutObjectRetention. COMPLIANCE mode is immutable for\neveryone until the retention expires; GOVERNANCE allows privileged\noverride."}
{"id":"cef74b4b01c849a8","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#compliance-reports","anchor":"compliance-reports","title":"Compliance reports & signed exports","section":"Reference","order":0,"tokens":446,"text":"AccessFlow Docs > Reference > Audit & compliance > Compliance reports & signed exports\n\nWhat it is. Ready-made compliance reporting with audit-grade exports. Two\npre-built reports answer common auditor questions over a chosen period:\nclassified-data access (which executed queries touched PII / PCI / PHI /\nGDPR / FINANCIAL / SENSITIVE data, joined to your data-classification tags) and a\nregulatory audit trail of DDL / DELETE operations with the approvers'\nnames and, where a row-security filter or a soft-delete rule rewrote the statement, the\neffective SQL that actually ran (filter values shown as ?, never\nstored). Use it to hand a regulator or internal auditor evidence they can verify themselves.\n\nConfigure it. Build and export reports from the compliance dashboard at\n/admin/auditor — open to the read-only AUDITOR role and to\nadmins. Each report exports as a digitally signed PDF or CSV that an\nauditor can verify offline against the public key at\n/api/v1/admin/compliance/signing-certificate; every export is itself recorded\nin the audit log with its content hash, so it's tamper-evident\nend to end.\n\nTune it. ACCESSFLOW_COMPLIANCE_MAX_REPORT_PERIOD (largest\nwindow, default P366D) and ACCESSFLOW_COMPLIANCE_MAX_ROWS (row\ncap before a report is marked truncated, default 50000). Signing reuses\nJWT_PRIVATE_KEY — no extra secret required.\n\n/admin/auditor — the read-only Auditor role builds and signs compliance reports over the immutable query snapshots."}
{"id":"0bcd2b6e400f1954","path":"website/docs/configuration/audit-compliance/index.html","url":"https://accessflow.io/docs/configuration/audit-compliance/#cfg-lifecycle","anchor":"cfg-lifecycle","title":"Data lifecycle & right-to-erasure","section":"Reference","order":0,"tokens":530,"text":"AccessFlow Docs > Reference > Audit & compliance > Data lifecycle & right-to-erasure\n\nAdmin. Define retention/erasure rules at\n/admin/lifecycle/policies — per datasource, target a table / column set /\nclassification tag with a retention window (ISO-8601, e.g. P30D or\nP7Y) plus arbitrary conditions (a structured, parameter-bound\npredicate builder and a parser-validated raw-WHERE escape hatch — SQL\ndatasources only) and an action: hard-delete, soft-delete,\nor pseudonymize (salted SHA-256 / format-preserving / tokenization), with an\noptional cron schedule. A dry-run preview reports impact\nwithout executing. The scan job stages eligible work (honouring the cron); tune it with\nACCESSFLOW_LIFECYCLE_POLICY_SCAN_INTERVAL (default PT1H). Staged\nruns now execute automatically through the proxy —\nACCESSFLOW_LIFECYCLE_POLICY_EXECUTION_INTERVAL (default PT5M).\n\nAny user can file a right-to-erasure request at\n/lifecycle/erasure using the same rich configuration (subject\nidentifier and/or target table + conditions). It flows through AI-assisted scope detection\nand review-plan-based peer review: any eligible REVIEWER or\nadmin reviews it at /lifecycle/erasure-reviews (per the datasource review plan,\nmulti-stage; the submitter can never approve their own), and stale reviews auto-reject via\nACCESSFLOW_LIFECYCLE_REVIEW_TIMEOUT (default PT168H). Approved\nrequests are executed through the proxy — soft-deleted rows vanish from reads,\nDELETEs become marker updates, aged PII resolves to an irreversible salted hash\nat read time — with tamper-evident proof-of-deletion audit records and a\nretention-adherence compliance export. Tune the executor with\nACCESSFLOW_LIFECYCLE_ERASURE_EXECUTION_INTERVAL (default PT1M)."}
diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json
index 4a7095c2e..a34127bb5 100644
--- a/help-corpus/manifest.json
+++ b/help-corpus/manifest.json
@@ -1,10 +1,10 @@
{
"schemaVersion": 1,
- "corpusVersion": "027be619d58e",
- "generatedAt": "2026-09-24T12:24:17.423Z",
- "sourceCommit": "a8e38098974c571c29b0cc49e49a0f794eb8e26b",
+ "corpusVersion": "b7e0f4f488b8",
+ "generatedAt": "2026-09-24T12:36:14.636Z",
+ "sourceCommit": "f37a068d56e37fc9f9710a5e894e3f9cf9e8e0d5",
"chunkCount": 587,
- "sha256": "027be619d58e1fc3c3f71f6083a0a37bf0d4932af0731f6d41fad51926ac3e92",
+ "sha256": "b7e0f4f488b88ca88bf5248c29f68ca94cb317cea961a2722eaa77989f346c38",
"quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66",
"sources": [
{
@@ -181,7 +181,7 @@
"url": "https://accessflow.io/docs/configuration/audit-compliance/",
"section": "Reference",
"chunks": 8,
- "sha256": "bdaa3ed33b5c665e2c27c0bbc6c218609d70f7ce142a5a7b59faf1eb3297a2a8"
+ "sha256": "c6395f9049daa2405813c9488864fcae4a7f2f1fbd2e8e18e15df324edc3ff40"
},
{
"path": "website/docs/configuration/auth/index.html",
@@ -429,7 +429,7 @@
"url": "https://accessflow.io/docs/",
"section": "Navigation",
"chunks": 9,
- "sha256": "a306f6cd074c4abfb9dd59e27b9a760963df9020b83c7424590f22897d4d3e2d"
+ "sha256": "95dd1629dd086cab9bf35f8dd5bce167b02cb35159ed22ba6cc88852ea7ce1c8"
}
]
}
From 53bfb66c61f69d386818a6a34d1cf621c39d9feb Mon Sep 17 00:00:00 2001
From: Tigran Babloyan
Date: Thu, 24 Sep 2026 16:42:03 +0400
Subject: [PATCH 4/4] fix(AF-938): keep the application name on one line in
narrow columns
---
frontend/src/components/common/ClientApplicationTag.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/common/ClientApplicationTag.tsx b/frontend/src/components/common/ClientApplicationTag.tsx
index 5a089d12b..b9cfd0fcf 100644
--- a/frontend/src/components/common/ClientApplicationTag.tsx
+++ b/frontend/src/components/common/ClientApplicationTag.tsx
@@ -23,7 +23,7 @@ export function ClientApplicationTag({
if (!trimmed) return null;
const untrusted = source === 'HEADER' || source === 'header';
return (
-
+
- {trimmed}
+
+ {trimmed}
+
{untrusted && (