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..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 @@ -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 && !filter.applicationName().isBlank()) { + // 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() + .>map(app -> Map.of( + APPLICATION_NAME, app.name(), + APPLICATION_NAME_SOURCE, app.source().name().toLowerCase(Locale.ROOT))) + .orElse(Map.of()); + } +} 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 new file mode 100644 index 000000000..d071a8ac2 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationService.java @@ -0,0 +1,47 @@ +package com.bablsoft.accessflow.security.internal; + +import com.bablsoft.accessflow.core.api.ApplicationNameSource; +import com.bablsoft.accessflow.core.api.ClientApplication; +import com.bablsoft.accessflow.security.api.ApiKeyAuthentication; +import com.bablsoft.accessflow.security.api.RequestApplicationService; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Optional; + +@Service +class DefaultRequestApplicationService implements RequestApplicationService { + + @Override + public Optional current() { + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) { + return Optional.empty(); + } + // A named key always wins: a caller holding it cannot relabel itself with the header. + if (SecurityContextHolder.getContext().getAuthentication() instanceof ApiKeyAuthentication apiKey) { + var keyName = sanitize(apiKey.applicationName()); + if (keyName != null) { + return Optional.of(new ClientApplication(keyName, ApplicationNameSource.API_KEY)); + } + } + var headerName = sanitize(attributes.getRequest().getHeader(HEADER)); + return Optional.ofNullable(headerName) + .map(name -> new ClientApplication(name, ApplicationNameSource.HEADER)); + } + + static String sanitize(String raw) { + if (raw == null) { + return null; + } + var value = raw.strip(); + if (value.isEmpty() || value.chars().anyMatch(Character::isISOControl)) { + return null; + } + 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/apikey/DefaultApiKeyService.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyService.java index ef4a6eca0..1e8231f20 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyService.java @@ -31,7 +31,8 @@ public class DefaultApiKeyService implements ApiKeyService { @Override @Transactional - public IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant expiresAt) { + public IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant expiresAt, + String applicationName) { if (apiKeyRepository.existsByUserIdAndName(userId, name)) { throw new ApiKeyDuplicateNameException(name); } @@ -44,6 +45,7 @@ public IssuedApiKey issue(UUID userId, UUID organizationId, String name, Instant entity.setKeyPrefix(ApiKeyHasher.prefixOf(rawKey)); entity.setKeyHash(ApiKeyHasher.hash(rawKey)); entity.setExpiresAt(expiresAt); + entity.setApplicationName(normalizeApplicationName(applicationName)); entity.setCreatedAt(Instant.now()); var saved = apiKeyRepository.save(entity); return new IssuedApiKey(toView(saved), rawKey); @@ -159,7 +161,15 @@ public Optional resolve(String rawKey) { } catch (RuntimeException ex) { log.warn("Failed to touch last_used_at for api key {}: {}", entity.getId(), ex.getMessage()); } - return Optional.of(new ResolvedApiKey(entity.getId(), entity.getUserId())); + return Optional.of(new ResolvedApiKey(entity.getId(), entity.getUserId(), + entity.getApplicationName())); + } + + private static String normalizeApplicationName(String applicationName) { + if (applicationName == null || applicationName.isBlank()) { + return null; + } + return applicationName.strip(); } static ApiKeyView toView(ApiKeyEntity entity) { @@ -173,7 +183,8 @@ static ApiKeyView toView(ApiKeyEntity entity) { entity.getLastUsedAt(), entity.getExpiresAt(), entity.getRevokedAt(), - entity.isBootstrapDeclared() + entity.isBootstrapDeclared(), + entity.getApplicationName() ); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilter.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilter.java index ccd160c83..759f14e0d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilter.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilter.java @@ -60,7 +60,8 @@ private Optional authenticate(String rawKey) { // The key id rides on the token beside the claims (#869); the claims themselves are built // exactly as before, so downstream consumers of JwtClaims see no difference. return apiKeyService.resolve(rawKey).flatMap(resolved -> loadClaims(resolved.userId()) - .map(claims -> new ApiKeyAuthenticationToken(resolved.apiKeyId(), claims))); + .map(claims -> new ApiKeyAuthenticationToken(resolved.apiKeyId(), + resolved.applicationName(), claims))); } private Optional loadClaims(UUID userId) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationToken.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationToken.java index f8fb7e383..d7802b120 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationToken.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationToken.java @@ -17,11 +17,17 @@ class ApiKeyAuthenticationToken extends AbstractAuthenticationToken implements ApiKeyAuthentication { private final UUID apiKeyId; + private final String applicationName; private final JwtClaims claims; ApiKeyAuthenticationToken(UUID apiKeyId, JwtClaims claims) { + this(apiKeyId, null, claims); + } + + ApiKeyAuthenticationToken(UUID apiKeyId, String applicationName, JwtClaims claims) { super(JwtAuthorities.from(claims)); this.apiKeyId = apiKeyId; + this.applicationName = applicationName; this.claims = claims; setAuthenticated(true); } @@ -31,6 +37,11 @@ public UUID apiKeyId() { return apiKeyId; } + @Override + public String applicationName() { + return applicationName; + } + @Override public Object getCredentials() { return null; diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/persistence/entity/ApiKeyEntity.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/persistence/entity/ApiKeyEntity.java index c088ca57b..6c86db113 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/persistence/entity/ApiKeyEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/persistence/entity/ApiKeyEntity.java @@ -31,6 +31,14 @@ public class ApiKeyEntity { @Column(nullable = false, length = 100) private String name; + /** + * The calling application this key identifies (#938) — recorded on every request the key + * authenticates. Trustworthy by construction: it cannot be forged without the key. Set at issue + * time only. + */ + @Column(name = "application_name", length = 100) + private String applicationName; + @Column(name = "key_prefix", nullable = false, length = 16) private String keyPrefix; diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/ApiKeysController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/ApiKeysController.java index 418a04c6e..fcc241ed3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/ApiKeysController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/ApiKeysController.java @@ -52,7 +52,7 @@ ApiKeyCreateResponse create(@Valid @RequestBody ApiKeyCreateRequest request, Authentication authentication) { var claims = (JwtClaims) authentication.getPrincipal(); var issued = apiKeyService.issue(claims.userId(), claims.organizationId(), - request.name(), request.expiresAt()); + request.name(), request.expiresAt(), request.applicationName()); return ApiKeyCreateResponse.from(issued); } 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 5808b5800..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; @@ -9,5 +10,8 @@ public record ApiKeyCreateRequest( @NotBlank(message = "{validation.api_key.name.required}") @Size(min = 1, max = 100, message = "{validation.api_key.name.size}") String name, - Instant expiresAt + 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/security/internal/web/model/ApiKeyResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyResponse.java index 3c6e20dd2..3a63bf706 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/ApiKeyResponse.java @@ -13,7 +13,8 @@ public record ApiKeyResponse( Instant lastUsedAt, Instant expiresAt, Instant revokedAt, - boolean bootstrapDeclared + boolean bootstrapDeclared, + String applicationName ) { public static ApiKeyResponse from(ApiKeyView view) { return new ApiKeyResponse( @@ -24,7 +25,8 @@ public static ApiKeyResponse from(ApiKeyView view) { view.lastUsedAt(), view.expiresAt(), view.revokedAt(), - view.bootstrapDeclared() + view.bootstrapDeclared(), + view.applicationName() ); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/IssueServiceAccountKeyCommand.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/IssueServiceAccountKeyCommand.java index 08b07f24c..5b2c2af03 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/IssueServiceAccountKeyCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/IssueServiceAccountKeyCommand.java @@ -2,6 +2,13 @@ import java.time.Instant; -/** Input to {@link ServiceAccountAdminService#issueKey}; {@code expiresAt} null = non-expiring. */ -public record IssueServiceAccountKeyCommand(String name, Instant expiresAt) { +/** + * Input to {@link ServiceAccountAdminService#issueKey}; {@code expiresAt} null = non-expiring, + * {@code applicationName} (#938) null = the key names no calling application. + */ +public record IssueServiceAccountKeyCommand(String name, Instant expiresAt, String applicationName) { + + public IssueServiceAccountKeyCommand(String name, Instant expiresAt) { + this(name, expiresAt, null); + } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/RotateServiceAccountKeyCommand.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/RotateServiceAccountKeyCommand.java index 35244f53b..8fa1e7dc8 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/RotateServiceAccountKeyCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/RotateServiceAccountKeyCommand.java @@ -7,6 +7,12 @@ * Input to {@link ServiceAccountAdminService#rotateKey}: the replacement's {@code name} and optional * {@code expiresAt}, and the {@code gracePeriod} the superseded key keeps authenticating for — null * falls back to {@code accessflow.serviceaccounts.rotation-grace}. Must be positive when set. + * {@code applicationName} (#938) null = the replacement inherits the superseded key's name. */ -public record RotateServiceAccountKeyCommand(String name, Instant expiresAt, Duration gracePeriod) { +public record RotateServiceAccountKeyCommand(String name, Instant expiresAt, Duration gracePeriod, + String applicationName) { + + public RotateServiceAccountKeyCommand(String name, Instant expiresAt, Duration gracePeriod) { + this(name, expiresAt, gracePeriod, null); + } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/ServiceAccountKeyView.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/ServiceAccountKeyView.java index 104f413fd..b0cdaf036 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/ServiceAccountKeyView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/api/ServiceAccountKeyView.java @@ -6,6 +6,7 @@ /** * One of a service account's API keys, raw secret never included (#871). {@code bootstrapDeclared} * marks the key the bootstrap reconciler declared — the one an admin can neither revoke nor rotate. + * {@code applicationName} (#938) is the calling application the key identifies, or null. */ public record ServiceAccountKeyView( UUID id, @@ -15,6 +16,13 @@ public record ServiceAccountKeyView( Instant createdAt, Instant lastUsedAt, Instant expiresAt, - Instant revokedAt + Instant revokedAt, + String applicationName ) { + + public ServiceAccountKeyView(UUID id, String name, String keyPrefix, boolean bootstrapDeclared, + Instant createdAt, Instant lastUsedAt, Instant expiresAt, + Instant revokedAt) { + this(id, name, keyPrefix, bootstrapDeclared, createdAt, lastUsedAt, expiresAt, revokedAt, null); + } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminService.java index eda2abd2d..6fc109adb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminService.java @@ -184,7 +184,8 @@ public ServiceAccountIssuedKey issueKey(UUID organizationId, UUID userId, IssueServiceAccountKeyCommand command) { load(organizationId, userId); try { - var issued = apiKeyService.issue(userId, organizationId, command.name(), command.expiresAt()); + var issued = apiKeyService.issue(userId, organizationId, command.name(), command.expiresAt(), + command.applicationName()); return new ServiceAccountIssuedKey(toKeyView(issued.view()), issued.rawKey()); } catch (ApiKeyDuplicateNameException ex) { throw new ServiceAccountKeyNameConflictException(command.name()); @@ -208,7 +209,9 @@ public ServiceAccountRotatedKey rotateKey(UUID organizationId, UUID userId, UUID throw new IllegalArgumentException("Rotation grace period must be positive"); } var replacement = issueKey(organizationId, userId, - new IssueServiceAccountKeyCommand(command.name(), command.expiresAt())); + new IssueServiceAccountKeyCommand(command.name(), command.expiresAt(), + command.applicationName() != null ? command.applicationName() + : old.applicationName())); // Expire, never revoke: the old key keeps authenticating until the window elapses, so a // running agent is not cut off mid-deploy. An earlier existing expiry is kept. var graceUntil = clock.instant().plus(grace); @@ -216,7 +219,8 @@ public ServiceAccountRotatedKey rotateKey(UUID organizationId, UUID userId, UUID ? old.expiresAt() : graceUntil; apiKeyService.expireAt(userId, keyId, expiresAt); var superseded = new ServiceAccountKeyView(old.id(), old.name(), old.keyPrefix(), - old.bootstrapDeclared(), old.createdAt(), old.lastUsedAt(), expiresAt, old.revokedAt()); + old.bootstrapDeclared(), old.createdAt(), old.lastUsedAt(), expiresAt, old.revokedAt(), + old.applicationName()); return new ServiceAccountRotatedKey(replacement.apiKey(), replacement.rawKey(), superseded); } @@ -355,6 +359,6 @@ private static boolean isActive(ApiKeyView key, Instant now) { static ServiceAccountKeyView toKeyView(ApiKeyView key) { return new ServiceAccountKeyView(key.id(), key.name(), key.keyPrefix(), key.bootstrapDeclared(), - key.createdAt(), key.lastUsedAt(), key.expiresAt(), key.revokedAt()); + key.createdAt(), key.lastUsedAt(), key.expiresAt(), key.revokedAt(), key.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 aa3cf316d..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; @@ -11,9 +12,13 @@ public record IssueServiceAccountKeyRequest( @Size(max = 100, message = "{validation.service_account_key_name.size}") String name, - Instant expiresAt + 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() { - return new IssueServiceAccountKeyCommand(name, expiresAt); + return new IssueServiceAccountKeyCommand(name, expiresAt, applicationName); } } 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 92f03fb0e..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; @@ -11,6 +12,7 @@ /** * {@code gracePeriod} is how long the superseded key keeps authenticating; absent means the * configured default ({@code accessflow.serviceaccounts.rotation-grace}), present must be positive. + * {@code applicationName} absent means the replacement inherits the superseded key's (#938). */ public record RotateServiceAccountKeyRequest( @NotBlank(message = "{validation.service_account_key_name.required}") @@ -19,7 +21,11 @@ public record RotateServiceAccountKeyRequest( Instant expiresAt, - Duration gracePeriod + 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}") public boolean isGracePeriodPositive() { @@ -27,6 +33,6 @@ public boolean isGracePeriodPositive() { } public RotateServiceAccountKeyCommand toCommand() { - return new RotateServiceAccountKeyCommand(name, expiresAt, gracePeriod); + return new RotateServiceAccountKeyCommand(name, expiresAt, gracePeriod, applicationName); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountKeyResponse.java b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountKeyResponse.java index a0376351c..1ed9f4c6c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountKeyResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountKeyResponse.java @@ -13,10 +13,12 @@ public record ServiceAccountKeyResponse( Instant createdAt, Instant lastUsedAt, Instant expiresAt, - Instant revokedAt + Instant revokedAt, + String applicationName ) { public static ServiceAccountKeyResponse from(ServiceAccountKeyView view) { return new ServiceAccountKeyResponse(view.id(), view.name(), view.keyPrefix(), view.bootstrapDeclared(), - view.createdAt(), view.lastUsedAt(), view.expiresAt(), view.revokedAt()); + view.createdAt(), view.lastUsedAt(), view.expiresAt(), view.revokedAt(), + view.applicationName()); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/BreakGlassService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/BreakGlassService.java index e6437f6b7..f8e0427b2 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/BreakGlassService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/BreakGlassService.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.ClientApplication; import com.bablsoft.accessflow.core.api.QueryStatus; import java.util.UUID; @@ -69,14 +70,25 @@ record BreakGlassInput( String submittedIp, String submittedUserAgent, /** The human an API-key caller acts for (#874); attribution only, null for a human. */ - UUID onBehalfOfUserId) { + UUID onBehalfOfUserId, + /** The calling application (#938); null when unknown. */ + ClientApplication application) { + + /** Backward-compatible constructor without the #938 calling application. */ + public BreakGlassInput(UUID datasourceId, String sql, String justification, + UUID submitterUserId, UUID organizationId, boolean isAdmin, + String submittedIp, String submittedUserAgent, + UUID onBehalfOfUserId) { + this(datasourceId, sql, justification, submitterUserId, organizationId, isAdmin, + submittedIp, submittedUserAgent, onBehalfOfUserId, null); + } /** Backward-compatible constructor without the #874 on-behalf-of principal. */ public BreakGlassInput(UUID datasourceId, String sql, String justification, UUID submitterUserId, UUID organizationId, boolean isAdmin, String submittedIp, String submittedUserAgent) { this(datasourceId, sql, justification, submitterUserId, organizationId, isAdmin, - submittedIp, submittedUserAgent, null); + submittedIp, submittedUserAgent, null, null); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryReplayService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryReplayService.java index fa8c96b3a..fc9d563e7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryReplayService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryReplayService.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.ClientApplication; import com.bablsoft.accessflow.core.api.QueryStatus; import java.util.UUID; @@ -26,14 +27,24 @@ record ReplayCommand(UUID originalQueryId, UUID targetDatasourceId, UUID callerU UUID callerOrganizationId, boolean isAdmin, String ipAddress, String userAgent, /** The human an API-key caller acts for (#874); null for a human. */ - UUID onBehalfOfUserId) { + UUID onBehalfOfUserId, + /** The calling application (#938); null when unknown. */ + ClientApplication application) { + + /** Backward-compatible constructor without the #938 calling application. */ + public ReplayCommand(UUID originalQueryId, UUID targetDatasourceId, UUID callerUserId, + UUID callerOrganizationId, boolean isAdmin, String ipAddress, + String userAgent, UUID onBehalfOfUserId) { + this(originalQueryId, targetDatasourceId, callerUserId, callerOrganizationId, isAdmin, + ipAddress, userAgent, onBehalfOfUserId, null); + } /** Backward-compatible constructor without the #874 on-behalf-of principal. */ public ReplayCommand(UUID originalQueryId, UUID targetDatasourceId, UUID callerUserId, UUID callerOrganizationId, boolean isAdmin, String ipAddress, String userAgent) { this(originalQueryId, targetDatasourceId, callerUserId, callerOrganizationId, isAdmin, - ipAddress, userAgent, null); + ipAddress, userAgent, null, null); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySubmissionService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySubmissionService.java index c1516eb67..6ee10da97 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySubmissionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QuerySubmissionService.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.ClientApplication; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.SubmissionReason; @@ -30,7 +31,21 @@ record SubmissionInput( String recurrenceRule, Instant recurrenceUntil, /** The human an API-key caller acts for (#874); null for a human submission. */ - UUID onBehalfOfUserId) { + UUID onBehalfOfUserId, + /** The calling application (#938); null when unknown. */ + ClientApplication application) { + + /** Backward-compatible constructor without the #938 calling application. */ + public SubmissionInput(UUID datasourceId, String sql, String justification, + UUID submitterUserId, UUID organizationId, boolean isAdmin, + Instant scheduledFor, SubmissionReason submissionReason, + String submittedIp, String submittedUserAgent, boolean ciCdOrigin, + String recurrenceRule, Instant recurrenceUntil, + UUID onBehalfOfUserId) { + this(datasourceId, sql, justification, submitterUserId, organizationId, isAdmin, + scheduledFor, submissionReason, submittedIp, submittedUserAgent, ciCdOrigin, + recurrenceRule, recurrenceUntil, onBehalfOfUserId, null); + } /** Backward-compatible constructor without the #874 on-behalf-of principal. */ public SubmissionInput(UUID datasourceId, String sql, String justification, @@ -50,7 +65,7 @@ public SubmissionInput(UUID datasourceId, String sql, String justification, String submittedIp, String submittedUserAgent, boolean ciCdOrigin) { this(datasourceId, sql, justification, submitterUserId, organizationId, isAdmin, scheduledFor, submissionReason, submittedIp, submittedUserAgent, ciCdOrigin, - null, null, null); + null, null, (UUID) null); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java index d57bc991a..9110cba7c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java @@ -91,7 +91,8 @@ public BreakGlassResult breakGlassExecute(BreakGlassInput input) { null, null, null, - input.onBehalfOfUserId())); + input.onBehalfOfUserId(), + input.application())); // SQL review findings are recorded for the retro-review but never gate an emergency (#864): // break-glass bypasses the decision chain the BLOCK guard lives in, by design. sqlReviewFindingService.recordForQuery(queryId, diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayService.java index 54c287a71..51a4a0568 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryReplayService.java @@ -66,7 +66,8 @@ public ReplayResult replay(ReplayCommand command) { false, null, null, - command.onBehalfOfUserId())); + command.onBehalfOfUserId(), + command.application())); return new ReplayResult(result.id(), result.status(), snapshot.schemaHash(), targetSchemaHash, snapshot.datasourceId(), target.id()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionService.java index 8e533ef39..ea791d881 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionService.java @@ -94,7 +94,8 @@ public QuerySubmissionResult submit(SubmissionInput input) { input.recurrenceRule(), input.recurrenceUntil(), initialNextRunAt, - input.onBehalfOfUserId())); + input.onBehalfOfUserId(), + input.application())); // Deterministic SQL review runs here, synchronously and before the AI is even asked (#864), // so the findings exist for the review decision even when AI analysis is skipped or fails. // Same transaction as the row itself; a not-applicable engine records nothing. diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassController.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassController.java index 18e3da389..6849a0f58 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassController.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.audit.api.RequestAuditContext; import com.bablsoft.accessflow.security.api.JwtClaims; +import com.bablsoft.accessflow.security.api.RequestApplicationService; import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService; import com.bablsoft.accessflow.workflow.api.BreakGlassService; import com.bablsoft.accessflow.workflow.api.BreakGlassService.BreakGlassInput; @@ -31,6 +32,7 @@ class BreakGlassController { private final BreakGlassService breakGlassService; private final OnBehalfOfPrincipalService onBehalfOfPrincipalService; + private final RequestApplicationService requestApplicationService; @PostMapping @Operation(summary = "Execute an emergency (break-glass) query immediately, bypassing approval") @@ -52,7 +54,8 @@ BreakGlassExecuteResponse breakGlass(@Valid @RequestBody BreakGlassSubmitRequest caller.has(Permission.QUERY_ADMIN), auditContext.ipAddress(), auditContext.userAgent(), - onBehalfOfPrincipalService.current().orElse(null))); + onBehalfOfPrincipalService.current().orElse(null), + requestApplicationService.current().orElse(null))); return BreakGlassExecuteResponse.from(result); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java index c05fcc48f..435ceb3d0 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponse.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonRawValue; import com.bablsoft.accessflow.access.api.AccessGrantView; +import com.bablsoft.accessflow.core.api.ApplicationNameSource; import com.bablsoft.accessflow.core.api.AiProviderType; import com.bablsoft.accessflow.core.api.DbType; import com.bablsoft.accessflow.core.api.DecisionType; @@ -58,7 +59,11 @@ public record QueryDetailResponse( Instant createdAt, Instant updatedAt, /** The human an API-key submitter acted for (#874); null for a human submission. */ - OnBehalfOfRef onBehalfOf) { + OnBehalfOfRef onBehalfOf, + /** The calling application (#938); null when unknown. */ + String applicationName, + /** {@code API_KEY} (trustworthy) or {@code HEADER} (client-controlled). */ + ApplicationNameSource applicationNameSource) { public static QueryDetailResponse from(QueryDetailView view) { return from(view, null, null); @@ -154,7 +159,9 @@ public static QueryDetailResponse from(QueryDetailView view, MatchedRoutingPolic view.createdAt(), view.updatedAt(), view.onBehalfOfUserId() == null ? null - : new OnBehalfOfRef(view.onBehalfOfUserId(), view.onBehalfOfEmail())); + : new OnBehalfOfRef(view.onBehalfOfUserId(), view.onBehalfOfEmail()), + view.applicationName(), + view.applicationNameSource()); } /** A ticket auto-created in an external ticketing system for this query (AF-453). */ diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItem.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItem.java index be5dada14..afea1869c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItem.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItem.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.internal.web; +import com.bablsoft.accessflow.core.api.ApplicationNameSource; import com.bablsoft.accessflow.core.api.QueryListItemView; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.QueryType; @@ -21,7 +22,11 @@ public record QueryListItem( Instant scheduledFor, boolean recurring, UUID recurringParentId, - Instant createdAt) { + Instant createdAt, + /** The calling application (#938); null when unknown. */ + String applicationName, + /** {@code API_KEY} (trustworthy) or {@code HEADER} (client-controlled). */ + ApplicationNameSource applicationNameSource) { public static QueryListItem from(QueryListItemView view) { return new QueryListItem( @@ -37,7 +42,9 @@ public static QueryListItem from(QueryListItemView view) { view.scheduledFor(), view.recurring(), view.recurringParentId(), - view.createdAt()); + view.createdAt(), + view.applicationName(), + view.applicationNameSource()); } public record DatasourceRef(UUID id, String name) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java index 5d1f027aa..462d7903b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadController.java @@ -95,6 +95,8 @@ QueryListPageResponse list( @RequestParam(required = false) Instant to, @Parameter(description = "Filter by query type") @RequestParam(name = "query_type", required = false) QueryType queryType, + @Parameter(description = "Filter by the recorded calling application (exact match)") + @RequestParam(name = "application_name", required = false) String applicationName, @Parameter(hidden = true) @RequestParam(name = "datasourceId", required = false) UUID legacyDatasourceId, @Parameter(hidden = true) @@ -111,7 +113,7 @@ QueryListPageResponse list( var filter = buildFilter(caller, status, firstNonNull(datasourceId, legacyDatasourceId), firstNonNull(submittedBy, legacySubmittedBy), - firstNonNull(queryType, legacyQueryType), from, to); + firstNonNull(queryType, legacyQueryType), from, to, applicationName); var page = queryRequestLookupService.findForOrganization(filter, SpringPageableAdapter.toPageRequest(pageable)) .map(QueryListItem::from); @@ -134,6 +136,8 @@ ResponseEntity exportCsv( @RequestParam(required = false) Instant to, @Parameter(description = "Filter by query type") @RequestParam(name = "query_type", required = false) QueryType queryType, + @Parameter(description = "Filter by the recorded calling application (exact match)") + @RequestParam(name = "application_name", required = false) String applicationName, @Parameter(hidden = true) @RequestParam(name = "datasourceId", required = false) UUID legacyDatasourceId, @Parameter(hidden = true) @@ -145,7 +149,7 @@ ResponseEntity exportCsv( var filter = buildFilter(caller, status, firstNonNull(datasourceId, legacyDatasourceId), firstNonNull(submittedBy, legacySubmittedBy), - firstNonNull(queryType, legacyQueryType), from, to); + firstNonNull(queryType, legacyQueryType), from, to, applicationName); var export = queryCsvExportService.exportQueries(filter); var headers = new HttpHeaders(); @@ -160,10 +164,11 @@ ResponseEntity exportCsv( private static QueryListFilter buildFilter(JwtClaims caller, QueryStatus status, UUID datasourceId, UUID submittedBy, - QueryType queryType, Instant from, Instant to) { + QueryType queryType, Instant from, Instant to, + String applicationName) { var effectiveSubmitter = caller.has(Permission.QUERY_ADMIN) ? submittedBy : caller.userId(); return new QueryListFilter(caller.organizationId(), effectiveSubmitter, datasourceId, - status, queryType, from, to); + status, queryType, from, to, applicationName); } // The camelCase names are the original bindings, kept as deprecated aliases so existing diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReplayController.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReplayController.java index 7cbc288df..13a5c9a2e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReplayController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QueryReplayController.java @@ -7,6 +7,7 @@ import com.bablsoft.accessflow.audit.api.AuditResourceType; import com.bablsoft.accessflow.audit.api.RequestAuditContext; import com.bablsoft.accessflow.security.api.JwtClaims; +import com.bablsoft.accessflow.security.api.RequestApplicationService; import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService; import com.bablsoft.accessflow.workflow.api.QueryReplayService; import com.bablsoft.accessflow.workflow.api.QueryReplayService.ReplayCommand; @@ -46,6 +47,7 @@ class QueryReplayController { private final AuditLogService auditLogService; private final MessageSource messageSource; private final OnBehalfOfPrincipalService onBehalfOfPrincipalService; + private final RequestApplicationService requestApplicationService; @PostMapping("/{id}/replay") @Operation(summary = "Replay an executed query's snapshot against a test datasource", @@ -71,7 +73,8 @@ ResponseEntity replay( caller.has(Permission.QUERY_ADMIN), auditContext.ipAddress(), auditContext.userAgent(), - onBehalfOfPrincipalService.current().orElse(null))); + onBehalfOfPrincipalService.current().orElse(null), + requestApplicationService.current().orElse(null))); recordAudit(caller, id, result, auditContext); return ResponseEntity.accepted().body(new SubmitQueryResponse( result.newQueryId(), result.status(), null, null, null)); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionController.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionController.java index 40a2d47bd..0ee278535 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionController.java @@ -8,6 +8,7 @@ import com.bablsoft.accessflow.audit.api.RequestAuditContext; import com.bablsoft.accessflow.core.api.SubmissionReason; import com.bablsoft.accessflow.security.api.ApiKeyAuthentication; +import com.bablsoft.accessflow.security.api.RequestApplicationService; import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService; import com.bablsoft.accessflow.security.api.JwtClaims; import com.bablsoft.accessflow.workflow.api.QuerySubmissionService; @@ -39,6 +40,7 @@ class QuerySubmissionController { private final QuerySubmissionService querySubmissionService; private final AuditLogService auditLogService; private final OnBehalfOfPrincipalService onBehalfOfPrincipalService; + private final RequestApplicationService requestApplicationService; @PostMapping @Operation(summary = "Submit a query for AI analysis and (eventually) human review") @@ -71,7 +73,8 @@ ResponseEntity submit(@Valid @RequestBody SubmitQueryReques ciCdOrigin, body.recurrenceRule(), body.recurrenceUntil(), - onBehalfOfPrincipalService.current().orElse(null))); + onBehalfOfPrincipalService.current().orElse(null), + requestApplicationService.current().orElse(null))); recordAudit(caller, result.id(), body, submissionReason, auditContext); return ResponseEntity.accepted().body(new SubmitQueryResponse( result.id(), result.status(), null, null, null)); diff --git a/backend/src/main/resources/db/migration/V189__add_application_name.sql b/backend/src/main/resources/db/migration/V189__add_application_name.sql new file mode 100644 index 000000000..08802252f --- /dev/null +++ b/backend/src/main/resources/db/migration/V189__add_application_name.sql @@ -0,0 +1,17 @@ +-- Calling application (#938): which application submitted a request. Identification and audit +-- only — never an authorization input. +-- API_KEY — the name stored on the api_keys row that authenticated the call. Trustworthy: it +-- cannot be forged without the key. +-- HEADER — the caller-supplied X-AccessFlow-Application header. Entirely client-controlled. +-- audit_log gets NO column (AuditChainHasher canonicalises a fixed ten-field list); the name rides +-- in metadata as application_name / application_name_source, which the MAC already covers. +CREATE TYPE application_name_source AS ENUM ('API_KEY', 'HEADER'); + +ALTER TABLE api_keys ADD COLUMN application_name VARCHAR(100); + +ALTER TABLE query_requests ADD COLUMN application_name VARCHAR(100); +ALTER TABLE query_requests ADD COLUMN application_name_source application_name_source; + +CREATE INDEX idx_query_requests_application_name + ON query_requests (application_name) + WHERE application_name IS NOT NULL; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index df1740893..37064527a 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -326,6 +326,8 @@ help_agent.test.success=Embedding model and vector store are reachable # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 af3e655c0..2d690c607 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -332,6 +332,8 @@ validation.saml_exchange.code.max=Der Austauschcode ist zu lang # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 e70300995..e50c5c381 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -332,6 +332,8 @@ validation.saml_exchange.code.max=El código de intercambio es demasiado largo # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 2f0644267..568eb190a 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -334,6 +334,8 @@ validation.saml_exchange.code.max=Le code d'échange est trop long # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 868bcfc72..22def1099 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -332,6 +332,8 @@ validation.saml_exchange.code.max=Փոխանակման կոդը շատ երկա # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 a70b383ac..6f298024b 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -332,6 +332,8 @@ validation.saml_exchange.code.max=Код обмена слишком длинн # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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 5d0d75672..2018be11f 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -332,6 +332,8 @@ validation.saml_exchange.code.max=交换码过长 # ── API Keys / MCP ──────────────────────────────────────────────────────────── 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/audit/internal/AuditLogSpecificationsTest.java b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecificationsTest.java index 94b32e8eb..8349d0b1e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecificationsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/AuditLogSpecificationsTest.java @@ -199,6 +199,31 @@ void onBehalfOfFilterMatchesTheMetadataKey() { verify(cb, never()).equal(eq(actorIdPath), any(Object.class)); } + @Test + void applicationNameFilterMatchesTheMetadataKey() { + var metadataPath = mock(Path.class); + var literal = mock(Expression.class); + var extracted = mock(Expression.class); + when(root.get("metadata")).thenReturn(metadataPath); + when(cb.literal("application_name")).thenReturn(literal); + when(cb.function(eq("jsonb_extract_path_text"), eq(String.class), eq(metadataPath), eq(literal))) + .thenReturn(extracted); + var query = new AuditLogQuery(null, null, null, null, null, null, null, " reporting "); + + AuditLogSpecifications.forQuery(UUID.randomUUID(), query).toPredicate(root, cq, cb); + + verify(cb).equal(extracted, "reporting"); + } + + @Test + void blankApplicationNameIsIgnored() { + var query = new AuditLogQuery(null, null, null, null, null, null, null, " "); + + AuditLogSpecifications.forQuery(UUID.randomUUID(), query).toPredicate(root, cq, cb); + + verify(root, never()).get("metadata"); + } + @Test void emptyFilterNeverTouchesTheMetadataColumn() { AuditLogSpecifications.forQuery(UUID.randomUUID(), AuditLogQuery.empty()).toPredicate(root, cq, cb); diff --git a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriterTest.java b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriterTest.java index 77c2f0127..ba1cfeae0 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/AuditExportEventWriterTest.java @@ -53,6 +53,32 @@ void toEventMapsAllFieldsAndHexesHashes() { assertThat(event.createdAt()).isEqualTo(CREATED_AT); assertThat(event.previousHash()).isEqualTo("00abff"); assertThat(event.currentHash()).isEqualTo("012c"); + assertThat(event.applicationName()).isNull(); + assertThat(event.applicationNameSource()).isNull(); + } + + @Test + void liftsTheCallingApplicationOutOfTheMetadata() { + var row = entity(); + row.setMetadata("{\"application_name\":\"reporting\",\"application_name_source\":\"api_key\"}"); + + var event = writer.toEvent(row); + var node = mapper.readTree(writer.toJson(event)); + + assertThat(event.applicationName()).isEqualTo("reporting"); + assertThat(event.applicationNameSource()).isEqualTo("api_key"); + assertThat(node.get("application_name").asString()).isEqualTo("reporting"); + assertThat(node.get("application_name_source").asString()).isEqualTo("api_key"); + } + + @Test + void ignoresANonStringApplicationNameAndCorruptMetadata() { + var row = entity(); + row.setMetadata("{\"application_name\":42}"); + assertThat(writer.toEvent(row).applicationName()).isNull(); + + row.setMetadata("{corrupt"); + assertThat(writer.toEvent(row).applicationName()).isNull(); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatterTest.java b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatterTest.java index 102f822bd..dbe66cebf 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/sink/CefFormatterTest.java @@ -41,6 +41,19 @@ void formatsRfc5424FrameWithCefHeader() { assertThat(message).doesNotEndWith(" "); } + @Test + void emitsTheCallingApplicationAsCs5AndCs6() { + var event = new AuditExportEvent(EVENT_ID, UUID.randomUUID(), ACTOR_ID, "QUERY_SUBMITTED", + "query_request", RESOURCE_ID, "{}", null, null, CREATED_AT, null, null, + "reporting", "header"); + + var message = formatter.format(event); + + assertThat(message).contains("cs5Label=application_name cs5=reporting"); + assertThat(message).contains("cs6Label=application_name_source cs6=header"); + assertThat(formatter.format(event("QUERY_SUBMITTED"))).doesNotContain("cs5"); + } + @Test void severityHeuristicFlagsDestructiveActions() { assertThat(CefFormatter.cefSeverity("QUERY_BREAK_GLASS_EXECUTED")).isEqualTo(7); diff --git a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogControllerIntegrationTest.java index c1c208664..d4553f956 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/audit/internal/web/AdminAuditLogControllerIntegrationTest.java @@ -168,6 +168,48 @@ void onBehalfOfRowsResolveTheEmailAndFilter() throws Exception { assertThat(bad).hasStatus(400); } + @Test + void applicationNameFiltersOnTheMetadataKey() { + var tag = "reporting-" + UUID.randomUUID(); + auditLogService.record(new AuditEntry(AuditAction.QUERY_SUBMITTED, AuditResourceType.QUERY_REQUEST, + UUID.randomUUID(), org.getId(), admin.getId(), + Map.of("application_name", tag, "application_name_source", "api_key"), null, null)); + auditLogService.record(new AuditEntry(AuditAction.QUERY_SUBMITTED, AuditResourceType.QUERY_REQUEST, + UUID.randomUUID(), org.getId(), admin.getId(), + Map.of("application_name", "other-app", "application_name_source", "header"), null, null)); + + var filtered = mvc.get().uri("/api/v1/admin/audit-log?applicationName=" + tag) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + + assertThat(filtered).hasStatus(200); + assertThat(filtered).bodyJson().extractingPath("$.total_elements").asNumber().isEqualTo(1); + assertThat(filtered).bodyJson().extractingPath("$.content[0].metadata.application_name_source") + .asString().isEqualTo("api_key"); + } + + @Test + void requestHeaderIsStampedOnAuditRowsTheRequestWrites() { + var tag = "notebook-" + UUID.randomUUID(); + var export = mvc.get().uri("/api/v1/admin/audit-log/export.csv?applicationName=nothing-matches") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .header("X-AccessFlow-Application", tag) + .exchange(); + assertThat(export).hasStatus(200); + + var rows = mvc.get().uri("/api/v1/admin/audit-log?applicationName=" + tag) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + + assertThat(rows).bodyJson().extractingPath("$.total_elements").asNumber().isEqualTo(1); + assertThat(rows).bodyJson().extractingPath("$.content[0].action").asString() + .isEqualTo("AUDIT_LOG_EXPORTED"); + assertThat(rows).bodyJson().extractingPath("$.content[0].metadata.application_name_source") + .asString().isEqualTo("header"); + assertThat(rows).bodyJson().extractingPath("$.content[0].metadata.filter_application_name") + .asString().isEqualTo("nothing-matches"); + } + @Test void analystGets403() { var result = mvc.get().uri("/api/v1/admin/audit-log") diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/ClientApplicationTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/ClientApplicationTest.java new file mode 100644 index 000000000..0ed183d2c --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/ClientApplicationTest.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.core.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class ClientApplicationTest { + + @Test + void onlyAnApiKeySourceIsTrusted() { + assertThat(new ClientApplication("a", ApplicationNameSource.API_KEY).trusted()).isTrue(); + assertThat(new ClientApplication("a", ApplicationNameSource.HEADER).trusted()).isFalse(); + } + + @Test + void requiresBothFields() { + assertThatNullPointerException().isThrownBy(() -> new ClientApplication(null, ApplicationNameSource.HEADER)); + assertThatNullPointerException().isThrownBy(() -> new ClientApplication("a", null)); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupServiceTest.java index 6fec9b51e..326c72d26 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestLookupServiceTest.java @@ -198,6 +198,8 @@ void findForOrganizationMapsListItemsWithoutAiAnalysis() { var orgId = UUID.randomUUID(); var entity = entityWith(UUID.randomUUID(), UUID.randomUUID(), orgId, UUID.randomUUID(), "alice@example.com", QueryStatus.PENDING_AI); + entity.setApplicationName("reporting"); + entity.setApplicationNameSource(com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); var pageable = PageRequest.of(0, 20); when(queryRequestRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), eq(pageable))) @@ -217,6 +219,9 @@ void findForOrganizationMapsListItemsWithoutAiAnalysis() { assertThat(item.aiRiskScore()).isNull(); assertThat(item.queryType()).isEqualTo(QueryType.SELECT); assertThat(item.status()).isEqualTo(QueryStatus.PENDING_AI); + assertThat(item.applicationName()).isEqualTo("reporting"); + assertThat(item.applicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); } @Test @@ -255,6 +260,8 @@ void findDetailByIdReturnsViewForOwningOrg() { "alice@example.com", QueryStatus.EXECUTED); entity.setRowsAffected(5L); entity.setExecutionDurationMs(99); + entity.setApplicationName("etl"); + entity.setApplicationNameSource(com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER); entity.setUpdatedAt(Instant.parse("2025-01-15T11:00:00Z")); var aiId = UUID.randomUUID(); entity.setAiAnalysisId(aiId); @@ -291,6 +298,9 @@ void findDetailByIdReturnsViewForOwningOrg() { assertThat(detail.aiAnalysis().failed()).isFalse(); assertThat(detail.aiAnalysis().errorMessage()).isNull(); assertThat(detail.reviewDecisions()).isEmpty(); + assertThat(detail.applicationName()).isEqualTo("etl"); + assertThat(detail.applicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceServiceTest.java index d501e5125..8fd55f2f2 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestPersistenceServiceTest.java @@ -71,6 +71,33 @@ void submitInsertsQueryRequestWithDefaults() { assertThat(saved.getStatus()) .isEqualTo(com.bablsoft.accessflow.core.api.QueryStatus.PENDING_AI); assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getApplicationName()).isNull(); + assertThat(saved.getApplicationNameSource()).isNull(); + } + + @Test + void submitStoresTheCallingApplication() { + var datasourceId = UUID.randomUUID(); + var userId = UUID.randomUUID(); + var datasource = new DatasourceEntity(); + datasource.setId(datasourceId); + var user = new UserEntity(); + user.setId(userId); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(datasource)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(queryRequestRepository.save(any(QueryRequestEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.submit(new SubmitQueryCommand(datasourceId, userId, "SELECT 1", QueryType.SELECT, + false, null, null, SubmissionReason.USER_SUBMITTED, null, null, true, null, null, + null, null, new com.bablsoft.accessflow.core.api.ClientApplication("reporting", + com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(QueryRequestEntity.class); + verify(queryRequestRepository).save(captor.capture()); + assertThat(captor.getValue().getApplicationName()).isEqualTo("reporting"); + assertThat(captor.getValue().getApplicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER); } @Test @@ -131,6 +158,8 @@ private QueryRequestEntity recurringParent() { @Test void createRecurringOccurrenceCopiesParentFieldsAndAdvancesCursorAtomically() { var parent = recurringParent(); + parent.setApplicationName("reporting"); + parent.setApplicationNameSource(com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); var nextRunAt = java.time.Instant.now().plusSeconds(6 * 3600); when(queryRequestRepository.findByIdForUpdate(parent.getId())) .thenReturn(Optional.of(parent)); @@ -155,6 +184,9 @@ void createRecurringOccurrenceCopiesParentFieldsAndAdvancesCursorAtomically() { .isEqualTo(com.bablsoft.accessflow.core.api.QueryStatus.APPROVED); assertThat(child.getSubmissionReason()).isEqualTo(SubmissionReason.RECURRING); assertThat(child.getRecurringParentId()).isEqualTo(parent.getId()); + assertThat(child.getApplicationName()).isEqualTo("reporting"); + assertThat(child.getApplicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); // The child never inherits the series definition — only the parent carries it. assertThat(child.getRecurrenceRule()).isNull(); assertThat(child.getRecurrenceNextRunAt()).isNull(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecificationsTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecificationsTest.java index a0820d93b..c5d2e636c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecificationsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/QueryRequestSpecificationsTest.java @@ -150,6 +150,21 @@ void onlyToBoundAddsUpperExclusivePredicateOnly() { verify(cb, never()).greaterThanOrEqualTo(any(Expression.class), any(Instant.class)); } + @Test + void applicationNameFilterIsAnExactStrippedMatchAndBlankIsIgnored() { + var applicationNamePath = mock(Path.class); + when(root.get("applicationName")).thenReturn(applicationNamePath); + var orgId = UUID.randomUUID(); + + QueryRequestSpecifications.forFilter(new QueryListFilter(orgId, null, null, null, null, null, null, + " reporting ")).toPredicate(root, cq, cb); + QueryRequestSpecifications.forFilter(new QueryListFilter(orgId, null, null, null, null, null, null, + " ")).toPredicate(root, cq, cb); + + verify(cb).equal(applicationNamePath, "reporting"); + verify(cb, org.mockito.Mockito.times(1)).equal(eq(applicationNamePath), any(Object.class)); + } + private static QueryListFilter filter(UUID orgId, UUID userId, UUID dsId, QueryStatus status, QueryType queryType, Instant from, Instant to) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/config/McpServerConfigurationTest.java b/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/config/McpServerConfigurationTest.java index f51b84c71..48197ba25 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/config/McpServerConfigurationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/mcp/internal/config/McpServerConfigurationTest.java @@ -30,6 +30,7 @@ void provider_wraps_every_tool_in_a_guard_and_keeps_the_advertised_names() { Mockito.mock(com.bablsoft.accessflow.workflow.api.QuerySubmissionService.class), Mockito.mock(com.bablsoft.accessflow.workflow.api.QueryLifecycleService.class), Mockito.mock(OnBehalfOfPrincipalService.class), + Mockito.mock(com.bablsoft.accessflow.security.api.RequestApplicationService.class), Mockito.mock(com.bablsoft.accessflow.audit.api.AuditLogService.class)); var reviewTools = new McpReviewToolService(new McpCurrentUser(), Mockito.mock(com.bablsoft.accessflow.workflow.api.ReviewService.class)); 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 fa729caf2..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 @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.audit.api.AuditEntry; import com.bablsoft.accessflow.audit.api.AuditResourceType; import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.security.api.RequestApplicationService; import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService; import com.bablsoft.accessflow.core.api.DatabaseSchemaView; import com.bablsoft.accessflow.core.api.DatasourceAdminService; @@ -54,6 +55,7 @@ class McpToolServiceTest { @Mock QuerySubmissionService querySubmissionService; @Mock QueryLifecycleService queryLifecycleService; @Mock OnBehalfOfPrincipalService onBehalfOfPrincipalService; + @Mock RequestApplicationService requestApplicationService; @Mock AuditLogService auditLogService; McpToolService tools; @@ -65,7 +67,7 @@ void setUp() { var currentUser = new McpCurrentUser(); tools = new McpToolService(currentUser, datasourceAdminService, queryRequestLookupService, queryResultPersistenceService, querySubmissionService, queryLifecycleService, - onBehalfOfPrincipalService, auditLogService); + onBehalfOfPrincipalService, requestApplicationService, auditLogService); userId = UUID.randomUUID(); orgId = UUID.randomUUID(); authenticateAs(UserRoleType.ANALYST); @@ -177,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)); @@ -190,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/ApplicationAuditMetadataContributorTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributorTest.java new file mode 100644 index 000000000..1c03be226 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/ApplicationAuditMetadataContributorTest.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.security.internal; + +import com.bablsoft.accessflow.core.api.ApplicationNameSource; +import com.bablsoft.accessflow.core.api.ClientApplication; +import com.bablsoft.accessflow.security.api.RequestApplicationService; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApplicationAuditMetadataContributorTest { + + private final RequestApplicationService requestApplicationService = mock(RequestApplicationService.class); + private final ApplicationAuditMetadataContributor contributor = + new ApplicationAuditMetadataContributor(requestApplicationService); + + @Test + void emptyWhenNoApplicationIsKnown() { + when(requestApplicationService.current()).thenReturn(Optional.empty()); + + assertThat(contributor.contribute()).isEmpty(); + } + + @Test + void stampsTheNameAndALowercaseSource() { + when(requestApplicationService.current()).thenReturn( + Optional.of(new ClientApplication("reporting", ApplicationNameSource.API_KEY))); + + assertThat(contributor.contribute()).isEqualTo(Map.of( + "application_name", "reporting", + "application_name_source", "api_key")); + } + + @Test + void marksAHeaderSource() { + when(requestApplicationService.current()).thenReturn( + Optional.of(new ClientApplication("cli", ApplicationNameSource.HEADER))); + + assertThat(contributor.contribute()).containsEntry("application_name_source", "header"); + } +} 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 new file mode 100644 index 000000000..97c2a3154 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/DefaultRequestApplicationServiceTest.java @@ -0,0 +1,149 @@ +package com.bablsoft.accessflow.security.internal; + +import com.bablsoft.accessflow.core.api.ApplicationNameSource; +import com.bablsoft.accessflow.core.api.ClientApplication; +import com.bablsoft.accessflow.security.api.ApiKeyAuthentication; +import com.bablsoft.accessflow.security.api.RequestApplicationService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultRequestApplicationServiceTest { + + private final DefaultRequestApplicationService service = new DefaultRequestApplicationService(); + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void reset() { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + } + + @Test + void emptyOffTheRequestThread() { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.getContext().setAuthentication(new StubApiKeyToken("reporting")); + + assertThat(service.current()).isEmpty(); + } + + @Test + void emptyWhenNeitherKeyNorHeaderNamesOne() { + SecurityContextHolder.getContext().setAuthentication(new StubApiKeyToken(null)); + + assertThat(service.current()).isEmpty(); + } + + @Test + void namedKeyIsTrustedAndBeatsTheHeader() { + SecurityContextHolder.getContext().setAuthentication(new StubApiKeyToken(" reporting ")); + request.addHeader(RequestApplicationService.HEADER, "spoofed"); + + assertThat(service.current()) + .contains(new ClientApplication("reporting", ApplicationNameSource.API_KEY)); + } + + @Test + void unnamedKeyFallsBackToTheUntrustedHeader() { + SecurityContextHolder.getContext().setAuthentication(new StubApiKeyToken(" ")); + request.addHeader(RequestApplicationService.HEADER, "etl-runner"); + + var app = service.current().orElseThrow(); + assertThat(app).isEqualTo(new ClientApplication("etl-runner", ApplicationNameSource.HEADER)); + assertThat(app.trusted()).isFalse(); + } + + @Test + void jwtSessionUsesTheHeader() { + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("u", null)); + request.addHeader(RequestApplicationService.HEADER, "notebook"); + + assertThat(service.current()) + .contains(new ClientApplication("notebook", ApplicationNameSource.HEADER)); + } + + @Test + void anonymousRequestUsesTheHeader() { + request.addHeader(RequestApplicationService.HEADER, "cli"); + + assertThat(service.current()).map(ClientApplication::source).contains(ApplicationNameSource.HEADER); + } + + @Test + void sanitizeDropsBlankAndControlCharactersAndTruncates() { + assertThat(DefaultRequestApplicationService.sanitize(null)).isNull(); + assertThat(DefaultRequestApplicationService.sanitize(" ")).isNull(); + assertThat(DefaultRequestApplicationService.sanitize("bad\nvalue")).isNull(); + assertThat(DefaultRequestApplicationService.sanitize("tab\tvalue")).isNull(); + assertThat(DefaultRequestApplicationService.sanitize(" ok ")).isEqualTo("ok"); + var longName = "a".repeat(RequestApplicationService.MAX_LENGTH + 20); + assertThat(DefaultRequestApplicationService.sanitize(longName)) + .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"); + + assertThat(service.current()).isEmpty(); + } + + private static final class StubApiKeyToken extends AbstractAuthenticationToken + implements ApiKeyAuthentication { + + private final String applicationName; + + StubApiKeyToken(String applicationName) { + super(List.of()); + this.applicationName = applicationName; + setAuthenticated(true); + } + + @Override + public UUID apiKeyId() { + return UUID.randomUUID(); + } + + @Override + public String applicationName() { + return applicationName; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return "bot"; + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyServiceTest.java index 10c9d83cb..85defccf0 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/apikey/DefaultApiKeyServiceTest.java @@ -63,6 +63,18 @@ void issue_persists_a_new_key_and_returns_the_raw_value_once() { assertThat(captor.getValue().getRevokedAt()).isNull(); } + @Test + void issue_stores_a_stripped_application_name_and_treats_blank_as_none() { + when(apiKeyRepository.existsByUserIdAndName(any(), any())).thenReturn(false); + when(apiKeyRepository.save(any(ApiKeyEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var named = service.issue(userId, orgId, "ci", null, " reporting "); + var blank = service.issue(userId, orgId, "ci-2", null, " "); + + assertThat(named.view().applicationName()).isEqualTo("reporting"); + assertThat(blank.view().applicationName()).isNull(); + } + @Test void issue_rejects_duplicate_name_for_same_user() { when(apiKeyRepository.existsByUserIdAndName(userId, "ci")).thenReturn(true); @@ -269,6 +281,18 @@ void resolve_returns_key_and_user_ids_for_valid_key_and_touches_last_used() { verify(apiKeyRepository).touchLastUsedAt(eq(entity.getId()), any(Instant.class)); } + @Test + void resolve_carries_the_keys_application_name() { + var raw = ApiKeyHasher.generate(); + var entity = newEntity(userId); + entity.setKeyHash(ApiKeyHasher.hash(raw)); + entity.setApplicationName("reporting"); + when(apiKeyRepository.findByKeyHash(entity.getKeyHash())).thenReturn(Optional.of(entity)); + + assertThat(service.resolve(raw)) + .contains(new ResolvedApiKey(entity.getId(), userId, "reporting")); + } + @Test void resolve_still_succeeds_when_last_used_touch_fails() { var raw = ApiKeyHasher.generate(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilterTest.java index bd28387d5..fa7fc56e9 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationFilterTest.java @@ -146,7 +146,8 @@ void already_authenticated_skips_lookup() throws Exception { void resolved_token_exposes_the_originating_api_key_id() throws Exception { var userId = UUID.randomUUID(); var apiKeyId = UUID.randomUUID(); - when(apiKeyService.resolve("af_valid")).thenReturn(Optional.of(new ResolvedApiKey(apiKeyId, userId))); + when(apiKeyService.resolve("af_valid")) + .thenReturn(Optional.of(new ResolvedApiKey(apiKeyId, userId, "reporting"))); when(userProfileService.getProfile(userId)).thenReturn(activeUser(userId, UUID.randomUUID())); var req = new MockHttpServletRequest(); @@ -156,6 +157,7 @@ void resolved_token_exposes_the_originating_api_key_id() throws Exception { var auth = SecurityContextHolder.getContext().getAuthentication(); assertThat(auth).isInstanceOf(ApiKeyAuthentication.class); assertThat(((ApiKeyAuthentication) auth).apiKeyId()).isEqualTo(apiKeyId); + assertThat(((ApiKeyAuthentication) auth).applicationName()).isEqualTo("reporting"); // The principal is byte-for-byte the JWT-path shape: the key id rides beside it, not in it. assertThat(((JwtClaims) auth.getPrincipal()).userId()).isEqualTo(userId); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationTokenTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationTokenTest.java index 97fbda464..13ac9c809 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationTokenTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/filter/ApiKeyAuthenticationTokenTest.java @@ -31,5 +31,13 @@ void implementsApiKeyAuthenticationMarkerForChannelDetection() { var token = new ApiKeyAuthenticationToken(UUID.randomUUID(), claims); assertThat(token).isInstanceOf(ApiKeyAuthentication.class); assertThat(((ApiKeyAuthentication) token).apiKeyId()).isEqualTo(token.apiKeyId()); + assertThat(token.applicationName()).isNull(); + } + + @Test + void carriesTheKeysApplicationName() { + var claims = JwtClaims.forSystemRole(UUID.randomUUID(), "u@e.c", UserRoleType.ANALYST, UUID.randomUUID()); + var token = new ApiKeyAuthenticationToken(UUID.randomUUID(), "reporting", claims); + assertThat(((ApiKeyAuthentication) token).applicationName()).isEqualTo("reporting"); } } 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 bdf4e2b59..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 @@ -106,6 +106,42 @@ void create_returns_raw_key_once_then_list_omits_it() { assertThat(list).bodyText().doesNotContain("raw_key"); } + @Test + void create_stores_the_application_name_and_list_shows_it() { + var create = mvc.post().uri("/api/v1/me/api-keys") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"reports\",\"application_name\":\"reporting-service\"}").exchange(); + assertThat(create).hasStatus(201); + assertThat(create).bodyJson().extractingPath("$.api_key.application_name").asString() + .isEqualTo("reporting-service"); + + var list = mvc.get().uri("/api/v1/me/api-keys") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).exchange(); + assertThat(list).bodyJson().extractingPath("$[0].application_name").asString() + .isEqualTo("reporting-service"); + } + + @Test + void overlong_application_name_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\":\"%s\"}".formatted("a".repeat(101))) + .exchange(); + 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/serviceaccounts/internal/DefaultServiceAccountAdminServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminServiceTest.java index 1661dc566..01cd90bee 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/DefaultServiceAccountAdminServiceTest.java @@ -484,7 +484,7 @@ void issueKeyIssuesOnBehalfOfTheAccountAndReturnsThePlaintextOnce() { stubLoad(); var expires = NOW.plusSeconds(3600); var view = key(userId, "ci", expires, null, false, null); - when(apiKeyService.issue(userId, ORG, "ci", expires)).thenReturn(new IssuedApiKey(view, "af_raw")); + when(apiKeyService.issue(userId, ORG, "ci", expires, null)).thenReturn(new IssuedApiKey(view, "af_raw")); var issued = service.issueKey(ORG, userId, new IssueServiceAccountKeyCommand("ci", expires)); @@ -497,7 +497,7 @@ void issueKeyIssuesOnBehalfOfTheAccountAndReturnsThePlaintextOnce() { @Test void issueKeyTranslatesADuplicateNameIntoTheModulesConflict() { stubLoad(); - when(apiKeyService.issue(userId, ORG, "ci", null)).thenThrow(new ApiKeyDuplicateNameException("ci")); + when(apiKeyService.issue(userId, ORG, "ci", null, null)).thenThrow(new ApiKeyDuplicateNameException("ci")); assertThatThrownBy(() -> service.issueKey(ORG, userId, new IssueServiceAccountKeyCommand("ci", null))) .isInstanceOf(ServiceAccountKeyNameConflictException.class) .satisfies(ex -> assertThat(((ServiceAccountKeyNameConflictException) ex).name()).isEqualTo("ci")); @@ -509,7 +509,7 @@ void rotateKeyIssuesTheReplacementAndExpiresTheOldKeyAfterTheDefaultGrace() { var old = key(userId, "ci", null, null, false, NOW.minusSeconds(60)); when(apiKeyService.list(userId)).thenReturn(List.of(old)); var replacement = key(userId, "ci-2", null, null, false, null); - when(apiKeyService.issue(userId, ORG, "ci-2", null)).thenReturn(new IssuedApiKey(replacement, "af_new")); + when(apiKeyService.issue(userId, ORG, "ci-2", null, null)).thenReturn(new IssuedApiKey(replacement, "af_new")); var rotated = service.rotateKey(ORG, userId, old.id(), new RotateServiceAccountKeyCommand("ci-2", null, null)); @@ -523,13 +523,40 @@ void rotateKeyIssuesTheReplacementAndExpiresTheOldKeyAfterTheDefaultGrace() { assertThat(rotated.supersededKey().lastUsedAt()).isEqualTo(NOW.minusSeconds(60)); } + @Test + void rotateKeyInheritsTheSupersededKeysApplicationNameUnlessOneIsGiven() { + stubLoad(); + var old = new ApiKeyView(UUID.randomUUID(), userId, ORG, "ci", "af_ci", NOW.minusSeconds(3600), + null, null, null, false, "reporting"); + when(apiKeyService.list(userId)).thenReturn(List.of(old)); + when(apiKeyService.issue(userId, ORG, "ci-2", null, "reporting")) + .thenReturn(new IssuedApiKey(new ApiKeyView(UUID.randomUUID(), userId, ORG, "ci-2", "af_ci2", + NOW, null, null, null, false, "reporting"), "af_new")); + + var rotated = service.rotateKey(ORG, userId, old.id(), new RotateServiceAccountKeyCommand("ci-2", null, null)); + + assertThat(rotated.apiKey().applicationName()).isEqualTo("reporting"); + assertThat(rotated.supersededKey().applicationName()).isEqualTo("reporting"); + + var older = new ApiKeyView(UUID.randomUUID(), userId, ORG, "ci-3", "af_ci3", NOW.minusSeconds(3600), + null, null, null, false, "reporting"); + when(apiKeyService.list(userId)).thenReturn(List.of(older)); + when(apiKeyService.issue(userId, ORG, "ci-4", null, "billing")) + .thenReturn(new IssuedApiKey(key(userId, "ci-4", null, null, false, null), "af_x")); + + service.rotateKey(ORG, userId, older.id(), + new RotateServiceAccountKeyCommand("ci-4", null, null, "billing")); + + verify(apiKeyService).issue(userId, ORG, "ci-4", null, "billing"); + } + @Test void rotateKeyHonoursARequestGraceAndKeepsAnEarlierExistingExpiry() { stubLoad(); var soon = NOW.plusSeconds(30); var old = key(userId, "ci", soon, null, false, null); when(apiKeyService.list(userId)).thenReturn(List.of(old)); - when(apiKeyService.issue(userId, ORG, "ci-2", soon)) + when(apiKeyService.issue(userId, ORG, "ci-2", soon, null)) .thenReturn(new IssuedApiKey(key(userId, "ci-2", soon, null, false, null), "af_new")); var rotated = service.rotateKey(ORG, userId, old.id(), @@ -541,7 +568,7 @@ void rotateKeyHonoursARequestGraceAndKeepsAnEarlierExistingExpiry() { var later = key(userId, "ci-3", null, null, false, null); var oldSoon = key(userId, "ci-4", soon, null, false, null); when(apiKeyService.list(userId)).thenReturn(List.of(oldSoon)); - when(apiKeyService.issue(userId, ORG, "ci-5", null)).thenReturn(new IssuedApiKey(later, "af_x")); + when(apiKeyService.issue(userId, ORG, "ci-5", null, null)).thenReturn(new IssuedApiKey(later, "af_x")); service.rotateKey(ORG, userId, oldSoon.id(), new RotateServiceAccountKeyCommand("ci-5", null, null)); @@ -556,7 +583,7 @@ void rotateKeyRejectsANonPositiveGrace() { assertThatThrownBy(() -> service.rotateKey(ORG, userId, old.id(), new RotateServiceAccountKeyCommand("ci-2", null, Duration.ZERO))) .isInstanceOf(IllegalArgumentException.class); - verify(apiKeyService, never()).issue(any(), any(), any(), any()); + verify(apiKeyService, never()).issue(any(), any(), any(), any(), any()); } @Test @@ -569,7 +596,7 @@ void rotateKeyRefusesTheBootstrapDeclaredKey() { .isInstanceOf(ServiceAccountKeyBootstrapDeclaredException.class) .satisfies(ex -> assertThat(((ServiceAccountKeyBootstrapDeclaredException) ex).apiKeyId()) .isEqualTo(declared.id())); - verify(apiKeyService, never()).issue(any(), any(), any(), any()); + verify(apiKeyService, never()).issue(any(), any(), any(), any(), any()); verify(apiKeyService, never()).expireAt(any(), any(), any()); } @@ -601,7 +628,7 @@ void rotateKeyPropagatesAReplacementNameConflictWithoutTouchingTheOldKey() { stubLoad(); var old = key(userId, "ci", null, null, false, null); when(apiKeyService.list(userId)).thenReturn(List.of(old)); - when(apiKeyService.issue(userId, ORG, "ci", null)).thenThrow(new ApiKeyDuplicateNameException("ci")); + when(apiKeyService.issue(userId, ORG, "ci", null, null)).thenThrow(new ApiKeyDuplicateNameException("ci")); assertThatThrownBy(() -> service.rotateKey(ORG, userId, old.id(), new RotateServiceAccountKeyCommand("ci", null, null))) .isInstanceOf(ServiceAccountKeyNameConflictException.class); diff --git a/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountControllerTest.java index e1d33e07d..fd1f45486 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountControllerTest.java @@ -203,7 +203,7 @@ void issueKeyReturns201WithTheRawKeyAndNeverAuditsIt() { when(service.issueKey(eq(organizationId), eq(accountId), any())).thenReturn(issued); var expires = Instant.parse("2027-01-01T00:00:00Z"); - var response = controller.issueKey(accountId, new IssueServiceAccountKeyRequest("ci", expires), + var response = controller.issueKey(accountId, new IssueServiceAccountKeyRequest("ci", expires, null), authentication, auditContext); var captor = ArgumentCaptor.forClass(IssueServiceAccountKeyCommand.class); @@ -229,7 +229,7 @@ void rotateKeyReturns201AndAuditsBothKeyIds() { when(service.rotateKey(eq(organizationId), eq(accountId), eq(keyId), any())).thenReturn(rotated); var response = controller.rotateKey(accountId, keyId, - new RotateServiceAccountKeyRequest("ci-2", null, Duration.ofHours(1)), authentication, auditContext); + new RotateServiceAccountKeyRequest("ci-2", null, Duration.ofHours(1), null), authentication, auditContext); var captor = ArgumentCaptor.forClass(RotateServiceAccountKeyCommand.class); verify(service).rotateKey(eq(organizationId), eq(accountId), eq(keyId), captor.capture()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountWebModelsTest.java b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountWebModelsTest.java index 97da903ac..2ddada0ed 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountWebModelsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/serviceaccounts/internal/web/ServiceAccountWebModelsTest.java @@ -191,19 +191,21 @@ void updateRequestRejectsAFieldThatIsBothSetAndCleared() { @Test void keyRequestsMapToCommandsAndValidateTheGrace() { var expires = Instant.parse("2027-01-01T00:00:00Z"); - var issue = new IssueServiceAccountKeyRequest("ci", expires).toCommand(); + var issue = new IssueServiceAccountKeyRequest("ci", expires, "reporting").toCommand(); assertThat(issue.name()).isEqualTo("ci"); + assertThat(issue.applicationName()).isEqualTo("reporting"); assertThat(issue.expiresAt()).isEqualTo(expires); - var rotate = new RotateServiceAccountKeyRequest("ci-2", expires, Duration.ofMinutes(5)); + var rotate = new RotateServiceAccountKeyRequest("ci-2", expires, Duration.ofMinutes(5), "etl"); assertThat(rotate.isGracePeriodPositive()).isTrue(); assertThat(rotate.toCommand().name()).isEqualTo("ci-2"); assertThat(rotate.toCommand().expiresAt()).isEqualTo(expires); assertThat(rotate.toCommand().gracePeriod()).isEqualTo(Duration.ofMinutes(5)); + assertThat(rotate.toCommand().applicationName()).isEqualTo("etl"); - assertThat(new RotateServiceAccountKeyRequest("x", null, null).isGracePeriodPositive()).isTrue(); - assertThat(new RotateServiceAccountKeyRequest("x", null, Duration.ZERO).isGracePeriodPositive()).isFalse(); - assertThat(new RotateServiceAccountKeyRequest("x", null, Duration.ofSeconds(-1)).isGracePeriodPositive()) + assertThat(new RotateServiceAccountKeyRequest("x", null, null, null).isGracePeriodPositive()).isTrue(); + assertThat(new RotateServiceAccountKeyRequest("x", null, Duration.ZERO, null).isGracePeriodPositive()).isFalse(); + assertThat(new RotateServiceAccountKeyRequest("x", null, Duration.ofSeconds(-1), null).isGracePeriodPositive()) .isFalse(); } @Test 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/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassControllerTest.java index 3136feb3b..537460c09 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/BreakGlassControllerTest.java @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.UserRoleType; import com.bablsoft.accessflow.security.api.JwtClaims; +import com.bablsoft.accessflow.security.api.RequestApplicationService; import com.bablsoft.accessflow.serviceaccounts.api.OnBehalfOfPrincipalService; import com.bablsoft.accessflow.workflow.api.BreakGlassService; import com.bablsoft.accessflow.workflow.api.BreakGlassService.BreakGlassInput; @@ -27,8 +28,9 @@ class BreakGlassControllerTest { private final BreakGlassService breakGlassService = mock(BreakGlassService.class); private final OnBehalfOfPrincipalService onBehalfOfPrincipalService = mock(OnBehalfOfPrincipalService.class); + private final RequestApplicationService requestApplicationService = mock(RequestApplicationService.class); private final BreakGlassController controller = new BreakGlassController(breakGlassService, - onBehalfOfPrincipalService); + onBehalfOfPrincipalService, requestApplicationService); private final UUID organizationId = UUID.randomUUID(); private final UUID userId = UUID.randomUUID(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java index 5d634b7d4..9c4d40922 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryDetailResponseTest.java @@ -230,6 +230,25 @@ void effectiveSqlIsNullUnlessSuppliedAndCopiedWhenPresent() { .isEqualTo("SELECT * FROM (SELECT * FROM t WHERE region = ?) t"); } + @Test + void callingApplicationIsCopiedOntoTheResponse() { + var m = minimalView(); + assertThat(QueryDetailResponse.from(m).applicationName()).isNull(); + + var view = new QueryDetailView(m.id(), m.datasourceId(), m.datasourceName(), m.dbType(), + m.organizationId(), m.submittedByUserId(), m.submittedByEmail(), + m.submittedByDisplayName(), m.sqlText(), m.queryType(), m.status(), m.justification(), + null, null, null, null, null, null, null, null, null, null, null, null, List.of(), + null, null, null, null, null, null, m.createdAt(), m.updatedAt(), null, null, + "reporting", com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER); + + var response = QueryDetailResponse.from(view); + + assertThat(response.applicationName()).isEqualTo("reporting"); + assertThat(response.applicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER); + } + @Test void linkedTicketsAreEmptyForThreeArgOverloadAndNullList() { assertThat(QueryDetailResponse.from(minimalView()).linkedTickets()).isEmpty(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItemTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItemTest.java index 81a119c72..bb042cf31 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItemTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryListItemTest.java @@ -43,6 +43,21 @@ void fromBuildsNestedRefsAndCopiesAllFields() { assertThat(item.createdAt()).isEqualTo(Instant.parse("2026-05-01T10:00:00Z")); } + @Test + void fromCarriesTheCallingApplication() { + var view = new QueryListItemView(UUID.randomUUID(), UUID.randomUUID(), "ds", + UUID.randomUUID(), "a@b.com", "A", + QueryType.SELECT, QueryStatus.EXECUTED, null, null, false, + null, false, null, Instant.now(), "reporting", + com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); + + var item = QueryListItem.from(view); + + assertThat(item.applicationName()).isEqualTo("reporting"); + assertThat(item.applicationNameSource()) + .isEqualTo(com.bablsoft.accessflow.core.api.ApplicationNameSource.API_KEY); + } + @Test void fromCarriesRecurringSeriesMarkers() { var parentId = UUID.randomUUID(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java index c36cea609..485af5c0f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QueryReadControllerIntegrationTest.java @@ -168,7 +168,7 @@ void listBindsTheDocumentedSnakeCaseFilters() { var submitter = UUID.randomUUID(); var filter = listFilterVia("/api/v1/queries?status=EXECUTED&datasource_id=" + datasourceId - + "&submitted_by=" + submitter + "&query_type=UPDATE" + + "&submitted_by=" + submitter + "&query_type=UPDATE&application_name=reporting" + "&from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z", adminToken); assertThat(filter.organizationId()).isEqualTo(org.getId()); @@ -178,6 +178,7 @@ void listBindsTheDocumentedSnakeCaseFilters() { assertThat(filter.queryType()).isEqualTo(QueryType.UPDATE); assertThat(filter.from()).isEqualTo(Instant.parse("2026-05-01T00:00:00Z")); assertThat(filter.to()).isEqualTo(Instant.parse("2026-06-01T00:00:00Z")); + assertThat(filter.applicationName()).isEqualTo("reporting"); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionControllerIntegrationTest.java index 31ae778d3..c8918e58a 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/QuerySubmissionControllerIntegrationTest.java @@ -177,6 +177,25 @@ void submitWithCiHeaderFlagsCiCdOrigin() { assertThat(captor.getValue().ciCdOrigin()).isTrue(); } + @Test + void submitRecordsTheApplicationHeaderAsAnUntrustedSource() { + when(querySubmissionService.submit(any())) + .thenReturn(new QuerySubmissionResult(UUID.randomUUID(), QueryStatus.PENDING_AI)); + + var response = mvc.post().uri("/api/v1/queries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .header("X-AccessFlow-Application", "reporting-service") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"datasource_id\":\"%s\",\"sql\":\"SELECT 1\"}".formatted(UUID.randomUUID())) + .exchange(); + + assertThat(response).hasStatus(202); + var captor = ArgumentCaptor.forClass(SubmissionInput.class); + verify(querySubmissionService).submit(captor.capture()); + assertThat(captor.getValue().application()).isEqualTo(new com.bablsoft.accessflow.core.api.ClientApplication( + "reporting-service", com.bablsoft.accessflow.core.api.ApplicationNameSource.HEADER)); + } + @Test void submitWithoutCiHeaderDoesNotFlagCiCdOrigin() { when(querySubmissionService.submit(any())) @@ -192,6 +211,7 @@ void submitWithoutCiHeaderDoesNotFlagCiCdOrigin() { var captor = ArgumentCaptor.forClass(SubmissionInput.class); verify(querySubmissionService).submit(captor.capture()); assertThat(captor.getValue().ciCdOrigin()).isFalse(); + assertThat(captor.getValue().application()).isNull(); } @Test diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 20f454990..a69c6f76a 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -123,6 +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, 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** @@ -1045,6 +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, 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 | @@ -1684,7 +1687,7 @@ Append-only tamper-evident log of every meaningful action in the system. **No qu | `action` | VARCHAR(100) — e.g. `QUERY_SUBMITTED`, `QUERY_APPROVED`, `DATASOURCE_CREATED` | | `resource_type` | VARCHAR(100) — e.g. `query_request`, `datasource`, `user` | | `resource_id` | UUID | -| `metadata` | JSONB — context-specific details (no query result data). Since #874 also the **request provenance** an `audit.api.AuditMetadataContributor` adds on the request thread: on every API-key request `api_key_id` and `service_account` (boolean), plus `on_behalf_of_user_id` when the request named a human. Explicit keys the caller placed on the entry always win on a collision (`trigger=` is never clobbered). Rows written off the request thread (after-commit listeners, scheduled jobs) get nothing from the contributor — the query-lifecycle rows carry `on_behalf_of_user_id` explicitly from the request row instead. A synchronous **null-actor** row written inside an agent's request (e.g. `SQL_REVIEW_BLOCKED`, `trigger=sql_review`) also carries these keys: they describe the request the system action ran in, never an actor claim | +| `metadata` | JSONB — context-specific details (no query result data). Since #874 also the **request provenance** an `audit.api.AuditMetadataContributor` adds on the request thread: on every API-key request `api_key_id` and `service_account` (boolean), plus `on_behalf_of_user_id` when the request named a human. Since #938, on **any** request that names a calling application (a named API key, or the `X-AccessFlow-Application` header — JWT sessions included), `application_name` and `application_name_source` (`api_key` \| `header`, lowercase) from `security.internal.ApplicationAuditMetadataContributor`; `audit_log` has no column for it, because `AuditChainHasher` canonicalises a fixed ten-field list. Explicit keys the caller placed on the entry always win on a collision (`trigger=` is never clobbered). Rows written off the request thread (after-commit listeners, scheduled jobs) get nothing from the contributor — the query-lifecycle rows carry `on_behalf_of_user_id` explicitly from the request row instead. A synchronous **null-actor** row written inside an agent's request (e.g. `SQL_REVIEW_BLOCKED`, `trigger=sql_review`) also carries these keys: they describe the request the system action ran in, never an actor claim | | `ip_address` | INET | | `user_agent` | TEXT | | `created_at` | TIMESTAMPTZ | @@ -1738,7 +1741,7 @@ The hash chain (added in V26) is per organization. Inserts are serialized by a P | `SAML_CONFIG_UPDATED` | Emitted by the bootstrap reconciler when it applies the SAML configuration from `accessflow.bootstrap.saml`. Metadata: `source: "BOOTSTRAP"`, `change_kind: "UPDATE"`, `config_type: "saml"`, optional `changed_fields`. | | `ACCESS_SIMULATION_RUN` | An admin traced a hypothetical request through the live evaluators, or read who can reach a table (AF-859, AF-967). Always read-only. **The resource names the kind asked about**: `datasource` for `POST /admin/access-simulations` and `GET /admin/effective-access`, `api_connector` for `POST /admin/api-call-simulations`, `deployment_pipeline` for `POST /admin/deployment-simulations`. Metadata differs by endpoint — every trace records `simulated_user_id`, `ai_outcome`, `step_count` and optional `risk_level` / `resulting_status`; the deployment trace adds `environment_id`, `evaluated_at` and `releasable`; the reverse index records `table`, `capability` and `row_count` (total matching users, not the page size). **None carries the governed content** — not the SQL, not the API request path, headers or body: the kind's manage permission does not otherwise grant read access to it. | | `PRIVILEGED_ACCESS_REPORT_VIEWED` | An admin or auditor read the privileged-access report — who can reach data with no permission row (#968). Read-only. Resource: `organization`, `resource_id` = the caller's organization. Metadata: `row_count` (total matching identities, not the page size), plus `kind` and `user_id` when the read was filtered. Never carries an email or a role name. | -| `AUDIT_LOG_EXPORTED` | Admin called `GET /admin/audit-log/export.csv`. Resource: `audit_log`, no resource id. Metadata captures the export filter (`action`, `resource_type`, `actor_id`, `resource_id`, `from`, `to`, `filter_on_behalf_of_user_id` — never the `on_behalf_of_user_id` attribution key, #875) and the row counts (`matched_rows`, `truncated`). | +| `AUDIT_LOG_EXPORTED` | Admin called `GET /admin/audit-log/export.csv`. Resource: `audit_log`, no resource id. Metadata captures the export filter (`action`, `resource_type`, `actor_id`, `resource_id`, `from`, `to`, `filter_on_behalf_of_user_id` — never the `on_behalf_of_user_id` attribution key, #875 — and `filter_application_name`, never the `application_name` key, which names the application that made the export request itself, #938) and the row counts (`matched_rows`, `truncated`). | | `SLACK_APP_CONFIG_UPDATED` / `SLACK_APP_CONFIG_DELETED` | Admin creates/updates (`PUT`) or deletes (`DELETE`) the org's `slack_app_config` row. Resource: `slack_app_config`. Metadata on update: `app_id`, `active`. | | `ACCESS_REQUEST_SUBMITTED` | User submits a JIT access-grant request. Resource: `access_grant_request`. Metadata: `datasource_id`, `requested_duration`, `can_read`/`can_write`/`can_ddl`. | | `ACCESS_REQUEST_APPROVED` / `ACCESS_REQUEST_REJECTED` | Reviewer approves/rejects an access request. Metadata: `resulting_status`, optional `comment`. | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index caa556923..d80269377 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -1596,6 +1596,14 @@ The `sql` field carries the query text for **every** engine. For a `MONGODB` dat **Client-context capture (AF-446).** On submission the backend captures the source IP (`X-Forwarded-For` first hop, else remote address), the `User-Agent` header, and a CI/CD-origin flag, persisting them on `query_requests` for the context-aware routing conditions (`source_ip`, `user_agent`, `cicd_origin`). The CI/CD-origin flag is set when the request is authenticated via an API key **or** carries the optional **`X-AccessFlow-CI`** request header with a truthy value (`true` / `1` / `yes` / `ci` / `cicd`) — pipelines using a JWT instead of an API key set this header to opt into CI/CD-origin routing. See [docs/05-backend.md → "Policy-as-code routing engine"](05-backend.md#policy-as-code-routing-engine-af-379). +**Calling application (#938).** Every submission path (REST submit, break-glass, replay, the MCP `submit_query` tool) records *which application* submitted the query as `application_name` + `application_name_source` on `query_requests`, and every audit row written on the request carries the same pair in `metadata` (`application_name`, `application_name_source` = `api_key` \| `header`). Resolution, most trusted first: + +1. The `application_name` stored on the **API key** that authenticated the request (source `API_KEY`). Trustworthy — it cannot be forged without the key — and it always wins: a named key's caller cannot relabel itself with the header. +2. Otherwise the optional **`X-AccessFlow-Application`** request header (source `HEADER`) — works for JWT sessions and unnamed keys, but is **entirely client-controlled**, so the UI labels it *Untrusted*. Trimmed; dropped when blank or containing control characters; truncated to 100 characters. +3. Otherwise nothing is recorded. + +Identification and audit only — never an authorization input and not a routing operand (the same trust split as `cicd_origin`'s API-key vs `X-AccessFlow-CI` sources). The header is honoured on every endpoint, so rows the request writes elsewhere (API calls, deployments, request groups, admin actions) are attributable on the audit log too. + ### POST /queries — Response 202 Accepted ```json @@ -1696,6 +1704,7 @@ transitions `PENDING_REVIEW → REVIEWED`, audits `BREAK_GLASS_REVIEWED`, and re | `from` | ISO datetime | Created after | | `to` | ISO datetime | Created before | | `query_type` | string | SELECT, INSERT, UPDATE, DELETE, DDL | +| `application_name` | string | Exact match on the recorded calling application (#938; trimmed, blank = no filter) | | `page` | int | Page number (default 0) | | `size` | int | Page size (default 20, max 100) | @@ -1704,7 +1713,7 @@ deprecated aliases (they were the only bound names before the snake_case filters when a filter is sent under both spellings, the snake_case value wins. New clients should use the snake_case names above. The same applies to `GET /queries/export.csv`. -Each row in the paginated response carries the summary fields shown on `QueryListPage`: `id`, `datasource`, `submitted_by`, `query_type`, `status`, `risk_level`, `risk_score`, `ai_failed`, `scheduled_for` (nullable ISO-8601 — non-null when the submitter requested a scheduled execution, so the frontend can render a clock indicator on the row), `recurring` (boolean — `true` when the row is a recurring-series parent, #627, so the frontend can render a repeat indicator), `recurring_parent_id` (nullable UUID — set on occurrence rows), and `created_at`. The full SQL text and AI analysis are only on `GET /queries/{id}`. +Each row in the paginated response carries the summary fields shown on `QueryListPage`: `id`, `datasource`, `submitted_by`, `query_type`, `status`, `risk_level`, `risk_score`, `ai_failed`, `scheduled_for` (nullable ISO-8601 — non-null when the submitter requested a scheduled execution, so the frontend can render a clock indicator on the row), `recurring` (boolean — `true` when the row is a recurring-series parent, #627, so the frontend can render a repeat indicator), `recurring_parent_id` (nullable UUID — set on occurrence rows), `created_at`, and — when one was recorded — `application_name` / `application_name_source` (`API_KEY` \| `HEADER`, #938). The full SQL text and AI analysis are only on `GET /queries/{id}`. ### GET /queries/export.csv — CSV export @@ -1714,7 +1723,7 @@ callers see only their own queries; admins may pass `submitted_by` to scope to a Results are ordered by `created_at DESC`. **Query parameters** (all optional): `status`, `datasource_id`, `submitted_by` (admin-only -override), `from`, `to`, `query_type` — same semantics as `GET /queries`. +override), `from`, `to`, `query_type`, `application_name` — same semantics as `GET /queries`. **Response**: - `200 OK` @@ -1741,6 +1750,8 @@ Each subsequent row contains the same fields as `QueryListItemView`. `ai_risk_le "datasource": { "id": "uuid", "name": "Production PostgreSQL" }, "db_type": "POSTGRESQL", "submitted_by": { "id": "uuid", "email": "alice@company.com", "display_name": "Alice" }, + "application_name": "reporting-service", + "application_name_source": "API_KEY", "sql_text": "UPDATE orders SET status = 'shipped' WHERE id = 123", "effective_sql": null, "query_type": "UPDATE", @@ -1856,6 +1867,8 @@ Each subsequent row contains the same fields as `QueryListItemView`. `ai_risk_le `sql_review_findings` are the deterministic SQL review findings recorded for this query at submission (#864, epic #860) — the same per-finding shape as [`POST /sql-review/evaluate`](#post-sql-reviewevaluate--request-body-863), ordered statement → line, with `message` rendered into the caller's `Accept-Language` at read time from the stored `rule_id` + `args` (never from stored text). Always present: an empty array for a datasource the rule catalog does not cover, for an organization with no ruleset bound, and for a clean evaluation. `line_number` is omitted when unknown. A `BLOCK` finding here explains why the query could not auto-approve: it suppressed routing `AUTO_APPROVE`, the grant fast path and the plan's own approvals and forced `PENDING_REVIEW` — it never rejects, and a routing `AUTO_REJECT` still rejects. Findings are evaluated once, at submission, so they are present even when AI analysis was skipped or failed, and are **not** re-evaluated on reanalysis or for recurring occurrences. See the "Submission enforcement (#864)" paragraph of [docs/05-backend.md → Deterministic SQL review rules](05-backend.md#deterministic-sql-review-rules-sqlreview-862). +`application_name` / `application_name_source` (#938) are the calling application recorded at submission — `API_KEY` when it came from the authenticating key (trustworthy), `HEADER` when it came from the caller-supplied `X-AccessFlow-Application` header (client-controlled; the UI marks it *Untrusted*). Both omitted when none was recorded. + `effective_sql` is the statement **as it actually executed** (#937), read from the query's immutable `query_snapshots` row: the submitted SQL with row-security predicates and soft-delete rewrites spliced in, every bound value left as a `?` placeholder (predicate values such as user attributes are never stored). For a transactional `BEGIN; … COMMIT;` batch it holds every statement's effective form joined by `;` + newline (the envelope markers are not included). It is omitted (`null`) when no rewrite occurred, when the query has not executed, and always for engine-plugin datasources (MongoDB, Redis, …), which splice filters into native commands and have no redacted form. Frozen at execution time — editing or deleting a policy afterwards never changes it. Visible under the same rule as the rest of the detail (the submitter or a `QUERY_VIEW_ALL` holder). `scheduled_for` echoes back the optional ISO-8601 instant supplied at submission; `null` for queries that are submitted for immediate review. @@ -3647,18 +3660,19 @@ Soft-deactivates the account (`active = false`) — the same `UserDeactivatedEve Issues a key owned by the service account. The plaintext `raw_key` is the only chance to capture the secret — it is never persisted and never audited. ```json -{ "name": "github-actions", "expires_at": null } +{ "name": "github-actions", "expires_at": null, "application_name": "deploy-pipeline" } ``` | Field | Constraints | |-------|-------------| | `name` | `@NotBlank`, `@Size(max=100)` — unique per account | | `expires_at` | Optional ISO-8601 timestamp; `null` for non-expiring | +| `application_name` | Optional, `@Size(max=100)` (#938) — the calling application the key identifies, as on `POST /me/api-keys` | **Response 201:** ```json { - "api_key": { "id": "uuid", "name": "github-actions", "key_prefix": "af_kQ7abcde", "bootstrap_declared": false, "created_at": "…", "last_used_at": null, "expires_at": null, "revoked_at": null }, + "api_key": { "id": "uuid", "name": "github-actions", "key_prefix": "af_kQ7abcde", "bootstrap_declared": false, "application_name": "deploy-pipeline", "created_at": "…", "last_used_at": null, "expires_at": null, "revoked_at": null }, "raw_key": "af_kQ7abcdeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" } ``` @@ -3678,6 +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; an explicit blank string clears it | **Response 201:** ```json @@ -5497,11 +5512,13 @@ Delivery is **at-least-once**: a durable per-sink `(created_at, id)` keyset curs "user_agent": "Mozilla/5.0", "created_at": "2026-08-19T09:00:00.123456Z", "previous_hash": "9f2c…", - "current_hash": "b41a…" + "current_hash": "b41a…", + "application_name": "reporting-service", + "application_name_source": "api_key" } ``` -`metadata` is embedded as a JSON object, `created_at` is ISO-8601 with microsecond precision, and `previous_hash` / `current_hash` are the lowercase-hex HMAC chain bytes (as in the audit CSV export) — so any exported window is independently chain-verifiable. +`application_name` / `application_name_source` (#938) are lifted out of `metadata` as first-class fields for Splunk / HTTPS-batch / S3 consumers (`null` when the row names no calling application); the syslog-CEF frame carries them as `cs5` (`cs5Label=application_name`) and `cs6` (`cs6Label=application_name_source`), omitted when absent. `metadata` is embedded as a JSON object, `created_at` is ISO-8601 with microsecond precision, and `previous_hash` / `current_hash` are the lowercase-hex HMAC chain bytes (as in the audit CSV export) — so any exported window is independently chain-verifiable. #### GET /admin/audit-sinks @@ -5611,6 +5628,7 @@ Admin CRUD on sinks is audited best-effort as `AUDIT_SINK_CREATED` / `AUDIT_SINK | `from` | ISO datetime | Inclusive lower bound on `created_at` | | `to` | ISO datetime | Exclusive upper bound on `created_at` | | `onBehalfOfUserId` | UUID | Filter to rows whose `metadata.on_behalf_of_user_id` names this person — the requests an API-key caller made *for* them (#874, #875). Matches inside the JSONB metadata; nothing else on the row changes. | +| `applicationName` | string | Filter to rows whose `metadata.application_name` equals this value exactly (#938, trimmed; blank = no filter) — the calling application recorded from the API key or the `X-AccessFlow-Application` header. `metadata.application_name_source` says which (`api_key` trusted, `header` untrusted). | | `page` | int | Page number (default 0) | | `size` | int | Page size (default 20, max 500). Requests over the cap get `400 BAD_AUDIT_QUERY` | | `sort` | string | Spring Data sort syntax; default `createdAt,DESC`. Allowed properties: `createdAt`, `action`, `resourceType`. Other values return 400 `BAD_AUDIT_QUERY`. | @@ -5694,7 +5712,7 @@ ADMIN role required (otherwise 403). Streams a CSV of audit-log rows matching the same filter set as `GET /admin/audit-log`, minus pagination (`page`, `size`, `sort` are not bound on this endpoint). Rows are emitted in `createdAt DESC` order. ADMIN role required (otherwise 403). -**Query parameters** (all optional): `actorId`, `action`, `resourceType`, `resourceId`, `from`, `to`, `onBehalfOfUserId` — same semantics as `GET /admin/audit-log`. The CSV columns are unchanged: the attribution rides in `metadata_json`. +**Query parameters** (all optional): `actorId`, `action`, `resourceType`, `resourceId`, `from`, `to`, `onBehalfOfUserId`, `applicationName` — same semantics as `GET /admin/audit-log`. The CSV columns are unchanged: the attribution rides in `metadata_json`. **Response**: - `200 OK` @@ -5713,7 +5731,7 @@ timestamp,organization_id,actor_email,action,resource_type,resource_id,ip_addres - `current_hash` / `previous_hash` — lowercase hex of the HMAC-SHA256 chain bytes. Empty for pre-V26 rows that have NULL hashes. - `metadata_json` — the row's JSONB metadata as a single string, RFC 4180 quoted when it contains a comma, quote, CR, or LF. -**Audit of the export action** — every successful call writes a new `AUDIT_LOG_EXPORTED` row whose `metadata` captures the filter (`action`, `resource_type`, `actor_id`, `resource_id`, `from`, `to`, and `filter_on_behalf_of_user_id` for `onBehalfOfUserId` — deliberately *not* the `on_behalf_of_user_id` attribution key, which would make the export row read as done on that person's behalf) and the row counts (`matched_rows`, `truncated`). The export is therefore part of the same tamper-evident chain it is exporting. +**Audit of the export action** — every successful call writes a new `AUDIT_LOG_EXPORTED` row whose `metadata` captures the filter (`action`, `resource_type`, `actor_id`, `resource_id`, `from`, `to`, and `filter_on_behalf_of_user_id` for `onBehalfOfUserId` — deliberately *not* the `on_behalf_of_user_id` attribution key, which would make the export row read as done on that person's behalf; likewise `filter_application_name` for `applicationName`, never `application_name`, which names the application that made the export request) and the row counts (`matched_rows`, `truncated`). The export is therefore part of the same tamper-evident chain it is exporting. **Response 400:** unknown `resourceType`. `error: BAD_AUDIT_QUERY`. @@ -6536,7 +6554,7 @@ service account's key its own `rate_limit_*` when set. #### GET /me/api-keys -Lists the calling user's API keys (newest first). The raw key is never included. `bootstrap_declared` (#871) marks the key the bootstrap reconciler declared for a service account — the one key an admin cannot revoke or rotate here. +Lists the calling user's API keys (newest first). The raw key is never included. `bootstrap_declared` (#871) marks the key the bootstrap reconciler declared for a service account — the one key an admin cannot revoke or rotate here. `application_name` (#938) is the calling application the key identifies; absent when it names none. **Response 200:** ```json @@ -6546,6 +6564,7 @@ Lists the calling user's API keys (newest first). The raw key is never included. "name": "claude-mcp", "key_prefix": "af_kQ7abcde", "bootstrap_declared": false, + "application_name": "reporting-service", "created_at": "2026-05-10T12:34:56Z", "last_used_at": "2026-05-12T08:11:02Z", "expires_at": null, @@ -6562,7 +6581,8 @@ Creates a new API key. The plaintext `raw_key` is the only chance to capture the ```json { "name": "claude-mcp", - "expires_at": null + "expires_at": null, + "application_name": "reporting-service" } ``` @@ -6570,6 +6590,7 @@ Creates a new API key. The plaintext `raw_key` is the only chance to capture the |-------|-------------| | `name` | `@NotBlank`, `@Size(min=1, max=100)` — UNIQUE per user | | `expires_at` | Optional ISO-8601 timestamp; null for non-expiring | +| `application_name` | Optional, `@Size(max=100)` (#938). The calling application this key identifies; stored stripped, blank = none. Recorded on every request the key authenticates as the trusted `API_KEY` source (see *Calling application* under the query submission endpoint). Set here only — there is no update path | **Response 201:** ```json @@ -6591,7 +6612,7 @@ Creates a new API key. The plaintext `raw_key` is the only chance to capture the **Errors:** | Code | Status | Cause | |------|--------|-------| -| `VALIDATION_ERROR` | 400 | Missing or oversize `name` | +| `VALIDATION_ERROR` | 400 | Missing or oversize `name`, or oversize `application_name` | | `API_KEY_DUPLICATE_NAME` | 409 | Caller already has an API key with this name | #### DELETE /me/api-keys/{id} diff --git a/docs/05-backend.md b/docs/05-backend.md index 719bcd0e0..44c894300 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -1711,6 +1711,8 @@ For `REQUIRE_APPROVALS` / `ESCALATE`, the resolved absolute count is written to **Client context (AF-446).** The `source_ip`, `user_agent`, and `cicd_origin` signals are only available on the HTTP submission request, but routing runs asynchronously after AI completion — so they are captured at submission (`QuerySubmissionController`) and persisted on `query_requests` (`submitted_ip`, `submitted_user_agent`, `cicd_origin`), then read back by `QueryReviewStateMachine` when it builds the `ConditionContext`. `cicd_origin` is set when the request was authenticated via an API key (the `security.api.ApiKeyAuthentication` marker) **or** carried the `X-AccessFlow-CI` header. `time_since_last_approval` is computed at routing time as the minutes since the requester's most recent APPROVED/EXECUTED query on the same datasource (`QueryRequestLookupService.findLastApprovalInstant`). All four client-context operands **fail closed** — when the required signal is absent the leaf evaluates to `false` (the matcher in `CidrMatcher` / `core.api.GlobMatcher` returns false on a null IP / user-agent, and `time_since_last_approval` is false with no prior approval), so a permissive `AUTO_APPROVE` policy never fires on missing context; express escalation of unknown context as `not(source_ip(...))`. CIDR syntax is validated by `RoutingConditionValidator` at create / update (422 on a malformed block). +**Calling application (#938).** Separate from the routing signals and never read by routing: `security.api.RequestApplicationService.current()` resolves the request's calling application as a `core.api.ClientApplication(name, ApplicationNameSource)` — the presenting key's `api_keys.application_name` (carried on `ResolvedApiKey` and exposed as `ApiKeyAuthentication.applicationName()`, source `API_KEY`) first, else the sanitised `X-AccessFlow-Application` header (source `HEADER`), else empty; empty off the request thread. The four query submission paths (`QuerySubmissionController`, `BreakGlassController`, `QueryReplayController`, `McpToolService.submitQuery`) pass it through `SubmissionInput` / `BreakGlassInput` / `ReplayCommand` onto `SubmitQueryCommand`, which stamps `query_requests.application_name` / `application_name_source`; recurring occurrences copy the parent's. Independently, `security.internal.ApplicationAuditMetadataContributor` — a second `audit.api.AuditMetadataContributor` beside `ServiceAccountProvenanceContributor` — adds `application_name` / `application_name_source` (lowercase) to every audit row written on such a request, JWT sessions included, so the application is filterable on the audit log for every request type without a column on the other request tables. The audit sinks lift the pair out of metadata as first-class fields (`AuditExportEventWriter`; CEF `cs5` / `cs6`). + **Skip / failure paths.** On the AI-skipped path (`datasource.ai_analysis_enabled = false`) the risk-based operands (`risk_level`, `risk_score`) evaluate to **false** — there is no AI signal, so risk-gated policies simply don't match and the query continues to non-risk policies or the plan fall-through. Routing is **not** run on the AI-failure path (`AiAnalysisFailedEvent`) — a missing AI signal never feeds an automated routing decision; the query lands in `PENDING_REVIEW` for a human, consistent with the auto-approve asymmetry above. **Audit.** Automated decisions reuse the `QUERY_APPROVED` / `QUERY_REJECTED` audit actions with metadata `{ auto_approved | auto_rejected: true, source: "ROUTING_POLICY", routing_policy_id, reason }`. A `REQUIRE_APPROVALS` / `ESCALATE` match records the same matched-policy metadata (`source: "ROUTING_POLICY", routing_policy_id, effective_min_approvals, reason`) on the `QUERY_REVIEW_REQUESTED` action — the `QueryReadyForReviewEvent` carries the matched-policy fields for the routed-to-review path (AF-446). Policy CRUD writes the dedicated `ROUTING_POLICY_CREATED` / `_UPDATED` / `_DELETED` / `_REORDERED` actions against the `routing_policy` resource type. The engine reads / writes the new `routing_policy` and `routing_decision` tables (Flyway `V59__create_routing_policy.sql`). @@ -3892,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/docs/06-frontend.md b/docs/06-frontend.md index 6aa009571..47451dd2c 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -1627,6 +1627,18 @@ the actor on `AuditLogPage` rows (`on_behalf_of_email`, resolved server-side fro The action / resource-type filter lists include the `SERVICE_ACCOUNT_*` actions and the `service_account` resource. +**Calling application (#938).** `ClientApplicationTag` (`src/components/common/`) renders the +recorded application name in `code` style with a tooltip naming its source; a `HEADER` source +(`application_name_source`, or the lowercase `header` in audit metadata) adds a warning `Tag` +reading *Untrusted*. It appears in the `QueryDetailPage` subtitle (`query.application_name`) and +under the actor on `AuditLogPage` rows and in its detail drawer (from `metadata.application_name`). +`QueryListPage` and `AuditLogPage` each gained an *Application* text filter (`application_name` / +`applicationName`, sent server-side; the audit page also seeds `?application_name=` once on +mount). The key create / issue / rotate forms (`ApiKeysSection`, `ServiceAccountKeysTab`) take an +optional *Application name* (`max: 100`, mirrored in `KEY_FORM_CONSTRAINTS`; on rotate, empty keeps +the superseded key's) and both key tables gained an *Application* column. Strings live under +`client_application.*`; the source enum label is `applicationNameSourceLabel` in `enumLabels.ts`. + ### OAuth 2.0 sign-in `LoginPage` renders one "Continue with <Provider>" button per active row returned by diff --git a/docs/07-security.md b/docs/07-security.md index 2dd90c1ad..fc95da7a2 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -1176,6 +1176,27 @@ Routing policies (see [docs/05-backend.md → "Policy-as-code routing engine"](0 - **Audit.** A matched `ESCALATE` / `REQUIRE_APPROVALS` policy records its id, resolved `effective_min_approvals`, and reason on the `QUERY_REVIEW_REQUESTED` audit row. +### Calling application (#938) + +Every request can be attributed to the **application** that made it — recorded as +`application_name` + `application_name_source` on `query_requests` and in the metadata of every +audit row the request writes (`security.api.RequestApplicationService`, +`security.internal.ApplicationAuditMetadataContributor`). Two sources, with different trust: + +- **`API_KEY` — trustworthy.** The optional `application_name` stored on the API key that + authenticated the request. A caller cannot forge it without the key, and it always wins: the + holder of a named key cannot relabel itself with the header. Set at issue time only; a rotation + inherits it. +- **`HEADER` — untrusted.** The `X-AccessFlow-Application` request header, used only when the key + names no application or the caller has a JWT session. It is entirely client-controlled: the value + is trimmed, dropped when blank or containing control characters (no log / CSV / CEF injection), and + truncated to 100 characters, and the UI labels it *Untrusted* on the query detail and audit log. + +This is **identification and audit only** — not an authorization dimension and not a routing +operand. Should a future routing condition read it, it must follow the `cicd_origin` precedent above: +a `HEADER` source must never be the sole basis of a permissive `AUTO_APPROVE`, and the leaf must fail +closed on a missing or header-sourced value. + ### Lifecycle pseudonymization & salt rotation (AF-499) A `PSEUDONYMIZE` retention policy applies an **irreversible** read-time transform to its target diff --git a/docs/13-mcp.md b/docs/13-mcp.md index c4dea3314..34f859dff 100644 --- a/docs/13-mcp.md +++ b/docs/13-mcp.md @@ -223,6 +223,12 @@ names both `application/json` and `text/event-stream`; real MCP clients send bot tool is refused at invocation instead of at the transport: **`review_query` returns the structured `permission_denied` whenever a principal is present** — an agent may submit *for* a human, never vote *as* one. +- **Calling application (#938).** `submit_query` records the calling application on the query like + the REST endpoint does: the `application_name` stored on the agent's API key (trusted), else an + `X-AccessFlow-Application` header sent on `POST /mcp` (untrusted), and every audit row the request + writes carries `application_name` / `application_name_source` in its metadata. Name the key after + the agent (`application_name` on `POST /me/api-keys` or the service-account key endpoints) so its + activity is filterable on the audit log without any client-side change. - **Errors:** tools return a structured `{ code, message }` rather than raw exceptions. Codes: - `permission_denied` — caller is not allowed; also returned when the tool is outside the caller's allow-list (the message names the tool). diff --git a/e2e/tests/admin-audit-log.spec.ts b/e2e/tests/admin-audit-log.spec.ts index b3e1a8890..8bb8dd014 100644 --- a/e2e/tests/admin-audit-log.spec.ts +++ b/e2e/tests/admin-audit-log.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; import { + apiBase, createPostgresDatasource, deleteDatasource, loginViaApi, @@ -255,6 +256,86 @@ test.describe.serial('admin audit log — list, filter, drawer, chain verify', ( expect(verifyOk.ok).toBe(true); }); + // #938 — the calling application: a named API key is trustworthy, the X-AccessFlow-Application + // header is not. Both land in audit metadata; the page filters on it and marks the header + // source as untrusted. + test('filters by calling application and marks a header-supplied one untrusted', async ({ + page, + request, + }) => { + const keyApp = `e2e-key-app-${UNIQUE_SUFFIX}`; + const headerApp = `e2e-header-app-${UNIQUE_SUFFIX}`; + const createKey = await request.post(`${apiBase()}/api/v1/me/api-keys`, { + headers: { Authorization: `Bearer ${adminAccessToken}` }, + data: { name: `e2e-app-key-${UNIQUE_SUFFIX}`, application_name: keyApp }, + }); + expect(createKey.status()).toBe(201); + const issued = (await createKey.json()) as { + api_key: { id: string; application_name: string }; + raw_key: string; + }; + expect(issued.api_key.application_name).toBe(keyApp); + + try { + // The CSV export is itself audited (AUDIT_LOG_EXPORTED), so each call writes one row that + // carries the request's calling application. + const viaKey = await request.get(`${apiBase()}/api/v1/admin/audit-log/export.csv`, { + headers: { + 'X-API-Key': issued.raw_key, + // A named key wins over the header — this value must never be recorded. + 'X-AccessFlow-Application': 'spoofed-by-header', + Accept: 'text/csv', + }, + }); + expect(viaKey.status()).toBe(200); + const viaHeader = await request.get(`${apiBase()}/api/v1/admin/audit-log/export.csv`, { + headers: { + Authorization: `Bearer ${adminAccessToken}`, + 'X-AccessFlow-Application': headerApp, + Accept: 'text/csv', + }, + }); + expect(viaHeader.status()).toBe(200); + + await login(page); + await page.goto('/admin/audit-log'); + await waitForAuditListReady(page); + + const keyFiltered = page.waitForResponse( + (r) => + r.request().method() === 'GET' && + r.url().includes(`applicationName=${keyApp}`) && + r.ok(), + { timeout: 15_000 }, + ); + await page.getByLabel('Filter by application').fill(keyApp); + const keyBody = (await (await keyFiltered).json()) as { total_elements: number }; + expect(keyBody.total_elements).toBe(1); + const table = page.getByRole('table'); + const keyRow = table.locator('tr').filter({ hasText: 'AUDIT_LOG_EXPORTED' }); + await expect(keyRow).toHaveCount(1); + await expect(keyRow).toContainText(keyApp); + await expect(keyRow.getByText('Untrusted', { exact: true })).toHaveCount(0); + + const headerFiltered = page.waitForResponse( + (r) => + r.request().method() === 'GET' && + r.url().includes(`applicationName=${headerApp}`) && + r.ok(), + { timeout: 15_000 }, + ); + await page.getByLabel('Filter by application').fill(headerApp); + await headerFiltered; + const headerRow = table.locator('tr').filter({ hasText: headerApp }); + await expect(headerRow).toHaveCount(1); + await expect(headerRow.getByText('Untrusted', { exact: true })).toBeVisible(); + } finally { + await request.delete(`${apiBase()}/api/v1/me/api-keys/${issued.api_key.id}`, { + headers: { Authorization: `Bearer ${adminAccessToken}` }, + }); + } + }); + test('filter that matches no events renders the empty-state', async ({ page }) => { await login(page); await page.goto('/admin/audit-log'); diff --git a/e2e/tests/profile-api-keys.spec.ts b/e2e/tests/profile-api-keys.spec.ts index e679baa38..d813f97b6 100644 --- a/e2e/tests/profile-api-keys.spec.ts +++ b/e2e/tests/profile-api-keys.spec.ts @@ -169,6 +169,44 @@ test.describe.serial('AF-286 — /profile API keys CRUD', () => { ).toHaveCount(0); }); + test('records an application name on the key and shows it in the table (#938)', async ({ + page, + }) => { + const keyName = `${KEY_NAME_PREFIX}app-${SUFFIX}`; + const appName = `reporting-${SUFFIX}`; + await page.getByRole('button', { name: 'Create API key', exact: true }).click(); + const createDialog = page.getByRole('dialog', { name: 'Create a new API key' }); + await expect(createDialog).toBeVisible({ timeout: 5_000 }); + await createDialog.getByLabel('Key name', { exact: true }).fill(keyName); + await createDialog.getByLabel('Application name', { exact: true }).fill(` ${appName} `); + + const createResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'POST' && + /\/api\/v1\/me\/api-keys$/.test(r.url()) && + r.status() === 201, + { timeout: 10_000 }, + ); + await createDialog.getByRole('button', { name: 'Create API key', exact: true }).click(); + const created = (await (await createResponsePromise).json()) as { + api_key: { application_name?: string }; + }; + expect(created.api_key.application_name).toBe(appName); + + const issuedDialog = page.getByRole('dialog', { name: 'Copy your new API key' }); + await issuedDialog + .locator('.ant-modal-footer') + .getByRole('button', { name: 'Close', exact: true }) + .click(); + await expect(issuedDialog).toBeHidden({ timeout: 5_000 }); + + const table = page.getByRole('table', { name: 'API keys' }); + await expect(table.getByRole('columnheader', { name: 'Application' })).toBeVisible(); + await expect(table.locator('tr').filter({ hasText: keyName })).toContainText(appName, { + timeout: 10_000, + }); + }); + test('blocks create when name is empty', async ({ page }) => { await page.getByRole('button', { name: 'Create API key', exact: true }).click(); const createDialog = page.getByRole('dialog', { name: 'Create a new API key' }); @@ -340,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/e2e/tests/query-list.spec.ts b/e2e/tests/query-list.spec.ts index fe2e01147..880d3f32d 100644 --- a/e2e/tests/query-list.spec.ts +++ b/e2e/tests/query-list.spec.ts @@ -282,6 +282,43 @@ test.describe.serial('query list filters + CSV export on /queries', () => { await search.fill(''); }); + // #938 — a query submitted with the X-AccessFlow-Application header records it as an untrusted + // calling application: the list filters on it server-side, the detail page flags it. + test('application filter narrows the list and the detail marks a header source untrusted', async ({ + page, + request, + }) => { + if (!datasourceA) throw new Error('beforeAll did not create datasources'); + const appName = `e2e-app-${UNIQUE_SUFFIX}`; + const res = await request.post(`${API_BASE}/api/v1/queries`, { + headers: { + Authorization: `Bearer ${adminAccessToken}`, + 'X-AccessFlow-Application': appName, + }, + data: { datasource_id: datasourceA.id, sql: 'SELECT 4', justification: 'e2e/query-list q4' }, + }); + expect(res.status()).toBe(202); + const { id: appQueryId } = (await res.json()) as { id: string }; + + await login(page); + await page.goto('/queries'); + await waitForListReady(page); + + await waitForListReady(page, () => + page.getByLabel('Filter by application').fill(appName), + ); + const rows = page.locator('tr.ant-table-row'); + await expect(rows).toHaveCount(1, { timeout: 10_000 }); + await expect(rows.first()).toContainText(appQueryId.slice(0, 8)); + + await rows.first().click(); + await page.waitForURL(new RegExp(`/queries/${appQueryId}$`), { timeout: 15_000 }); + await expect(page.getByTestId('query-application')).toContainText(appName, { + timeout: 10_000, + }); + await expect(page.getByTestId('query-application-untrusted')).toBeVisible(); + }); + test('clicking a row navigates to /queries/', async ({ page }) => { await login(page); await page.goto('/queries'); diff --git a/frontend/src/api/admin.test.ts b/frontend/src/api/admin.test.ts index ed22e7d02..5572287c5 100644 --- a/frontend/src/api/admin.test.ts +++ b/frontend/src/api/admin.test.ts @@ -117,6 +117,7 @@ describe('api/admin', () => { await adminApi.listAuditEvents({ actor_id: 'u-1', on_behalf_of_user_id: 'u-2', + application_name: 'reporting', action: 'USER_LOGIN', resource_type: 'user', resource_id: 'r-1', @@ -130,6 +131,7 @@ describe('api/admin', () => { params: { actorId: 'u-1', onBehalfOfUserId: 'u-2', + applicationName: 'reporting', action: 'USER_LOGIN', resourceType: 'user', resourceId: 'r-1', @@ -195,6 +197,7 @@ describe('api/admin', () => { const result = await adminApi.exportAuditLogCsv({ actor_id: 'u-1', on_behalf_of_user_id: 'u-2', + application_name: 'reporting', action: 'USER_LOGIN', resource_type: 'user', resource_id: 'r-1', @@ -210,6 +213,7 @@ describe('api/admin', () => { params: { actorId: 'u-1', onBehalfOfUserId: 'u-2', + applicationName: 'reporting', action: 'USER_LOGIN', resourceType: 'user', resourceId: 'r-1', diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 8e8097b2d..bc693b1fe 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -189,6 +189,7 @@ export async function listAuditEvents( const params: Record = {}; if (filters.actor_id) params.actorId = filters.actor_id; if (filters.on_behalf_of_user_id) params.onBehalfOfUserId = filters.on_behalf_of_user_id; + if (filters.application_name) params.applicationName = filters.application_name; if (filters.action) params.action = filters.action; if (filters.resource_type) params.resourceType = filters.resource_type; if (filters.resource_id) params.resourceId = filters.resource_id; @@ -225,6 +226,7 @@ export async function exportAuditLogCsv( const params: Record = {}; if (filters.actor_id) params.actorId = filters.actor_id; if (filters.on_behalf_of_user_id) params.onBehalfOfUserId = filters.on_behalf_of_user_id; + if (filters.application_name) params.applicationName = filters.application_name; if (filters.action) params.action = filters.action; if (filters.resource_type) params.resourceType = filters.resource_type; if (filters.resource_id) params.resourceId = filters.resource_id; diff --git a/frontend/src/api/queries.test.ts b/frontend/src/api/queries.test.ts index 69deae61f..3740c6470 100644 --- a/frontend/src/api/queries.test.ts +++ b/frontend/src/api/queries.test.ts @@ -66,6 +66,7 @@ describe('api/queries', () => { datasource_id: 'ds-1', submitted_by: 'user-1', query_type: 'SELECT', + application_name: 'reporting', from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z', page: 2, @@ -77,6 +78,7 @@ describe('api/queries', () => { datasource_id: 'ds-1', submitted_by: 'user-1', query_type: 'SELECT', + application_name: 'reporting', from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z', page: 2, diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 504731f54..255b360dd 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -47,6 +47,8 @@ export interface QueryListFilters { from?: string; to?: string; query_type?: QueryType; + /** Exact match on the recorded calling application (#938). */ + application_name?: string; page?: number; size?: number; } @@ -191,6 +193,7 @@ function toQueryParams(filters: QueryListFilters): Record + + + {trimmed} + + + {untrusted && ( + + {t('client_application.untrusted')} + + )} + + ); +} diff --git a/frontend/src/components/common/__tests__/ClientApplicationTag.test.tsx b/frontend/src/components/common/__tests__/ClientApplicationTag.test.tsx new file mode 100644 index 000000000..764ec1f3a --- /dev/null +++ b/frontend/src/components/common/__tests__/ClientApplicationTag.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@/i18n'; +import { ClientApplicationTag } from '../ClientApplicationTag'; + +describe('ClientApplicationTag', () => { + it('renders nothing without an application name', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows an API-key name without the untrusted marker', () => { + render(); + expect(screen.getByTestId('client-application')).toHaveTextContent('reporting'); + expect(screen.queryByTestId('client-application-untrusted')).not.toBeInTheDocument(); + }); + + it('marks a header-supplied name as untrusted, whatever its case', () => { + const { rerender } = render(); + expect(screen.getByTestId('client-application-untrusted')).toHaveTextContent('Untrusted'); + + rerender(); + expect(screen.getByTestId('audit-app-untrusted')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx b/frontend/src/components/serviceaccounts/ServiceAccountKeysTab.tsx index 4b75d8cf5..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, @@ -48,6 +49,7 @@ const DEFAULT_GRACE_HOURS = 24; interface KeyFormValues { name: string; expires_at?: Dayjs | null; + application_name?: string; grace_hours?: number | null; } @@ -72,6 +74,7 @@ export function ServiceAccountKeysTab({ account }: { account: ServiceAccount }) issueServiceAccountKey(account.id, { name: values.name.trim(), expires_at: values.expires_at ? values.expires_at.toISOString() : null, + application_name: values.application_name?.trim() || null, }), onSuccess: (result) => { message.success(t('admin.service_accounts.keys.issued')); @@ -89,6 +92,8 @@ export function ServiceAccountKeysTab({ account }: { account: ServiceAccount }) name: values.name.trim(), expires_at: values.expires_at ? values.expires_at.toISOString() : null, grace_period: gracePeriodOf(values.grace_hours) ?? null, + // Omitted (null) keeps the superseded key's application name on the backend (#938). + application_name: values.application_name?.trim() || null, }), onSuccess: (result) => { message.success(t('admin.service_accounts.keys.rotated')); @@ -126,6 +131,12 @@ export function ServiceAccountKeysTab({ account }: { account: ServiceAccount }) ), }, + { + title: t('client_application.column'), + dataIndex: 'application_name', + render: (v: string | null | undefined) => + v ? {v} : —, + }, { title: t('admin.service_accounts.keys.col_prefix'), dataIndex: 'key_prefix', @@ -214,6 +225,19 @@ export function ServiceAccountKeysTab({ account }: { account: ServiceAccount }) > + + + searchParams.get('on_behalf_of_user_id') ?? '', ); + const [applicationName, setApplicationName] = useState( + () => searchParams.get('application_name') ?? '', + ); const [resourceId, setResourceId] = useState(''); const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null); const [detail, setDetail] = useState(null); @@ -169,11 +173,12 @@ export function AuditLogPage() { resource_type: resourceType === 'all' ? undefined : resourceType, actor_id: actorId.trim() || undefined, on_behalf_of_user_id: onBehalfOfUserId.trim() || undefined, + application_name: applicationName.trim() || undefined, resource_id: resourceId.trim() || undefined, from: range?.[0]?.toISOString(), to: range?.[1]?.toISOString(), }), - [page, action, resourceType, actorId, onBehalfOfUserId, resourceId, range], + [page, action, resourceType, actorId, onBehalfOfUserId, applicationName, resourceId, range], ); const exportCsv = useMutation({ @@ -283,6 +288,17 @@ export function AuditLogPage() { style={{ width: 240 }} className="mono" /> + { + setApplicationName(e.target.value); + setPage(0); + }} + style={{ width: 200 }} + className="mono" + /> + {typeof e.metadata.application_name === 'string' && ( +
+ +
+ )} ); @@ -496,6 +525,30 @@ export function AuditLogPage() { +
+ application + {typeof detail.metadata.application_name === 'string' ? ( + + ) : ( + — + )} +
diff --git a/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx b/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx index 41af8e411..20b04a2e6 100644 --- a/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx +++ b/frontend/src/pages/admin/__tests__/AuditLogPage.test.tsx @@ -56,7 +56,13 @@ describe('AuditLogPage — on-behalf-of attribution (#874, #875)', () => { content: [ event({ on_behalf_of_email: 'alice@example.com', - metadata: { on_behalf_of_user_id: 'u-alice', service_account: true, api_key_id: 'k-1' }, + metadata: { + on_behalf_of_user_id: 'u-alice', + service_account: true, + api_key_id: 'k-1', + application_name: 'reporting-service', + application_name_source: 'header', + }, }), event({ id: 'a-2', actor_id: 'u-bob', actor_email: 'bob@example.com', actor_display_name: 'Bob' }), ], @@ -86,6 +92,40 @@ describe('AuditLogPage — on-behalf-of attribution (#874, #875)', () => { ); }); + it('shows the calling application with an untrusted marker for a header source (#938)', async () => { + render(wrap()); + + const botRow = (await screen.findByText('CI bot')).closest('tr'); + expect(within(botRow!).getByTestId('audit-application-a-1')).toHaveTextContent('reporting-service'); + expect(within(botRow!).getByTestId('audit-application-a-1-untrusted')).toBeInTheDocument(); + const bobRow = screen.getByText('Bob').closest('tr'); + 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'); + expect(listAuditEvents).toHaveBeenCalledWith(expect.objectContaining({ application_name: 'etl' })); + + fireEvent.change(screen.getByLabelText('Filter by application'), { + target: { value: ' billing ' }, + }); + + await waitFor(() => + expect(listAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ application_name: 'billing', page: 0 }), + ), + ); + }); + it('applies a typed on-behalf-of filter', async () => { render(wrap()); 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 d5fd25b27..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')); @@ -150,7 +178,11 @@ describe('ServiceAccountSettingsPage', () => { fireEvent.click(within(dialog).getByRole('button', { name: 'Issue key' })); await waitFor(() => - expect(issueServiceAccountKey).toHaveBeenCalledWith('sa-1', { name: 'ci', expires_at: null }), + expect(issueServiceAccountKey).toHaveBeenCalledWith('sa-1', { + name: 'ci', + expires_at: null, + application_name: null, + }), ); expect(await screen.findByTestId('issued-raw-key')).toHaveTextContent('af_raw_secret'); expect(screen.getByText(/only time the key is shown/)).toBeInTheDocument(); @@ -175,6 +207,7 @@ describe('ServiceAccountSettingsPage', () => { name: `github-actions-${new Date().toISOString().slice(0, 10)}`, expires_at: null, grace_period: 'PT12H', + application_name: null, }), ); expect(await screen.findByTestId('issued-raw-key')).toHaveTextContent('af_rotated'); diff --git a/frontend/src/pages/admin/service-accounts/__tests__/createFormParity.test.ts b/frontend/src/pages/admin/service-accounts/__tests__/createFormParity.test.ts index 5a4d794db..48135a70f 100644 --- a/frontend/src/pages/admin/service-accounts/__tests__/createFormParity.test.ts +++ b/frontend/src/pages/admin/service-accounts/__tests__/createFormParity.test.ts @@ -68,7 +68,8 @@ describe('service-account form ↔ backend validation parity', () => { }); const rotate = constraintsOf('RotateServiceAccountKeyRequest.java'); expect(rotate.name).toEqual(KEY_FORM_CONSTRAINTS.name); + expect(rotate.application_name).toEqual(KEY_FORM_CONSTRAINTS.application_name); // grace_period is @AssertTrue-positive on the backend; the form's InputNumber min=1 mirrors it. - expect(Object.keys(rotate)).toEqual(['name', 'expires_at', 'grace_period']); + expect(Object.keys(rotate)).toEqual(['name', 'expires_at', 'grace_period', 'application_name']); }); }); 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 328375043..ca2b892c4 100644 --- a/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts +++ b/frontend/src/pages/admin/service-accounts/serviceAccountForm.ts @@ -43,6 +43,7 @@ export const UPDATE_FORM_CONSTRAINTS = { /** `IssueServiceAccountKeyRequest` / `RotateServiceAccountKeyRequest` (#871). */ export const KEY_FORM_CONSTRAINTS = { name: { required: true, max: 100 }, + application_name: { max: 100 }, } as const satisfies Record; export function fieldRules(t: TFunction, constraints: FieldConstraints): Rule[] { @@ -250,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 2071854ac..f7398cf55 100644 --- a/frontend/src/pages/profile/sections/ApiKeysSection.tsx +++ b/frontend/src/pages/profile/sections/ApiKeysSection.tsx @@ -22,10 +22,12 @@ 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; expires_at?: Dayjs | null; + application_name?: string; } const upTo = (limit: number) => Array.from({ length: Math.max(limit, 0) }, (_, i) => i); @@ -78,6 +80,13 @@ export function ApiKeysSection() { key: 'name', render: (name: string) => {name}, }, + { + title: t('client_application.column'), + dataIndex: 'application_name', + key: 'application_name', + render: (name: string | null | undefined) => + name ? {name} : '—', + }, { title: t('profile.api_keys.column.prefix'), dataIndex: 'key_prefix', @@ -197,13 +206,13 @@ export function ApiKeysSection() { form={form} name="createApiKey" layout="vertical" - onFinish={(values) => - createMutation.mutate( - values.expires_at - ? { name: values.name, expires_at: values.expires_at.toISOString() } - : { name: values.name }, - ) - } + onFinish={(values) => { + const input: CreateApiKeyInput = { name: values.name }; + if (values.expires_at) input.expires_at = values.expires_at.toISOString(); + const applicationName = values.application_name?.trim(); + if (applicationName) input.application_name = applicationName; + createMutation.mutate(input); + }} > + + + { expect(screen.getByText('Active')).toBeInTheDocument(); }); + it('shows the application a key identifies (#938)', async () => { + listApiKeys.mockResolvedValueOnce([{ ...baseKey, application_name: 'reporting-service' }]); + render(wrap()); + expect(await screen.findByText('reporting-service')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Application' })).toBeInTheDocument(); + }); + + it('sends a trimmed application name when one is given', async () => { + listApiKeys.mockResolvedValue([]); + createApiKey.mockResolvedValueOnce({ + api_key: { ...baseKey, name: 'reports', application_name: 'reporting-service' }, + raw_key: 'af_x', + }); + render(wrap()); + + fireEvent.click(await screen.findByRole('button', { name: 'Create API key' })); + fireEvent.change(await screen.findByLabelText('Key name'), { target: { value: 'reports' } }); + fireEvent.change(screen.getByLabelText('Application name'), { + target: { value: ' reporting-service ' }, + }); + const createButtons = screen.getAllByRole('button', { name: 'Create API key' }); + fireEvent.click(createButtons[createButtons.length - 1]!); + + await waitFor(() => + expect(createApiKey).toHaveBeenCalledWith({ + name: 'reports', + application_name: 'reporting-service', + }), + ); + }); + it('labels a key past its expiry as Expired rather than Active', async () => { listApiKeys.mockResolvedValueOnce([ { ...baseKey, id: 'k-2', name: 'stale', expires_at: '2026-05-02T12:00:00Z' }, diff --git a/frontend/src/pages/queries/QueryDetailPage.test.tsx b/frontend/src/pages/queries/QueryDetailPage.test.tsx index 4307e9499..7ecd365a8 100644 --- a/frontend/src/pages/queries/QueryDetailPage.test.tsx +++ b/frontend/src/pages/queries/QueryDetailPage.test.tsx @@ -258,6 +258,20 @@ describe('QueryDetailPage — AI failure surface (AF-249)', () => { ).toBeGreaterThan(0); }); + it('shows the calling application and marks a header-supplied one untrusted (#938)', async () => { + setUser('REVIEWER'); + getQueryMock.mockResolvedValue({ + ...failedQuery(), + application_name: 'reporting-service', + application_name_source: 'HEADER', + }); + + render(wrap()); + + expect(await screen.findByTestId('query-application')).toHaveTextContent('reporting-service'); + expect(screen.getByTestId('query-application-untrusted')).toBeInTheDocument(); + }); + it('does not render the failure banner when analysis succeeded', async () => { setUser('REVIEWER'); const ok = failedQuery(); diff --git a/frontend/src/pages/queries/QueryDetailPage.tsx b/frontend/src/pages/queries/QueryDetailPage.tsx index c0b86e6d0..2067cdfae 100644 --- a/frontend/src/pages/queries/QueryDetailPage.tsx +++ b/frontend/src/pages/queries/QueryDetailPage.tsx @@ -76,6 +76,7 @@ import { QuerySqlView } from './QuerySqlView'; import { buildTimelineStages } from './buildTimelineStages'; import './query-detail.css'; import { OnBehalfOfTag } from '@/components/common/OnBehalfOfTag'; +import { ClientApplicationTag } from '@/components/common/ClientApplicationTag'; export function QueryDetailPage() { const { t } = useTranslation(); @@ -298,6 +299,17 @@ export function QueryDetailPage() { )} · {fmtDate(query.created_at)} ·{' '} {query.datasource.name} + {query.application_name && ( + <> + {' '} + · {t('client_application.label')}{' '} + + + )} } actions={ diff --git a/frontend/src/pages/queries/QueryListPage.tsx b/frontend/src/pages/queries/QueryListPage.tsx index 503e7bd64..476919138 100644 --- a/frontend/src/pages/queries/QueryListPage.tsx +++ b/frontend/src/pages/queries/QueryListPage.tsx @@ -52,6 +52,7 @@ export function QueryListPage() { const [type, setType] = useState('all'); const [risk, setRisk] = useState('all'); const [datasource, setDatasource] = useState('all'); + const [application, setApplication] = useState(''); const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null); const [page, setPage] = useState(0); @@ -60,12 +61,13 @@ export function QueryListPage() { status: status === 'all' ? undefined : status, query_type: type === 'all' ? undefined : type, datasource_id: datasource === 'all' ? undefined : datasource, + application_name: application.trim() || undefined, from: range?.[0] ? range[0].toISOString() : undefined, to: range?.[1] ? range[1].endOf('day').toISOString() : undefined, page, size: PAGE_SIZE, }), - [status, type, datasource, range, page], + [status, type, datasource, application, range, page], ); const { data, isLoading } = useQuery({ @@ -275,6 +277,17 @@ export function QueryListPage() { ]} style={{ width: 200 }} /> + { + setApplication(e.target.value); + setPage(0); + }} + allowClear + style={{ width: 180 }} + /> { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 52a3ba835..38304c832 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1324,8 +1324,16 @@ export interface QueryListItem { recurring: boolean; recurring_parent_id: string | null; created_at: string; + application_name?: string | null; + application_name_source?: ApplicationNameSource | null; } +/** + * Where a request's calling application came from (#938): the API key (trustworthy) or the + * caller-supplied `X-AccessFlow-Application` header (client-controlled, shown as untrusted). + */ +export type ApplicationNameSource = 'API_KEY' | 'HEADER'; + export interface AiAnalysisDetail { id: string; risk_level: RiskLevel; @@ -1399,6 +1407,9 @@ export interface QueryDetail { submitted_by: UserRef; /** The human an API-key submitter acted for (#874); null for a human submission. */ on_behalf_of?: { id: string; email: string | null } | null; + /** The calling application (#938); absent when unknown. */ + application_name?: string | null; + application_name_source?: ApplicationNameSource | null; sql_text: string; /** * The statement as actually executed (#937) — row-security / soft-delete rewrite with bound @@ -1911,6 +1922,8 @@ export interface AuditLogFilters { actor_id?: string; /** Rows whose metadata names this person as the on-behalf-of principal (#874). */ on_behalf_of_user_id?: string; + /** Rows whose metadata names this calling application (#938), exact match. */ + application_name?: string; action?: string; resource_type?: string; resource_id?: string; @@ -2538,11 +2551,14 @@ export interface ApiKey { last_used_at: string | null; expires_at: string | null; revoked_at: string | null; + /** The calling application the key identifies (#938); absent when it names none. */ + application_name?: string | null; } export interface CreateApiKeyInput { name: string; expires_at?: string | null; + application_name?: string | null; } export interface CreateApiKeyResponse { @@ -2637,6 +2653,8 @@ export interface UpdateServiceAccountInput { export interface IssueServiceAccountKeyInput { name: string; expires_at?: string | null; + /** On rotate, omitting it keeps the superseded key's application name (#938). */ + application_name?: string | null; } export interface IssuedServiceAccountKey { diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index 95e3d3066..a782e3dbd 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -173,8 +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":413,"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, 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":"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":741,"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.\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)."} @@ -248,8 +249,8 @@ {"id":"cf357e69598c439d","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-access-requests","anchor":"cfg-access-requests","title":"Just-in-time (JIT) access requests","section":"Reference","order":0,"tokens":652,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Just-in-time (JIT) access requests\n\nInstead of an admin pre-granting a\npermission, any user can request temporary, scoped access from\n/access-requests — to a datasource (pick the capabilities they\nneed — read / write / DDL — and an optional schema/table scope) or to an API\nconnection (read / write plus an optional allow-list of specific operations from\nthe connector's schema catalog), with a duration. The request runs\nthrough the same reviewer-eligibility and multi-stage approval engine as query review\n(a requester can never approve their own); API-connection requests route through the\nconnector's assigned review plan. Admins are the backstop approver: an admin\nsees and can approve every pending access request from\n/admin/access-requests — even on resources with no review plan — so a\nrequest is never stuck waiting for an approver who was never configured. On final\napproval AccessFlow writes a time-boxed permission grant (expiring at\nnow + duration) — a datasource permission, or an API-connection permission\nvisible on the connector's Permissions tab alongside admin-granted rows; it's revoked\nautomatically on expiry, and an admin can revoke an\nactive grant early from /admin/access-requests. Tune the revocation cadence\nwith ACCESSFLOW_ACCESS_GRANT_EXPIRY_POLL_INTERVAL (default PT5M)\nand the allowed duration window with ACCESSFLOW_ACCESS_MIN_DURATION /\nACCESSFLOW_ACCESS_MAX_DURATION (defaults PT15M / P30D).\nA requester can additionally tick “Pre-approve queries under this grant” on the\nrequest form (off by default): while such a grant is active, queries it covers —\nmatching capability and schema/table scope — skip human review entirely and are\nauto-approved with the grant and its approver recorded on the query detail and in the\naudit log. The flag is shown as a highlighted tag in the approval queue so the reviewer\nsees exactly what they authorize; auto-reject and escalation routing policies, high-risk\nAI verdicts, and open behavioural anomalies still override the fast-path.\n\n/admin/access-requests — pending JIT access requests; admins approve, reject, or revoke an active grant."} {"id":"3d92254db5c80485","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-break-glass","anchor":"cfg-break-glass","title":"Break-glass / emergency access","section":"Reference","order":0,"tokens":385,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Break-glass / emergency access\n\nFor genuine emergencies — production is\ndown and approvers are unreachable — an admin can grant a user the\ncan_break_glass permission on a datasource (a checkbox on the permission\ngrant, alongside read / write / DDL, time-boxed via the same expires_at).\nWith that grant, an Emergency access button appears on the editor for\nthat datasource: the user supplies a mandatory justification and the query\nexecutes immediately, bypassing review — but still through every proxy\nguard (schema/table allow-list, dynamic masking, row-level security, row caps). The grant\nis required for everyone, including admins. Each break-glass execution fires\ninstant notifications to all admins (including PagerDuty), writes a prominently-tagged\nQUERY_BREAK_GLASS_EXECUTED audit row, and opens a mandatory\nretro-review on the /admin/break-glass log that an admin —\nnever the submitter — must acknowledge after the fact. The executed query keeps\nits normal terminal state; the retro-review is tracked alongside it.\n\n/admin/break-glass — every emergency execution opens a mandatory retro-review here for an admin (never the submitter) to acknowledge."} {"id":"2fc7ae27667de7df","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-groups","anchor":"cfg-groups","title":"User groups","section":"Reference","order":0,"tokens":620,"text":"AccessFlow Docs > Reference > Users, roles & organizations > User groups\n\nWhat it is. Named, organisation-scoped collections of users. Use them to\n(1) bundle reviewers so you can attach a single group — instead of ten individual users —\nto a datasource as eligible reviewers, (2) grant a whole team data or API\naccess (a datasource or API-connector grant on a group is inherited by every\nmember, so you don't add a row per person), and (3) act as the target of IdP group mappings\nso SAML / OAuth2 logins keep membership in sync automatically.\n\nConfigure it. Manage groups from /admin/groups:\n\n- Create a group. Go to /admin/groups → Create\ngroup. Pick a name (e.g. Billing Reviewers) and an optional description.\n\n- Add members. Open the group, click Add member, and pick\nusers from the dropdown. Manually-added members are tagged\nManual and stay put regardless of the IdP sync.\n\n- Use the group. On a datasource's Reviewers tab\n(/datasources//settings), add the group as a reviewer. From\nthat point on, members of the group can see and decide queries against that\ndatasource (in addition to plan-approver rules). On the same page's\nPermissions tab (and an API connector's Permissions tab) you can also\ngrant the group access — switch the grant target from User to\nGroup and every member inherits the read / write / DDL / break-glass grant.\n\n- Optional: IdP-managed memberships. Configure\ngroup_mappings on the SAML or OAuth2 admin pages so an IdP group claim\nauto-maps to the AccessFlow group. On every login, AccessFlow replaces the user's\nIdP-sourced memberships with the mapped set; Manual memberships\nare never touched.\n\nPer-datasource reviewer scoping. Once a datasource has at least one\nassigned reviewer (a user or a group), only those reviewers see its queries. Datasources\nwith none fall back to the review-plan approvers — so adopting groups is purely additive,\nno migration required.\n\n/admin/groups — organisation-scoped user groups; open one to manage members."} -{"id":"723280b37b7886ba","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-service-accounts","anchor":"cfg-service-accounts","title":"Service accounts","section":"Reference","order":0,"tokens":732,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Service accounts (part 1 of 2)\n\nWhat it is. Non-human identities — a CI pipeline, an AI agent, an\nintegration — that authenticate with an API key and never sign in. A service account\ncarries a role like any user, so the same permissions and the same per-datasource grants\napply, but it has no password, cannot use SSO, and is badged as a\nService account wherever people are listed so a reviewer never mistakes a robot\nfor a colleague.\n\nConfigure it. Manage them from /admin/service-accounts (requires the Manage service accounts permission, held by admins):\n\n- Create an account. Click Create service account, give it an\nemail (an identifier only — it never receives mail), a display name and a role. The role\ndefaults to Read-only; the page warns when you pick a role that can approve\nrequests, because an agent on such a role becomes an eligible approver. Optionally name an\nowner — the person accountable for the account, shown to attestation reviewers.\n\n- Issue a key. On the API keys tab, Issue key shows the\nplaintext exactly once — copy it into the pipeline or agent's secret store. Keys are the\naccount's only credential.\n\n- Rotate rather than revoke. Rotate issues a replacement and\nkeeps the old key working for a grace period (24 hours by default, or the number of hours\nyou enter), so a consumer can be updated without an outage. Revoke cuts access\nimmediately.\n\n- Restrict its MCP tools. The MCP tools tab limits which tools\nthe account's keys may call on the MCP server. The server still\nadvertises every tool to the account; a call outside the allow-list is denied when it is\ninvoked — the list is an enforcement boundary, not a discovery filter.\n\n- Cap its request rate. The Limits tab sets per-minute and\nper-day request ceilings for this account; leave a field blank to inherit the deployment\ndefault.\n\n- Let it act on someone's behalf. The On-behalf-of principals\ntab lists the people who consent to be named by this account through the\nX-AccessFlow-On-Behalf-Of header. A delegation confers no permission — the\naccount's own key decides what it may do — but the named person appears as an\non behalf of chip on the request and in the audit log, and counts as a submitter\nfor the self-approval ban.\n\n- Review its activity. The Activity tab is the audit log\npre-filtered to the account; /admin/audit-log can also filter by the person\nan account acted for."} -{"id":"f99a7887b94c8326","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-service-accounts","anchor":"cfg-service-accounts","title":"Service accounts","section":"Reference","order":1,"tokens":260,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Service accounts (part 2 of 2)\n\nBootstrap-managed accounts. An account declared through\nACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS__* (see\nInfrastructure as code) shows a\nBootstrap badge. Its email, display name, role and declared key are read-only on\nthe page and change only in that configuration — the declared key can be neither revoked\nnor rotated here, because the next bootstrap run would re-import it. Everything else\n(owner, description, tools, limits, delegations, extra keys) stays editable.\n\nWhere robots show up. /admin/users lists service accounts\nalongside people with a badge and a filter (People and service accounts / People only / Service accounts only); group member\nand permission pickers badge them too; and an attestation reviewer sees the account's owner\nnext to its grant."} +{"id":"723280b37b7886ba","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-service-accounts","anchor":"cfg-service-accounts","title":"Service accounts","section":"Reference","order":0,"tokens":755,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Service accounts (part 1 of 2)\n\nWhat it is. Non-human identities — a CI pipeline, an AI agent, an\nintegration — that authenticate with an API key and never sign in. A service account\ncarries a role like any user, so the same permissions and the same per-datasource grants\napply, but it has no password, cannot use SSO, and is badged as a\nService account wherever people are listed so a reviewer never mistakes a robot\nfor a colleague.\n\nConfigure it. Manage them from /admin/service-accounts (requires the Manage service accounts permission, held by admins):\n\n- Create an account. Click Create service account, give it an\nemail (an identifier only — it never receives mail), a display name and a role. The role\ndefaults to Read-only; the page warns when you pick a role that can approve\nrequests, because an agent on such a role becomes an eligible approver. Optionally name an\nowner — the person accountable for the account, shown to attestation reviewers.\n\n- Issue a key. On the API keys tab, Issue key shows the\nplaintext exactly once — copy it into the pipeline or agent's secret store. Keys are the\naccount's only credential. Give each key an optional Application name (for\nexample reporting-service) so the audit log shows which application used\nit; see Which\napplication made a request?\n\n- Rotate rather than revoke. Rotate issues a replacement and\nkeeps the old key working for a grace period (24 hours by default, or the number of hours\nyou enter), so a consumer can be updated without an outage. The replacement keeps the old\nkey's application name unless you enter a new one. Revoke cuts access\nimmediately.\n\n- Restrict its MCP tools. The MCP tools tab limits which tools\nthe account's keys may call on the MCP server. The server still\nadvertises every tool to the account; a call outside the allow-list is denied when it is\ninvoked — the list is an enforcement boundary, not a discovery filter.\n\n- Cap its request rate. The Limits tab sets per-minute and\nper-day request ceilings for this account; leave a field blank to inherit the deployment\ndefault.\n\n- Let it act on someone's behalf. The On-behalf-of principals\ntab lists the people who consent to be named by this account through the\nX-AccessFlow-On-Behalf-Of header. A delegation confers no permission — the\naccount's own key decides what it may do — but the named person appears as an\non behalf of chip on the request and in the audit log, and counts as a submitter\nfor the self-approval ban."} +{"id":"f99a7887b94c8326","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-service-accounts","anchor":"cfg-service-accounts","title":"Service accounts","section":"Reference","order":1,"tokens":308,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Service accounts (part 2 of 2)\n\n- Review its activity. The Activity tab is the audit log\npre-filtered to the account; /admin/audit-log can also filter by the person\nan account acted for.\n\nBootstrap-managed accounts. An account declared through\nACCESSFLOW_BOOTSTRAP_SERVICE_ACCOUNTS__* (see\nInfrastructure as code) shows a\nBootstrap badge. Its email, display name, role and declared key are read-only on\nthe page and change only in that configuration — the declared key can be neither revoked\nnor rotated here, because the next bootstrap run would re-import it. Everything else\n(owner, description, tools, limits, delegations, extra keys) stays editable.\n\nWhere robots show up. /admin/users lists service accounts\nalongside people with a badge and a filter (People and service accounts / People only / Service accounts only); group member\nand permission pickers badge them too; and an attestation reviewer sees the account's owner\nnext to its grant."} {"id":"2d2c812edac56dfa","path":"website/docs/guides/ai-analysis/index.html","url":"https://accessflow.io/docs/guides/ai-analysis/#guide-ai","anchor":"guide-ai","title":"What you are building","section":"Guides","order":0,"tokens":110,"text":"AccessFlow Docs > Guides > Turn on AI risk analysis > What you are building\n\nA risk verdict attached to every submitted query, so a reviewer opens their queue and\nsees a score, a level and an explanation rather than a wall of raw SQL. It does not\ndecide anything on its own — a human still approves — but it turns \"read this and\njudge it\" into \"check whether I agree\"."} {"id":"5d25cd24b73cd9b2","path":"website/docs/guides/ai-analysis/index.html","url":"https://accessflow.io/docs/guides/ai-analysis/","anchor":"","title":"Do I have to send my queries to a third party?","section":"Guides","order":0,"tokens":208,"text":"AccessFlow Docs > Guides > Turn on AI risk analysis > Do I have to send my queries to a third party?\n\nNo. Anthropic and OpenAI are hosted options, but Ollama runs models on your own hardware\nand the OpenAI-compatible option covers anything speaking that protocol — vLLM, LM\nStudio, a local text-generation server. What is sent is the SQL, the database type, and\noptionally schema names; never query results.\n\nYou can have more than one. Configurations are not one-per-organization.\nCreate as many as you need — a cheap fast model for one datasource, something stronger\nfor another — and bind each datasource to one of them. Adding a fallback priority turns\nthe set into a pool that fails over when a provider is unavailable."} {"id":"6f8261601f77dded","path":"website/docs/guides/ai-analysis/index.html","url":"https://accessflow.io/docs/guides/ai-analysis/#guide-ai-config","anchor":"guide-ai-config","title":"1. Create the configuration","section":"Guides","order":0,"tokens":618,"text":"AccessFlow Docs > Guides > Turn on AI risk analysis > 1. Create the configuration\n\nAdmin section → AI configurations → Add AI\nconfiguration. The wizard is three steps: Provider,\nConnection, Test.\n\nProvider | Default model | API key |\n\nAnthropic | claude-sonnet-4-20250514 | Required |\n\nOpenAI | gpt-4o | Required |\n\nOllama | llama3.1:70b | Not used — set the endpoint to your server |\n\nHugging Face | meta-llama/Llama-3.3-70B-Instruct | Optional — needed for the hosted router, not for a local server |\n\nCustom (OpenAI-compatible) | You supply one | Optional |\n\nThose five analyze queries; embeddings are a separate choice.\nVoyage AI (default model voyage-4, Voyage API key required)\nis an embeddings-only provider: it appears in the retrieval-augmented generation (RAG)\nknowledge base's Embedding provider list and cannot be an analysis provider.\nAnthropic is the mirror case — it analyzes queries but publishes no embeddings API, which\nis exactly why an Anthropic install needs one of the others to embed with.\n\nPicking a tile pre-fills the model and, where relevant, the endpoint. On the\nConnection step, give the configuration a name you will\nrecognise in a dropdown later, then set the model and the key.\n\nThree limits are required and pre-filled sensibly: Timeout (ms),\nMax prompt tokens and Max completion tokens. They are\ntwo separate token budgets, not one — the prompt budget bounds how much schema context\ncan be sent, the completion budget bounds the answer.\n\nThe final step sends a test prompt. Do it: a wrong key or an unreachable endpoint is far\neasier to diagnose here than as a stream of failed analyses later.\n\nThe AI configuration wizard — provider, then connection details, then a live test prompt.\n\nENCRYPTION_KEY has to be set properly. API keys are\nencrypted with it before storage. It must be a 32-byte value as 64 hex characters, and\nthe application will not start without one — but if you are running the zero-config demo\nstack, yours is the committed insecure default. Generate a real one before storing a real\nprovider key."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index 3c858a98a..a34127bb5 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "d3b6cd6a8de9", - "generatedAt": "2026-09-24T11:11:17.030Z", - "sourceCommit": "4614328fe393e510b79f671cf91bbbb416e1233a", - "chunkCount": 586, - "sha256": "d3b6cd6a8de98b1e738ff540809911a8a76effe444620108796d7773ce842ce3", + "corpusVersion": "b7e0f4f488b8", + "generatedAt": "2026-09-24T12:36:14.636Z", + "sourceCommit": "f37a068d56e37fc9f9710a5e894e3f9cf9e8e0d5", + "chunkCount": 587, + "sha256": "b7e0f4f488b88ca88bf5248c29f68ca94cb317cea961a2722eaa77989f346c38", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -180,8 +180,8 @@ "title": "Audit & compliance", "url": "https://accessflow.io/docs/configuration/audit-compliance/", "section": "Reference", - "chunks": 7, - "sha256": "c5614e71536e35690d3eb0c3f739b2f0cc183f65abde90cbc987c7ae20195581" + "chunks": 8, + "sha256": "c6395f9049daa2405813c9488864fcae4a7f2f1fbd2e8e18e15df324edc3ff40" }, { "path": "website/docs/configuration/auth/index.html", @@ -229,7 +229,7 @@ "url": "https://accessflow.io/docs/configuration/users-roles/", "section": "Reference", "chunks": 15, - "sha256": "917315b30f9410bd5d4d62489980920ef32e37377e8e4c3dfa8f671bf6d82f90" + "sha256": "6e24fffc3ed9eea66d263a4f1cfe678f47b3fec661d60c1a9e2d8f0def82cb53" }, { "path": "website/docs/guides/ai-analysis/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "f472575231cccfe92ec7133e86bf79972329f033f50e217bff9d3484b6061c6f" + "sha256": "95dd1629dd086cab9bf35f8dd5bce167b02cb35159ed22ba6cc88852ea7ce1c8" } ] } diff --git a/website/README.md b/website/README.md index 75b81a691..fdb414e87 100644 --- a/website/README.md +++ b/website/README.md @@ -90,6 +90,7 @@ the right. | [`docs/05-backend.md`](../docs/05-backend.md) "Query snapshots & replay" (AF-449), [`docs/03-data-model.md`](../docs/03-data-model.md) `query_snapshots`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) `POST /queries/{id}/replay` | the snapshot-and-replay sentence at `/features/database-access-governance/#proxy` ("kept as an exact snapshot that can be replayed in a test environment") — not on the `/features/` hub, which has no snapshot copy + the "Version history & diff · dry-run sandbox · replay" item in `/roadmap/`'s available-now **Review & access** group | | [`docs/05-backend.md`](../docs/05-backend.md) "Compliance reporting" (AF-459), [`docs/07-security.md`](../docs/07-security.md) "Compliance reporting & signed exports" + AUDITOR role matrix, [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Compliance Reporting", [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_COMPLIANCE_*` env vars | "Tamper-evident audit & compliance reports" hub card (`/features/#platform`), a shortened rewrite of the canonical copy at `/security/#audit`, + the "Compliance reports & signed exports" item in `/roadmap/`'s available-now **Compliance** group + the "Compliance reports & signed exports" section in [`docs/configuration/audit-compliance/index.html`](docs/configuration/audit-compliance/index.html) and the AUDITOR row under "User roles & RBAC" in [`docs/configuration/users-roles/index.html`](docs/configuration/users-roles/index.html) | | [`docs/05-backend.md`](../docs/05-backend.md) "SIEM & WORM audit streaming" (#628), [`docs/03-data-model.md`](../docs/03-data-model.md) `audit_sinks` + `AUDIT_SINK_*` actions, [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Audit Sinks", [`docs/07-security.md`](../docs/07-security.md) "SIEM & WORM audit streaming" + `AUDIT_SINK_MANAGE` matrix row, [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_AUDIT_SINKS_*` env vars + "S3 Object Lock prerequisite" | SIEM/WORM sentence + `SIEM streaming` / `WORM archival` chips in the "Tamper-evident audit & compliance reports" hub card (`/features/#platform`) and at `/security/#audit` + the "SIEM audit streaming & WORM archival" item in `/roadmap/`'s available-now **Auth & audit** group + the `featureList` entry, which stays on `/`, + the "Audit sinks (SIEM & WORM streaming)" section (`#cfg-audit-sinks`) in [`docs/configuration/audit-compliance/index.html`](docs/configuration/audit-compliance/index.html) | +| [`docs/07-security.md`](../docs/07-security.md) "Calling application (#938)", [`docs/05-backend.md`](../docs/05-backend.md) "Calling application (#938)", [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Calling application" under query submission + `application_name` on `/me/api-keys` and the service-account key endpoints + `applicationName` on `/admin/audit-log`, [`docs/03-data-model.md`](../docs/03-data-model.md) `api_keys.application_name` + `query_requests.application_name[_source]` | "Which application made a request?" subsection (`#cfg-audit-application`) under "Audit log" in [`docs/configuration/audit-compliance/index.html`](docs/configuration/audit-compliance/index.html) — the trusted-key vs untrusted-header split, where the name is shown and filtered, and that it never grants access; plus the *Application name* hints on the issue / rotate steps of `#cfg-service-accounts` in [`docs/configuration/users-roles/index.html`](docs/configuration/users-roles/index.html) and the `cs5` / `cs6` sentence in `#cfg-audit-sinks`. Must keep calling the header source *Untrusted* — the one word the UI shows | | [`docs/05-backend.md`](../docs/05-backend.md) "Behavioural anomaly detection (UBA)" (AF-383), [`docs/03-data-model.md`](../docs/03-data-model.md) `behavior_baseline` / `behavior_anomaly`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Behavioural Anomaly Detection (UBA)", [`docs/08-notifications.md`](../docs/08-notifications.md) `ANOMALY_DETECTED`, [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_AI_ANOMALY_*` env vars | the `Anomaly detection (UBA)` chip on the "AI query analysis" hub card (`/features/#database`) — chip only — plus the anomaly paragraphs at `/features/database-access-governance/#ai` and `#review`, and the "your own anomaly alerts" clause in the "Personalized dashboard" hub card + the "Behavioral anomaly detection (UBA)" item in `/roadmap/`'s available-now **AI & monitoring** group + the "Behavioural anomaly detection (UBA)" section in [`docs/configuration/ai/index.html`](docs/configuration/ai/index.html) and the anomaly RBAC rows under "User roles & RBAC" in [`docs/configuration/users-roles/index.html`](docs/configuration/users-roles/index.html) | | [`docs/05-backend.md`](../docs/05-backend.md) "Approval-outcome prediction" (AF-645), [`docs/03-data-model.md`](../docs/03-data-model.md) `approval_prediction_model` / `approval_predictions`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) `approval_prediction` block + `approval_probability` + `query.prediction_complete`, [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_AI_APPROVAL_PREDICTION_*` env vars | "historical approval likelihood" sentence + `Approval likelihood (advisory)` chip in the "AI query analysis" hub card (`/features/#database`) + the analyzer section at `/features/database-access-governance/#ai` + the approval-likelihood bullet in the "Review at scale without a ticket queue" persona at `/use-cases/#dba` + the "Approval-likelihood prediction" item in `/roadmap/`'s available-now **AI & monitoring** group + the "Approval-likelihood prediction" subsection (`#cfg-approval-prediction`) in [`docs/configuration/ai/index.html`](docs/configuration/ai/index.html) and the queue paragraph under "Reviewing & bulk approval" in [`docs/workflows/index.html`](docs/workflows/index.html). Keep the advisory-only framing — it is a triage signal, never auto-approval | | [`docs/05-backend.md`](../docs/05-backend.md) "Break-glass / emergency access" (AF-385), [`docs/03-data-model.md`](../docs/03-data-model.md) `break_glass_events` / `can_break_glass`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) `/queries/break-glass` + `/admin/break-glass`, [`docs/07-security.md`](../docs/07-security.md) "Break-glass / emergency access", [`docs/08-notifications.md`](../docs/08-notifications.md) `BREAK_GLASS_EXECUTED` | "break-glass / emergency access" sentence + `Break-glass emergency access` chip in the "Configurable review workflows" hub card (`/features/#database`) + `/features/database-access-governance/#review` + the on-call persona at `/use-cases/#sre` + the "Break-glass emergency access" item in `/roadmap/`'s available-now **Review & access** group + the "Break-glass / emergency access" subsection (`#cfg-break-glass`) and break-glass RBAC rows under "User roles & RBAC" in [`docs/configuration/users-roles/index.html`](docs/configuration/users-roles/index.html) | diff --git a/website/docs/configuration/audit-compliance/index.html b/website/docs/configuration/audit-compliance/index.html index dc2530b98..a18de074f 100644 --- a/website/docs/configuration/audit-compliance/index.html +++ b/website/docs/configuration/audit-compliance/index.html @@ -302,7 +302,7 @@

What makes the AccessFlow audit log tamper-evident?

/admin/audit-log — filter, paginate, verify the HMAC chain, and export to CSV.
    -
  1. Filter and search. Narrow by action, resource type, actor user id, or resource id; an optional start/end date pair scopes the window.
  2. +
  3. Filter and search. Narrow by action, resource type, actor user id, application, or resource id; an optional start/end date pair scopes the window.
  4. 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.
  5. 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).
@@ -313,6 +313,26 @@

What makes the AccessFlow audit log tamper-evident?

AUDIT_DB_USER / AUDIT_DB_PASSWORD role that has no UPDATE / DELETE rights on the log.

+

Which application made a request?

+

+ 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 + when you create it — on your profile, or on a service account's API keys tab. + Every request made with that key is recorded under that name. Nobody can fake it + without holding the key.
  • +
  • From a request header (untrusted). A caller without a named key can + send an X-AccessFlow-Application header. Because the caller chooses the + value, AccessFlow records it but marks it Untrusted wherever it is shown.
  • +
+

+ The application appears under the actor in the audit log, in the entry's detail panel, + 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. +

@@ -325,7 +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. + 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 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