Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<mount>/<path>#<field>`, `aws:<name-or-arn>[#jsonField]`, `azure:<secret-name>`) 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand Down Expand Up @@ -61,6 +62,13 @@ static Specification<AuditLogEntity> 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]));
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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. */
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<AuditLogView> page = auditLogService.query(organizationId, filter,
SpringPageableAdapter.toPageRequest(pageable));
Map<UUID, UserView> users = lookupUsers(organizationId, page);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReviewDecisionView> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading