From 318c23837133df83be69a32816c4593c335df367 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Fri, 25 Sep 2026 17:00:00 +0400 Subject: [PATCH 1/4] feat(AF-942): per-user data-volume budgets over a rolling usage ledger Datasource-scoped budgets bound the rows and result bytes each targeted user may read over a rolling window. Reads are capped at the allowance left; an exhausted budget rejects the SELECT or forces human review, and only a review the exhausted budget itself forced may run past it. Usage is an append-only ledger charged by query execution, request-group members and sample data, pruned by a locked job. Adds the data_budget_used_percent routing operand, the DATA_BUDGET decision step, DATA_BUDGET_MANAGE, audit actions and two notification events. Refs #942 --- .../accessflow/audit/api/AuditAction.java | 9 + .../audit/api/AuditResourceType.java | 1 + .../core/api/DataBudgetAdminService.java | 21 + .../core/api/DataBudgetBreachAction.java | 20 + .../core/api/DataBudgetCommand.java | 22 + .../core/api/DataBudgetConsumption.java | 46 +++ .../core/api/DataBudgetException.java | 9 + .../api/DataBudgetExhaustedException.java | 21 + .../core/api/DataBudgetNotFoundException.java | 10 + .../accessflow/core/api/DataBudgetStatus.java | 72 ++++ .../core/api/DataBudgetStatusService.java | 17 + .../core/api/DataBudgetUsageRecord.java | 26 ++ .../core/api/DataBudgetUsageService.java | 13 + .../core/api/DataBudgetUsageSource.java | 8 + .../accessflow/core/api/DataBudgetView.java | 28 ++ .../core/api/IllegalDataBudgetException.java | 13 + .../accessflow/core/api/Permission.java | 2 + .../core/api/QueryExecutionRequest.java | 45 ++- .../core/api/QueryRequestStateService.java | 12 + .../core/api/SelectExecutionResult.java | 55 ++- .../DataBudgetThresholdCrossedEvent.java | 28 ++ .../core/internal/AppliesToMatcher.java | 46 +++ .../DefaultDataBudgetAdminService.java | 195 +++++++++ .../DefaultDataBudgetStatusService.java | 123 ++++++ .../DefaultDataBudgetUsageService.java | 106 +++++ .../DefaultQueryRequestStateService.java | 16 + ...efaultRowLimitPolicyResolutionService.java | 33 +- .../config/CorePropertiesConfiguration.java | 3 +- .../internal/config/DataBudgetProperties.java | 27 ++ .../persistence/entity/DataBudgetEntity.java | 91 +++++ .../entity/DataBudgetUsageEntity.java | 61 +++ .../entity/QueryRequestEntity.java | 5 + .../repo/DataBudgetRepository.java | 20 + .../repo/DataBudgetUsageRepository.java | 43 ++ .../scheduled/DataBudgetUsagePruneJob.java | 39 ++ .../api/NotificationEventType.java | 12 + .../internal/DataBudgetNotice.java | 65 +++ .../DataBudgetNotificationListener.java | 30 ++ .../internal/NotificationContext.java | 87 +++- .../internal/NotificationContextBuilder.java | 77 +++- .../internal/NotificationDispatcher.java | 27 +- .../internal/codec/PagerDutyTrigger.java | 4 +- .../internal/strategy/DataBudgetText.java | 52 +++ .../strategy/DiscordPayloadFactory.java | 14 + .../strategy/EmailNotificationStrategy.java | 23 ++ .../strategy/MsTeamsPayloadFactory.java | 18 + .../strategy/PagerDutyPayloadFactory.java | 11 + .../strategy/SlackBlockKitFactory.java | 14 + .../strategy/TelegramMessageFactory.java | 13 + .../strategy/TicketDescriptionBuilder.java | 4 + .../strategy/WebhookPayloadFactory.java | 19 + .../proxy/internal/DefaultQueryExecutor.java | 22 + .../internal/DefaultSampleDataService.java | 78 +++- .../internal/GroupExecutionService.java | 59 +++ .../internal/web/DataBudgetController.java | 155 +++++++ .../web/DataBudgetStatusController.java | 69 ++++ .../internal/web/GlobalExceptionHandler.java | 37 ++ .../web/model/DataBudgetListResponse.java | 5 + .../internal/web/model/DataBudgetRequest.java | 32 ++ .../web/model/DataBudgetResponse.java | 43 ++ .../model/DataBudgetStatusListResponse.java | 5 + .../web/model/DataBudgetStatusResponse.java | 62 +++ .../workflow/api/ConditionContext.java | 26 +- .../workflow/api/ConditionNode.java | 19 + .../workflow/api/QueryDecisionStepKind.java | 8 + .../workflow/internal/DataBudgetCheck.java | 39 ++ .../DefaultAccessSimulationService.java | 10 +- .../DefaultQueryLifecycleService.java | 118 +++++- .../workflow/internal/QueryDecision.java | 17 +- .../internal/QueryDecisionEvaluator.java | 117 +++++- .../workflow/internal/QueryDecisionKind.java | 8 +- .../internal/QueryReviewStateMachine.java | 66 ++- .../routing/ConditionContextFactory.java | 15 +- .../internal/routing/ConditionNodeMixin.java | 1 + .../routing/RoutingConditionEvaluator.java | 2 + .../migration/V193__create_data_budgets.sql | 57 +++ ...194__add_data_budget_manage_permission.sql | 7 + ...5__add_query_data_budget_review_forced.sql | 5 + .../main/resources/i18n/messages.properties | 52 +++ .../resources/i18n/messages_de.properties | 52 +++ .../resources/i18n/messages_es.properties | 52 +++ .../resources/i18n/messages_fr.properties | 52 +++ .../resources/i18n/messages_hy.properties | 52 +++ .../resources/i18n/messages_ru.properties | 52 +++ .../resources/i18n/messages_zh_CN.properties | 52 +++ .../email/data-budget-exhausted.html | 47 +++ .../email/data-budget-threshold-reached.html | 47 +++ .../core/api/DataBudgetStatusTest.java | 112 ++++++ .../core/api/QueryExecutionRequestTest.java | 37 ++ .../core/api/SelectExecutionResultTest.java | 27 ++ .../core/internal/AppliesToMatcherTest.java | 39 ++ .../DefaultDataBudgetAdminServiceTest.java | 277 +++++++++++++ .../DefaultDataBudgetStatusServiceTest.java | 181 +++++++++ .../DefaultDataBudgetUsageServiceTest.java | 180 +++++++++ .../DefaultQueryRequestStateServiceTest.java | 23 ++ .../config/DataBudgetPropertiesTest.java | 20 + .../DataBudgetUsagePruneJobTest.java | 48 +++ .../internal/DataBudgetNoticeTest.java | 45 +++ .../DataBudgetNotificationListenerTest.java | 48 +++ .../NotificationContextBuilderTest.java | 62 +++ .../internal/NotificationDispatcherTest.java | 78 ++++ .../strategy/DataBudgetNotificationTest.java | 267 +++++++++++++ .../internal/strategy/DataBudgetTextTest.java | 42 ++ .../internal/DefaultQueryExecutorTest.java | 38 ++ .../DefaultSampleDataServiceTest.java | 120 ++++++ .../internal/GroupExecutionServiceTest.java | 95 +++++ .../DataBudgetControllerIntegrationTest.java | 375 +++++++++++++++++ .../workflow/api/ConditionNodeTest.java | 21 + .../internal/DataBudgetCheckTest.java | 53 +++ .../DataBudgetEnforcementIntegrationTest.java | 378 ++++++++++++++++++ .../DefaultAccessSimulationServiceTest.java | 61 ++- .../DefaultQueryLifecycleServiceTest.java | 218 +++++++++- .../internal/QueryDecisionEvaluatorTest.java | 167 +++++++- .../internal/QueryReviewStateMachineTest.java | 91 ++++- .../routing/ConditionContextFactoryTest.java | 52 ++- .../routing/RoutingConditionCodecTest.java | 11 + .../RoutingConditionEvaluatorTest.java | 22 + ...ssSimulationControllerIntegrationTest.java | 17 +- 118 files changed, 6407 insertions(+), 94 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetAdminService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetBreachAction.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetConsumption.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetExhaustedException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetNotFoundException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatus.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatusService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageRecord.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageSource.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetView.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/IllegalDataBudgetException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/events/DataBudgetThresholdCrossedEvent.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/AppliesToMatcher.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/config/DataBudgetProperties.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetEntity.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetUsageEntity.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetRepository.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetUsageRepository.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJob.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotice.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListener.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetText.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetStatusController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetListResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusListResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheck.java create mode 100644 backend/src/main/resources/db/migration/V193__create_data_budgets.sql create mode 100644 backend/src/main/resources/db/migration/V194__add_data_budget_manage_permission.sql create mode 100644 backend/src/main/resources/db/migration/V195__add_query_data_budget_review_forced.sql create mode 100644 backend/src/main/resources/templates/email/data-budget-exhausted.html create mode 100644 backend/src/main/resources/templates/email/data-budget-threshold-reached.html create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/api/DataBudgetStatusTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/AppliesToMatcherTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/config/DataBudgetPropertiesTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJobTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNoticeTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListenerTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetNotificationTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetTextTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DataBudgetControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheckTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetEnforcementIntegrationTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java index 415bec54b..1f2976bb9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java +++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java @@ -140,6 +140,15 @@ public enum AuditAction { * {@code estimated_bytes} and {@code outcome}. */ QUERY_BYTES_SCANNED_CAP_ENFORCED, + /** + * An exhausted data-volume budget (#942) refused a query or turned an automatic approval into + * human review ({@code stage=decision}), or refused an execution ({@code stage=execution}). + */ + QUERY_DATA_BUDGET_ENFORCED, + /** A data-volume budget (#942) was created, updated or deleted. */ + DATA_BUDGET_CREATED, + DATA_BUDGET_UPDATED, + DATA_BUDGET_DELETED, ROW_SECURITY_POLICY_CREATED, ROW_SECURITY_POLICY_UPDATED, ROW_SECURITY_POLICY_DELETED, diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java index fc969c3c5..48ba16088 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java +++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java @@ -37,6 +37,7 @@ public enum AuditResourceType { SERVICE_ACCOUNT("service_account"), ROW_SECURITY_POLICY("row_security_policy"), ROW_LIMIT_POLICY("row_limit_policy"), + DATA_BUDGET("data_budget"), CONNECTOR("connector"), QUERY_COMMENT("query_comment"), DATA_CLASSIFICATION_TAG("data_classification_tag"), diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetAdminService.java new file mode 100644 index 000000000..5b8bc0b51 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetAdminService.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.List; +import java.util.UUID; + +/** + * Admin CRUD for per-user data-volume budgets on a datasource (#942). All methods are + * organization-scoped: a datasource outside {@code organizationId} raises + * {@link DatasourceNotFoundException}; {@code applies_to} targets must belong to the organization. + */ +public interface DataBudgetAdminService { + + List listForDatasource(UUID datasourceId, UUID organizationId); + + DataBudgetView create(UUID datasourceId, UUID organizationId, DataBudgetCommand command); + + DataBudgetView update(UUID budgetId, UUID datasourceId, UUID organizationId, + DataBudgetCommand command); + + void delete(UUID budgetId, UUID datasourceId, UUID organizationId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetBreachAction.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetBreachAction.java new file mode 100644 index 000000000..0da63f7f6 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetBreachAction.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.core.api; + +/** + * What happens to a SELECT once a data budget (#942) is exhausted. {@link #REJECT} beats + * {@link #REQUIRE_REVIEW} when several exhausted budgets disagree. + */ +public enum DataBudgetBreachAction { + REJECT, + REQUIRE_REVIEW; + + public static DataBudgetBreachAction strictest(DataBudgetBreachAction a, DataBudgetBreachAction b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a == REJECT || b == REJECT ? REJECT : REQUIRE_REVIEW; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetCommand.java new file mode 100644 index 000000000..f1ec83386 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetCommand.java @@ -0,0 +1,22 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.List; +import java.util.UUID; + +/** + * Create/update payload for a data budget (#942). Updates are total: every field is re-applied. + * At least one of {@code maxRows} / {@code maxBytes} must be set; {@code windowMinutes} and + * {@code breachAction} fall back to 1440 and {@link DataBudgetBreachAction#REQUIRE_REVIEW}. + */ +public record DataBudgetCommand( + String name, + Long maxRows, + Long maxBytes, + Integer windowMinutes, + DataBudgetBreachAction breachAction, + Integer warnThresholdPercent, + List appliesToRoles, + List appliesToGroupIds, + List appliesToUserIds, + Boolean enabled) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetConsumption.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetConsumption.java new file mode 100644 index 000000000..914943302 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetConsumption.java @@ -0,0 +1,46 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.UUID; + +/** + * One budget's consumption for one user over its trailing window (#942). A null limit means the + * budget does not bound that metric; the matching {@code remaining*} is then null too. + */ +public record DataBudgetConsumption( + UUID budgetId, + String name, + Long maxRows, + Long maxBytes, + int windowMinutes, + DataBudgetBreachAction breachAction, + Integer warnThresholdPercent, + long usedRows, + long usedBytes) { + + public Long remainingRows() { + return maxRows == null ? null : Math.max(0, maxRows - usedRows); + } + + public Long remainingBytes() { + return maxBytes == null ? null : Math.max(0, maxBytes - usedBytes); + } + + public boolean exhausted() { + return (maxRows != null && usedRows >= maxRows) || (maxBytes != null && usedBytes >= maxBytes); + } + + /** The larger of the rows and bytes percentages used, unclamped (may exceed 100). */ + public double usedPercent() { + return Math.max(percent(usedRows, maxRows), percent(usedBytes, maxBytes)); + } + + /** This consumption after {@code rows} / {@code bytes} more were read. */ + public DataBudgetConsumption plus(long rows, long bytes) { + return new DataBudgetConsumption(budgetId, name, maxRows, maxBytes, windowMinutes, + breachAction, warnThresholdPercent, usedRows + rows, usedBytes + bytes); + } + + private static double percent(long used, Long limit) { + return limit == null ? 0d : used * 100d / limit; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetException.java new file mode 100644 index 000000000..001480f15 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetException.java @@ -0,0 +1,9 @@ +package com.bablsoft.accessflow.core.api; + +public sealed class DataBudgetException extends RuntimeException + permits DataBudgetNotFoundException, IllegalDataBudgetException { + + protected DataBudgetException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetExhaustedException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetExhaustedException.java new file mode 100644 index 000000000..f934f3326 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetExhaustedException.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.core.api; + +/** + * A read was refused because the reader's data budget (#942) is exhausted — at execution under + * {@link DataBudgetBreachAction#REJECT} (or {@code REQUIRE_REVIEW} without a human approval), and + * on the sample-data path, which has no review to escalate to. The message is the caller's + * localized explanation; execution paths record it as the failure reason. + */ +public class DataBudgetExhaustedException extends RuntimeException { + + private final transient DataBudgetConsumption budget; + + public DataBudgetExhaustedException(String message, DataBudgetConsumption budget) { + super(message); + this.budget = budget; + } + + public DataBudgetConsumption budget() { + return budget; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetNotFoundException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetNotFoundException.java new file mode 100644 index 000000000..f71fecd75 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetNotFoundException.java @@ -0,0 +1,10 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.UUID; + +public final class DataBudgetNotFoundException extends DataBudgetException { + + public DataBudgetNotFoundException(UUID id) { + super("Data budget not found: " + id); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatus.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatus.java new file mode 100644 index 000000000..09697fd22 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatus.java @@ -0,0 +1,72 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * A user's effective data-budget standing on one datasource (#942): every enabled budget that + * applies to them, each over its own window. The effective view is the most constrained budget — + * the smallest remaining allowance per metric, exhausted when any budget is, and the strictest + * breach action among the exhausted ones. An empty status means no budget applies. + */ +public record DataBudgetStatus(UUID datasourceId, String datasourceName, + List budgets) { + + public DataBudgetStatus { + budgets = budgets == null ? List.of() : List.copyOf(budgets); + } + + public static DataBudgetStatus none(UUID datasourceId) { + return new DataBudgetStatus(datasourceId, null, List.of()); + } + + public boolean isEmpty() { + return budgets.isEmpty(); + } + + public boolean exhausted() { + return budgets.stream().anyMatch(DataBudgetConsumption::exhausted); + } + + /** The strictest action among exhausted budgets; null while none is exhausted. */ + public DataBudgetBreachAction breachAction() { + DataBudgetBreachAction action = null; + for (var budget : budgets) { + if (budget.exhausted()) { + action = DataBudgetBreachAction.strictest(action, budget.breachAction()); + } + } + return action; + } + + /** The exhausted budget that decides {@link #breachAction()}; null while none is exhausted. */ + public DataBudgetConsumption decidingBudget() { + var action = breachAction(); + if (action == null) { + return null; + } + return budgets.stream() + .filter(b -> b.exhausted() && b.breachAction() == action) + .findFirst() + .orElse(null); + } + + /** Smallest remaining row allowance across budgets bounding rows; null when none does. */ + public Long remainingRows() { + return budgets.stream().map(DataBudgetConsumption::remainingRows).filter(Objects::nonNull) + .min(Long::compare).orElse(null); + } + + /** Smallest remaining byte allowance across budgets bounding bytes; null when none does. */ + public Long remainingBytes() { + return budgets.stream().map(DataBudgetConsumption::remainingBytes).filter(Objects::nonNull) + .min(Long::compare).orElse(null); + } + + /** The highest percentage used across budgets; null when no budget applies. */ + public Double usedPercent() { + return budgets.stream().map(DataBudgetConsumption::usedPercent) + .max(Double::compare).orElse(null); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatusService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatusService.java new file mode 100644 index 000000000..9a57e0c6e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetStatusService.java @@ -0,0 +1,17 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.List; +import java.util.UUID; + +/** + * Reads a user's data-budget standing (#942) from the append-only usage ledger — a trailing-window + * sum per budget, no counter table and no reset job. + */ +public interface DataBudgetStatusService { + + /** The user's standing on one datasource; {@link DataBudgetStatus#isEmpty()} when unbudgeted. */ + DataBudgetStatus statusFor(UUID datasourceId, UUID userId); + + /** The user's standing on every datasource of the organization where a budget applies. */ + List statusesForUser(UUID organizationId, UUID userId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageRecord.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageRecord.java new file mode 100644 index 000000000..008526ad2 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageRecord.java @@ -0,0 +1,26 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.Objects; +import java.util.UUID; + +/** + * One delivered SELECT result to charge against a user's data budgets (#942): the rows and + * estimated result bytes the user actually received, after row security and every cap. + */ +public record DataBudgetUsageRecord( + UUID userId, + UUID datasourceId, + long rowsRead, + long bytesRead, + DataBudgetUsageSource source, + UUID queryRequestId, + UUID requestGroupId) { + + public DataBudgetUsageRecord { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(datasourceId, "datasourceId"); + Objects.requireNonNull(source, "source"); + rowsRead = Math.max(0, rowsRead); + bytesRead = Math.max(0, bytesRead); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageService.java new file mode 100644 index 000000000..3cd346afa --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageService.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.core.api; + +/** + * Charges delivered reads to the usage ledger (#942). A read on a datasource where no budget + * applies to the user writes nothing. Crossing a budget's warn threshold or its limit publishes a + * {@code core.events.DataBudgetThresholdCrossedEvent}, computed statelessly from the before/after + * consumption, so no dedup table is needed. Runs in its own transaction: a failure never affects + * the read that was already delivered, and callers log rather than propagate it. + */ +public interface DataBudgetUsageService { + + void record(DataBudgetUsageRecord usage); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageSource.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageSource.java new file mode 100644 index 000000000..ade5bad30 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetUsageSource.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.core.api; + +/** Which read path delivered the rows a data-budget ledger entry (#942) counts. */ +public enum DataBudgetUsageSource { + QUERY, + REQUEST_GROUP, + SAMPLE_DATA +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetView.java new file mode 100644 index 000000000..aa8c178e8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DataBudgetView.java @@ -0,0 +1,28 @@ +package com.bablsoft.accessflow.core.api; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record DataBudgetView( + UUID id, + UUID datasourceId, + String name, + Long maxRows, + Long maxBytes, + int windowMinutes, + DataBudgetBreachAction breachAction, + Integer warnThresholdPercent, + List appliesToRoles, + List appliesToGroupIds, + List appliesToUserIds, + boolean enabled, + Instant createdAt, + Instant updatedAt) { + + public DataBudgetView { + appliesToRoles = appliesToRoles == null ? List.of() : List.copyOf(appliesToRoles); + appliesToGroupIds = appliesToGroupIds == null ? List.of() : List.copyOf(appliesToGroupIds); + appliesToUserIds = appliesToUserIds == null ? List.of() : List.copyOf(appliesToUserIds); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/IllegalDataBudgetException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/IllegalDataBudgetException.java new file mode 100644 index 000000000..b83618119 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/IllegalDataBudgetException.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.core.api; + +/** + * Raised when a data-budget create/update request is structurally invalid — no limit, a limit or + * window out of range, an unknown {@code applies_to} role, or a user/group outside the + * organization. The {@code message} is a resolved, localized string. + */ +public final class IllegalDataBudgetException extends DataBudgetException { + + public IllegalDataBudgetException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/Permission.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/Permission.java index e0aa10dc4..d075b402a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/Permission.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/Permission.java @@ -65,6 +65,8 @@ public enum Permission { EXPORT_POLICY_MANAGE(PermissionGroup.DATA_POLICIES), /** Manage per-table row-limit policies (#934). */ ROW_LIMIT_POLICY_MANAGE(PermissionGroup.DATA_POLICIES), + /** Manage per-user data-volume budgets (#942). */ + DATA_BUDGET_MANAGE(PermissionGroup.DATA_POLICIES), /** Manage review plans. */ REVIEW_PLAN_MANAGE(PermissionGroup.WORKFLOW_ADMIN), diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryExecutionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryExecutionRequest.java index 2ef079184..2c325b3f7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryExecutionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryExecutionRequest.java @@ -12,6 +12,10 @@ * {@code schema.table} or bare {@code table}) so the proxy can index cached SELECT results for * write-invalidation. An empty set means "tables unknown": SELECTs are then never cached and * writes purge the whole datasource cache (fail-safe). + * + *

{@code maxResultBytesOverride} (#942) is the reader's remaining data-budget byte allowance: + * the proxy trims a SELECT result past it (the first row is always kept) and marks it truncated + * with {@link SelectExecutionResult#TRUNCATED_DATA_BUDGET}. {@code null} leaves only the global cap. */ public record QueryExecutionRequest( UUID datasourceId, @@ -25,7 +29,8 @@ public record QueryExecutionRequest( boolean transactional, List statements, List softDeleteDirectives, - Set referencedTables) { + Set referencedTables, + Long maxResultBytesOverride) { public QueryExecutionRequest { Objects.requireNonNull(datasourceId, "datasourceId"); @@ -36,6 +41,9 @@ public record QueryExecutionRequest( if (maxRowsOverride != null && maxRowsOverride <= 0) { throw new IllegalArgumentException("maxRowsOverride must be positive"); } + if (maxResultBytesOverride != null && maxResultBytesOverride <= 0) { + throw new IllegalArgumentException("maxResultBytesOverride must be positive"); + } if (statementTimeoutOverride != null && (statementTimeoutOverride.isNegative() || statementTimeoutOverride.isZero())) { throw new IllegalArgumentException("statementTimeoutOverride must be positive"); @@ -66,6 +74,39 @@ public record QueryExecutionRequest( } } + /** Backward-compatible constructor without a result-byte override (#942). */ + public QueryExecutionRequest(UUID datasourceId, String sql, QueryType queryType, + Integer maxRowsOverride, Duration statementTimeoutOverride, + List restrictedColumns, List columnMasks, + List rowSecurityPredicates, + boolean transactional, List statements, + List softDeleteDirectives, + Set referencedTables) { + this(datasourceId, sql, queryType, maxRowsOverride, statementTimeoutOverride, + restrictedColumns, columnMasks, rowSecurityPredicates, transactional, statements, + softDeleteDirectives, referencedTables, null); + } + + /** + * Returns a copy whose row and result-byte caps are lowered to the given allowance (#942); + * a null argument leaves that cap unchanged, and neither can ever raise an existing cap. + */ + public QueryExecutionRequest withAllowance(Long maxRows, Long maxBytes) { + Integer rows = maxRowsOverride; + if (maxRows != null) { + int allowance = (int) Math.min(Integer.MAX_VALUE, Math.max(1, maxRows)); + rows = rows == null ? allowance : Math.min(rows, allowance); + } + Long bytes = maxResultBytesOverride; + if (maxBytes != null) { + long allowance = Math.max(1, maxBytes); + bytes = bytes == null ? allowance : Math.min(bytes, allowance); + } + return new QueryExecutionRequest(datasourceId, sql, queryType, rows, statementTimeoutOverride, + restrictedColumns, columnMasks, rowSecurityPredicates, transactional, statements, + softDeleteDirectives, referencedTables, bytes); + } + /** Backward-compatible constructor without referenced tables (defaults to unknown). */ public QueryExecutionRequest(UUID datasourceId, String sql, QueryType queryType, Integer maxRowsOverride, Duration statementTimeoutOverride, @@ -75,7 +116,7 @@ public QueryExecutionRequest(UUID datasourceId, String sql, QueryType queryType, List softDeleteDirectives) { this(datasourceId, sql, queryType, maxRowsOverride, statementTimeoutOverride, restrictedColumns, columnMasks, rowSecurityPredicates, transactional, statements, - softDeleteDirectives, Set.of()); + softDeleteDirectives, Set.of(), null); } public QueryExecutionRequest(UUID datasourceId, String sql, QueryType queryType, diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryRequestStateService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryRequestStateService.java index 826e2d896..abbd024ca 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryRequestStateService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryRequestStateService.java @@ -33,6 +33,18 @@ public interface QueryRequestStateService { void recordBytesScannedCap(UUID queryRequestId, long limit, BytesScannedCapSource source, BytesScannedCapOutcome outcome); + /** + * Stamps that an exhausted {@code REQUIRE_REVIEW} data budget (#942) forced the query into + * human review as it left {@code PENDING_AI}. A plain stamp, not a transition. + */ + void recordDataBudgetReviewForced(UUID queryRequestId); + + /** + * Whether an exhausted data budget forced this query into review (#942) — the only case in which + * an approved query may run past an exhausted {@code REQUIRE_REVIEW} budget. + */ + boolean isDataBudgetReviewForced(UUID queryRequestId); + /** * Inserts an {@code APPROVED} {@link com.bablsoft.accessflow.core.api.DecisionType} row * for the given reviewer/stage and, if the per-stage threshold is now met AND it was the diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java index 78f377c1a..bd563f8cd 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/SelectExecutionResult.java @@ -14,12 +14,19 @@ public record SelectExecutionResult( Set appliedMaskingPolicyIds, Set appliedRowSecurityPolicyIds, String truncatedReason, - String effectiveSql) implements QueryExecutionResult { + String effectiveSql, + long resultBytes) implements QueryExecutionResult { /** {@link #truncatedReason()} value when the configured row cap cut the result short. */ public static final String TRUNCATED_ROW_LIMIT = "ROW_LIMIT"; /** {@link #truncatedReason()} value when the configured byte cap cut the result short. */ public static final String TRUNCATED_BYTE_LIMIT = "BYTE_LIMIT"; + /** + * {@link #truncatedReason()} value when the reader's remaining data-budget allowance (#942) + * cut the result short — set by the proxy's result-byte override trim and by the workflow when + * the budget's row allowance was the binding row cap. + */ + public static final String TRUNCATED_DATA_BUDGET = "DATA_BUDGET"; public SelectExecutionResult { columns = List.copyOf(columns); @@ -32,14 +39,14 @@ public record SelectExecutionResult( public SelectExecutionResult(List columns, List> rows, long rowCount, boolean truncated, Duration duration) { - this(columns, rows, rowCount, truncated, duration, Set.of(), Set.of(), null, null); + this(columns, rows, rowCount, truncated, duration, Set.of(), Set.of(), null, null, 0L); } public SelectExecutionResult(List columns, List> rows, long rowCount, boolean truncated, Duration duration, Set appliedMaskingPolicyIds) { this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, Set.of(), null, - null); + null, 0L); } public SelectExecutionResult(List columns, List> rows, long rowCount, @@ -47,7 +54,7 @@ public SelectExecutionResult(List columns, List> rows Set appliedMaskingPolicyIds, Set appliedRowSecurityPolicyIds) { this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, - appliedRowSecurityPolicyIds, null, null); + appliedRowSecurityPolicyIds, null, null, 0L); } /** Pre-#937 canonical shape — kept so published engine plugins stay binary-compatible. */ @@ -56,13 +63,23 @@ public SelectExecutionResult(List columns, List> rows Set appliedMaskingPolicyIds, Set appliedRowSecurityPolicyIds, String truncatedReason) { this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, - appliedRowSecurityPolicyIds, truncatedReason, null); + appliedRowSecurityPolicyIds, truncatedReason, null, 0L); + } + + /** Pre-#942 canonical shape — kept so published engine plugins stay binary-compatible. */ + public SelectExecutionResult(List columns, List> rows, long rowCount, + boolean truncated, Duration duration, + Set appliedMaskingPolicyIds, + Set appliedRowSecurityPolicyIds, String truncatedReason, + String effectiveSql) { + this(columns, rows, rowCount, truncated, duration, appliedMaskingPolicyIds, + appliedRowSecurityPolicyIds, truncatedReason, effectiveSql, 0L); } /** Returns a copy of this result with the given row-security policy ids attached. */ public SelectExecutionResult withRowSecurityPolicyIds(Set ids) { return new SelectExecutionResult(columns, rows, rowCount, truncated, duration, - appliedMaskingPolicyIds, ids, truncatedReason, effectiveSql); + appliedMaskingPolicyIds, ids, truncatedReason, effectiveSql, resultBytes); } /** @@ -72,6 +89,30 @@ public SelectExecutionResult withRowSecurityPolicyIds(Set ids) { */ public SelectExecutionResult withEffectiveSql(String sql) { return new SelectExecutionResult(columns, rows, rowCount, truncated, duration, - appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, truncatedReason, sql); + appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, truncatedReason, sql, + resultBytes); + } + + /** + * Returns a copy holding only the first {@code keptRows} rows, flagged truncated for + * {@code reason}, with {@code bytes} as the delivered size — the proxy's byte trim (#942). + */ + public SelectExecutionResult truncatedTo(int keptRows, String reason, long bytes) { + return new SelectExecutionResult(columns, rows.subList(0, keptRows), keptRows, true, duration, + appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, reason, effectiveSql, bytes); + } + + /** Returns a copy carrying the estimated delivered size in bytes (#942). */ + public SelectExecutionResult withResultBytes(long bytes) { + return new SelectExecutionResult(columns, rows, rowCount, truncated, duration, + appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, truncatedReason, effectiveSql, + bytes); + } + + /** Returns a copy whose truncation is attributed to {@code reason}. */ + public SelectExecutionResult withTruncatedReason(String reason) { + return new SelectExecutionResult(columns, rows, rowCount, truncated, duration, + appliedMaskingPolicyIds, appliedRowSecurityPolicyIds, reason, effectiveSql, + resultBytes); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/events/DataBudgetThresholdCrossedEvent.java b/backend/src/main/java/com/bablsoft/accessflow/core/events/DataBudgetThresholdCrossedEvent.java new file mode 100644 index 000000000..c8e9773f1 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/events/DataBudgetThresholdCrossedEvent.java @@ -0,0 +1,28 @@ +package com.bablsoft.accessflow.core.events; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; + +import java.util.UUID; + +/** + * A recorded read carried a user across one of their data budgets' marks (#942): its warn + * threshold ({@code exhausted=false}) or its limit ({@code exhausted=true}). Published once per + * crossing — a read that stays above a mark never re-publishes, and a read that jumps straight past + * the limit publishes only the exhausted crossing. + */ +public record DataBudgetThresholdCrossedEvent( + UUID organizationId, + UUID userId, + UUID datasourceId, + UUID budgetId, + String budgetName, + boolean exhausted, + Integer warnThresholdPercent, + int usedPercent, + Long maxRows, + Long maxBytes, + long usedRows, + long usedBytes, + int windowMinutes, + DataBudgetBreachAction breachAction) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/AppliesToMatcher.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/AppliesToMatcher.java new file mode 100644 index 000000000..8a339d558 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/AppliesToMatcher.java @@ -0,0 +1,46 @@ +package com.bablsoft.accessflow.core.internal; + +import java.util.Set; +import java.util.UUID; + +/** + * The {@code applies_to_roles / _group_ids / _user_ids} scope shared by row-limit policies (#934) + * and data budgets (#942): all three empty ⇒ every user; otherwise a match on any one list. + */ +final class AppliesToMatcher { + + private AppliesToMatcher() { + } + + static boolean matches(String[] roles, UUID[] groups, UUID[] users, UUID userId, + String roleName, Set groupIds) { + boolean hasRoles = roles != null && roles.length > 0; + boolean hasGroups = groups != null && groups.length > 0; + boolean hasUsers = users != null && users.length > 0; + if (!hasRoles && !hasGroups && !hasUsers) { + return true; + } + if (hasRoles && roleName != null) { + for (var allowed : roles) { + if (allowed != null && roleName.equalsIgnoreCase(allowed.trim())) { + return true; + } + } + } + if (hasUsers) { + for (var allowed : users) { + if (userId.equals(allowed)) { + return true; + } + } + } + if (hasGroups) { + for (var allowed : groups) { + if (groupIds.contains(allowed)) { + return true; + } + } + } + return false; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminService.java new file mode 100644 index 000000000..7b19089e6 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminService.java @@ -0,0 +1,195 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetAdminService; +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetCommand; +import com.bablsoft.accessflow.core.api.DataBudgetNotFoundException; +import com.bablsoft.accessflow.core.api.DataBudgetView; +import com.bablsoft.accessflow.core.api.DatasourceNotFoundException; +import com.bablsoft.accessflow.core.api.IllegalDataBudgetException; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +class DefaultDataBudgetAdminService implements DataBudgetAdminService { + + static final int MIN_WINDOW_MINUTES = 60; + static final int MAX_WINDOW_MINUTES = 44_640; + static final int DEFAULT_WINDOW_MINUTES = 1_440; + static final int MAX_NAME_LENGTH = 120; + + private final DataBudgetRepository dataBudgetRepository; + private final RoleRepository roleRepository; + private final DatasourceRepository datasourceRepository; + private final UserRepository userRepository; + private final UserGroupRepository userGroupRepository; + private final MessageSource messageSource; + + @Override + @Transactional(readOnly = true) + public List listForDatasource(UUID datasourceId, UUID organizationId) { + requireDatasourceInOrganization(datasourceId, organizationId); + return dataBudgetRepository + .findAllByOrganizationIdAndDatasourceIdOrderByCreatedAtAsc(organizationId, datasourceId) + .stream() + .map(DefaultDataBudgetAdminService::toView) + .toList(); + } + + @Override + @Transactional + public DataBudgetView create(UUID datasourceId, UUID organizationId, DataBudgetCommand command) { + requireDatasourceInOrganization(datasourceId, organizationId); + var entity = new DataBudgetEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(organizationId); + entity.setDatasourceId(datasourceId); + apply(entity, organizationId, command); + return toView(dataBudgetRepository.save(entity)); + } + + @Override + @Transactional + public DataBudgetView update(UUID budgetId, UUID datasourceId, UUID organizationId, + DataBudgetCommand command) { + var entity = loadInScope(budgetId, datasourceId, organizationId); + apply(entity, organizationId, command); + return toView(dataBudgetRepository.save(entity)); + } + + @Override + @Transactional + public void delete(UUID budgetId, UUID datasourceId, UUID organizationId) { + dataBudgetRepository.delete(loadInScope(budgetId, datasourceId, organizationId)); + } + + private void apply(DataBudgetEntity entity, UUID organizationId, DataBudgetCommand command) { + var name = command.name() == null ? "" : command.name().trim(); + if (name.isEmpty() || name.length() > MAX_NAME_LENGTH) { + throw new IllegalDataBudgetException(msg("error.data_budget.name_invalid")); + } + if (command.maxRows() == null && command.maxBytes() == null) { + throw new IllegalDataBudgetException(msg("error.data_budget.limit_required")); + } + if ((command.maxRows() != null && command.maxRows() < 1) + || (command.maxBytes() != null && command.maxBytes() < 1)) { + throw new IllegalDataBudgetException(msg("error.data_budget.limit_invalid")); + } + var window = command.windowMinutes() == null ? DEFAULT_WINDOW_MINUTES : command.windowMinutes(); + if (window < MIN_WINDOW_MINUTES || window > MAX_WINDOW_MINUTES) { + throw new IllegalDataBudgetException(msg("error.data_budget.window_invalid")); + } + var threshold = command.warnThresholdPercent(); + if (threshold != null && (threshold < 1 || threshold > 99)) { + throw new IllegalDataBudgetException(msg("error.data_budget.threshold_invalid")); + } + var roles = normalizeRoles(organizationId, command.appliesToRoles()); + validateAppliesToTargets(organizationId, command.appliesToUserIds(), + command.appliesToGroupIds()); + entity.setName(name); + entity.setMaxRows(command.maxRows()); + entity.setMaxBytes(command.maxBytes()); + entity.setWindowMinutes(window); + entity.setBreachAction(command.breachAction() == null + ? DataBudgetBreachAction.REQUIRE_REVIEW : command.breachAction()); + entity.setWarnThresholdPercent(threshold == null ? null : threshold.shortValue()); + entity.setAppliesToRoles(roles.isEmpty() ? null : roles.toArray(new String[0])); + entity.setAppliesToGroupIds(toUuidArray(command.appliesToGroupIds())); + entity.setAppliesToUserIds(toUuidArray(command.appliesToUserIds())); + entity.setEnabled(command.enabled() == null || command.enabled()); + } + + private DataBudgetEntity loadInScope(UUID budgetId, UUID datasourceId, UUID organizationId) { + requireDatasourceInOrganization(datasourceId, organizationId); + var entity = dataBudgetRepository.findByIdAndOrganizationId(budgetId, organizationId) + .orElseThrow(() -> new DataBudgetNotFoundException(budgetId)); + if (!entity.getDatasourceId().equals(datasourceId)) { + throw new DataBudgetNotFoundException(budgetId); + } + return entity; + } + + private void requireDatasourceInOrganization(UUID datasourceId, UUID organizationId) { + var datasource = datasourceRepository.findById(datasourceId) + .orElseThrow(() -> new DatasourceNotFoundException(datasourceId)); + if (!datasource.getOrganization().getId().equals(organizationId)) { + throw new DatasourceNotFoundException(datasourceId); + } + } + + private List normalizeRoles(UUID organizationId, List roles) { + if (roles == null || roles.isEmpty()) { + return List.of(); + } + var normalized = new ArrayList(roles.size()); + for (var role : roles) { + if (role == null || role.isBlank()) { + continue; + } + var resolved = roleRepository.findByNameInScope(organizationId, role.trim()) + .orElseThrow(() -> new IllegalDataBudgetException( + msg("error.data_budget.unknown_role", role))); + normalized.add(resolved.getName()); + } + return normalized; + } + + private void validateAppliesToTargets(UUID organizationId, List userIds, + List groupIds) { + if (userIds != null && !userIds.isEmpty()) { + var found = userRepository.findAllByOrganization_IdAndIdIn(organizationId, userIds); + if (found.size() != userIds.stream().distinct().count()) { + throw new IllegalDataBudgetException(msg("error.data_budget.user_not_in_org")); + } + } + if (groupIds != null && !groupIds.isEmpty()) { + var found = userGroupRepository.findAllByOrganization_IdAndIdIn(organizationId, + new ArrayList<>(groupIds)); + if (found.size() != groupIds.stream().distinct().count()) { + throw new IllegalDataBudgetException(msg("error.data_budget.group_not_in_org")); + } + } + } + + static DataBudgetView toView(DataBudgetEntity entity) { + return new DataBudgetView( + entity.getId(), + entity.getDatasourceId(), + entity.getName(), + entity.getMaxRows(), + entity.getMaxBytes(), + entity.getWindowMinutes(), + entity.getBreachAction(), + entity.getWarnThresholdPercent() == null + ? null : entity.getWarnThresholdPercent().intValue(), + entity.getAppliesToRoles() == null ? List.of() : List.of(entity.getAppliesToRoles()), + entity.getAppliesToGroupIds() == null ? List.of() : List.of(entity.getAppliesToGroupIds()), + entity.getAppliesToUserIds() == null ? List.of() : List.of(entity.getAppliesToUserIds()), + entity.isEnabled(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + private static UUID[] toUuidArray(List values) { + return values == null || values.isEmpty() + ? null : values.stream().distinct().toArray(UUID[]::new); + } + + private String msg(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusService.java new file mode 100644 index 000000000..0a1baa99d --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusService.java @@ -0,0 +1,123 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupMembershipRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +class DefaultDataBudgetStatusService implements DataBudgetStatusService { + + private final DataBudgetRepository dataBudgetRepository; + private final DataBudgetUsageRepository usageRepository; + private final DatasourceRepository datasourceRepository; + private final UserRepository userRepository; + private final UserGroupMembershipRepository membershipRepository; + private final Clock clock; + + @Override + @Transactional(readOnly = true) + public DataBudgetStatus statusFor(UUID datasourceId, UUID userId) { + var budgets = dataBudgetRepository.findAllByDatasourceIdAndEnabledTrue(datasourceId); + if (budgets.isEmpty()) { + return DataBudgetStatus.none(datasourceId); + } + var scope = scopeOf(userId); + var applying = budgets.stream().filter(b -> scope.matches(b, userId)).toList(); + if (applying.isEmpty()) { + return DataBudgetStatus.none(datasourceId); + } + var name = datasourceRepository.findById(datasourceId).map(DatasourceEntity::getName) + .orElse(null); + return evaluate(datasourceId, name, userId, applying); + } + + @Override + @Transactional(readOnly = true) + public List statusesForUser(UUID organizationId, UUID userId) { + var budgets = dataBudgetRepository.findAllByOrganizationIdAndEnabledTrue(organizationId); + if (budgets.isEmpty()) { + return List.of(); + } + var scope = scopeOf(userId); + var byDatasource = new LinkedHashMap>(); + budgets.stream() + .filter(b -> scope.matches(b, userId)) + .sorted(Comparator.comparing(DataBudgetEntity::getCreatedAt)) + .forEach(b -> byDatasource.computeIfAbsent(b.getDatasourceId(), k -> new ArrayList<>()) + .add(b)); + var names = new HashMap(); + datasourceRepository.findAllById(byDatasource.keySet()) + .forEach(ds -> names.put(ds.getId(), ds.getName())); + var statuses = new ArrayList(byDatasource.size()); + byDatasource.forEach((dsId, applying) -> + statuses.add(evaluate(dsId, names.get(dsId), userId, applying))); + statuses.sort(Comparator.comparing(s -> s.datasourceName() == null ? "" : s.datasourceName(), + String.CASE_INSENSITIVE_ORDER)); + return statuses; + } + + private DataBudgetStatus evaluate(UUID datasourceId, String datasourceName, UUID userId, + List applying) { + var now = clock.instant(); + // Budgets sharing a window share one ledger sum. + Map totalsByWindow = new HashMap<>(); + var consumptions = new ArrayList(applying.size()); + for (var budget : applying) { + var totals = totalsByWindow.computeIfAbsent(budget.getWindowMinutes(), + window -> usageRepository.sumSince(userId, datasourceId, + now.minus(Duration.ofMinutes(window)))); + consumptions.add(new DataBudgetConsumption( + budget.getId(), + budget.getName(), + budget.getMaxRows(), + budget.getMaxBytes(), + budget.getWindowMinutes(), + budget.getBreachAction(), + budget.getWarnThresholdPercent() == null + ? null : budget.getWarnThresholdPercent().intValue(), + valueOf(totals == null ? null : totals.getRowsRead()), + valueOf(totals == null ? null : totals.getBytesRead()))); + } + return new DataBudgetStatus(datasourceId, datasourceName, consumptions); + } + + private Scope scopeOf(UUID userId) { + var roleName = userRepository.findById(userId).map(u -> u.roleName()).orElse(null); + return new Scope(roleName, new HashSet<>(membershipRepository.findGroupIdsForUser(userId))); + } + + private static long valueOf(Long value) { + return value == null ? 0L : value; + } + + private record Scope(String roleName, Set groupIds) { + + boolean matches(DataBudgetEntity budget, UUID userId) { + return AppliesToMatcher.matches(budget.getAppliesToRoles(), budget.getAppliesToGroupIds(), + budget.getAppliesToUserIds(), userId, roleName, groupIds); + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageService.java new file mode 100644 index 000000000..fb7b33964 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageService.java @@ -0,0 +1,106 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageRecord; +import com.bablsoft.accessflow.core.api.DataBudgetUsageService; +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetUsageEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +class DefaultDataBudgetUsageService implements DataBudgetUsageService { + + private final DataBudgetStatusService statusService; + private final DataBudgetRepository dataBudgetRepository; + private final DataBudgetUsageRepository usageRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(DataBudgetUsageRecord usage) { + // Without the lock two concurrent charges read the same `before`: a crossing that only + // their sum makes would never notify, and one both make would notify twice. + usageRepository.lockUserDatasource(lockKey(usage.userId(), usage.datasourceId())); + var before = statusService.statusFor(usage.datasourceId(), usage.userId()); + if (before.isEmpty()) { + return; + } + var organizationId = dataBudgetRepository.findById(before.budgets().getFirst().budgetId()) + .map(b -> b.getOrganizationId()) + .orElse(null); + if (organizationId == null) { + return; + } + var entry = new DataBudgetUsageEntity(); + entry.setId(UUID.randomUUID()); + entry.setOrganizationId(organizationId); + entry.setUserId(usage.userId()); + entry.setDatasourceId(usage.datasourceId()); + entry.setRowsRead(usage.rowsRead()); + entry.setBytesRead(usage.bytesRead()); + entry.setSource(usage.source()); + entry.setQueryRequestId(usage.queryRequestId()); + entry.setRequestGroupId(usage.requestGroupId()); + entry.setOccurredAt(clock.instant()); + usageRepository.save(entry); + for (var budget : before.budgets()) { + var after = budget.plus(usage.rowsRead(), usage.bytesRead()); + crossing(budget, after).ifPresent(exhausted -> eventPublisher.publishEvent( + toEvent(organizationId, usage, after, exhausted))); + } + } + + static long lockKey(UUID userId, UUID datasourceId) { + return userId.getMostSignificantBits() ^ Long.rotateLeft(userId.getLeastSignificantBits(), 17) + ^ Long.rotateLeft(datasourceId.getMostSignificantBits(), 31) + ^ datasourceId.getLeastSignificantBits(); + } + + /** Empty when no mark was crossed; true for the limit, false for the warn threshold. */ + static Optional crossing(DataBudgetConsumption before, + DataBudgetConsumption after) { + if (!before.exhausted() && after.exhausted()) { + return Optional.of(true); + } + var threshold = before.warnThresholdPercent(); + if (threshold != null && !after.exhausted() + && before.usedPercent() < threshold && after.usedPercent() >= threshold) { + return Optional.of(false); + } + return Optional.empty(); + } + + private static DataBudgetThresholdCrossedEvent toEvent(UUID organizationId, + DataBudgetUsageRecord usage, + DataBudgetConsumption after, + boolean exhausted) { + return new DataBudgetThresholdCrossedEvent( + organizationId, + usage.userId(), + usage.datasourceId(), + after.budgetId(), + after.name(), + exhausted, + after.warnThresholdPercent(), + (int) Math.min(100, Math.floor(after.usedPercent())), + after.maxRows(), + after.maxBytes(), + after.usedRows(), + after.usedBytes(), + after.windowMinutes(), + after.breachAction()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateService.java index 30788db44..7245d53a9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateService.java @@ -79,6 +79,22 @@ public void recordBytesScannedCap(UUID queryRequestId, long limit, queryRequestRepository.save(entity); } + @Override + @Transactional + public void recordDataBudgetReviewForced(UUID queryRequestId) { + var entity = lockOrThrow(queryRequestId); + entity.setDataBudgetReviewForced(true); + queryRequestRepository.save(entity); + } + + @Override + @Transactional(readOnly = true) + public boolean isDataBudgetReviewForced(UUID queryRequestId) { + return queryRequestRepository.findById(queryRequestId) + .map(QueryRequestEntity::isDataBudgetReviewForced) + .orElse(false); + } + @Override @Transactional public RecordDecisionResult recordApprovalAndAdvance(RecordApprovalCommand command) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultRowLimitPolicyResolutionService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultRowLimitPolicyResolutionService.java index 708c85ddd..10b6c4a8c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultRowLimitPolicyResolutionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultRowLimitPolicyResolutionService.java @@ -77,36 +77,7 @@ static boolean matches(RowLimitPolicyEntity policy, String rawReference) { private static boolean appliesTo(RowLimitPolicyEntity policy, UUID userId, String roleName, Set groupIds) { - var roles = policy.getAppliesToRoles(); - var groups = policy.getAppliesToGroupIds(); - var users = policy.getAppliesToUserIds(); - boolean hasRoles = roles != null && roles.length > 0; - boolean hasGroups = groups != null && groups.length > 0; - boolean hasUsers = users != null && users.length > 0; - if (!hasRoles && !hasGroups && !hasUsers) { - return true; // empty scope = applies to every submitter - } - if (hasRoles && roleName != null) { - for (var allowed : roles) { - if (allowed != null && roleName.equalsIgnoreCase(allowed.trim())) { - return true; - } - } - } - if (hasUsers) { - for (var allowed : users) { - if (userId.equals(allowed)) { - return true; - } - } - } - if (hasGroups) { - for (var allowed : groups) { - if (groupIds.contains(allowed)) { - return true; - } - } - } - return false; + return AppliesToMatcher.matches(policy.getAppliesToRoles(), policy.getAppliesToGroupIds(), + policy.getAppliesToUserIds(), userId, roleName, groupIds); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/CorePropertiesConfiguration.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/CorePropertiesConfiguration.java index e32a9e9e6..3bf8ddac4 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/CorePropertiesConfiguration.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/CorePropertiesConfiguration.java @@ -5,6 +5,7 @@ @Configuration @EnableConfigurationProperties({EncryptionProperties.class, SecretsProperties.class, - ReviewDelegationProperties.class, PolicySimulationProperties.class}) + ReviewDelegationProperties.class, PolicySimulationProperties.class, + DataBudgetProperties.class}) class CorePropertiesConfiguration { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/DataBudgetProperties.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/DataBudgetProperties.java new file mode 100644 index 000000000..ecf56c44f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/config/DataBudgetProperties.java @@ -0,0 +1,27 @@ +package com.bablsoft.accessflow.core.internal.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * Tuning for the data-budget usage ledger (#942). + * + * @param usageRetention how long ledger rows are kept. Must outlive the longest budget window + * (31 days), or a budget would under-count — shorter values are raised to + * the floor. + */ +@ConfigurationProperties("accessflow.core.data-budget") +public record DataBudgetProperties(Duration usageRetention) { + + public static final Duration MIN_RETENTION = Duration.ofDays(31).plusHours(1); + public static final Duration DEFAULT_RETENTION = Duration.ofDays(32); + + public DataBudgetProperties { + if (usageRetention == null) { + usageRetention = DEFAULT_RETENTION; + } else if (usageRetention.compareTo(MIN_RETENTION) < 0) { + usageRetention = MIN_RETENTION; + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetEntity.java new file mode 100644 index 000000000..974cbdc8b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetEntity.java @@ -0,0 +1,91 @@ +package com.bablsoft.accessflow.core.internal.persistence.entity; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcType; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.dialect.type.PostgreSQLEnumJdbcType; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "data_budgets") +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor +public class DataBudgetEntity { + + @Id + private UUID id; + + @Column(name = "organization_id", nullable = false) + private UUID organizationId; + + @Column(name = "datasource_id", nullable = false) + private UUID datasourceId; + + @Column(nullable = false, length = 120) + private String name; + + @Column(name = "max_rows") + private Long maxRows; + + @Column(name = "max_bytes") + private Long maxBytes; + + @Column(name = "window_minutes", nullable = false) + private int windowMinutes; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(name = "breach_action", nullable = false, columnDefinition = "data_budget_breach_action") + private DataBudgetBreachAction breachAction; + + @Column(name = "warn_threshold_percent") + private Short warnThresholdPercent; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "applies_to_roles", columnDefinition = "text[]") + private String[] appliesToRoles; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "applies_to_group_ids", columnDefinition = "uuid[]") + private UUID[] appliesToGroupIds; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "applies_to_user_ids", columnDefinition = "uuid[]") + private UUID[] appliesToUserIds; + + @Column(nullable = false) + private boolean enabled = true; + + @Version + @Column(nullable = false) + private long version; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetUsageEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetUsageEntity.java new file mode 100644 index 000000000..be296160f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DataBudgetUsageEntity.java @@ -0,0 +1,61 @@ +package com.bablsoft.accessflow.core.internal.persistence.entity; + +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.type.PostgreSQLEnumJdbcType; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "data_budget_usage") +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor +public class DataBudgetUsageEntity { + + @Id + private UUID id; + + @Column(name = "organization_id", nullable = false, updatable = false) + private UUID organizationId; + + @Column(name = "user_id", nullable = false, updatable = false) + private UUID userId; + + @Column(name = "datasource_id", nullable = false, updatable = false) + private UUID datasourceId; + + @Column(name = "rows_read", nullable = false, updatable = false) + private long rowsRead; + + @Column(name = "bytes_read", nullable = false, updatable = false) + private long bytesRead; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(name = "source", nullable = false, updatable = false, + columnDefinition = "data_budget_usage_source") + private DataBudgetUsageSource source; + + @Column(name = "query_request_id", updatable = false) + private UUID queryRequestId; + + @Column(name = "request_group_id", updatable = false) + private UUID requestGroupId; + + @Column(name = "occurred_at", nullable = false, updatable = false) + private Instant occurredAt; +} 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 73d3c690e..99923e851 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 @@ -169,6 +169,11 @@ public class QueryRequestEntity { @Column(name = "bytes_scanned_cap_outcome", columnDefinition = "bytes_scanned_cap_outcome") private BytesScannedCapOutcome bytesScannedCapOutcome; + // #942: an exhausted REQUIRE_REVIEW data budget forced this query into review; only then may + // it run past the exhausted budget once approved. + @Column(name = "data_budget_review_forced", nullable = false) + private boolean dataBudgetReviewForced; + @Version @Column(name = "updated_at", nullable = false) private Instant updatedAt = Instant.now(); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetRepository.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetRepository.java new file mode 100644 index 000000000..64711190c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetRepository.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.core.internal.persistence.repo; + +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface DataBudgetRepository extends JpaRepository { + + List findAllByOrganizationIdAndDatasourceIdOrderByCreatedAtAsc( + UUID organizationId, UUID datasourceId); + + List findAllByDatasourceIdAndEnabledTrue(UUID datasourceId); + + List findAllByOrganizationIdAndEnabledTrue(UUID organizationId); + + Optional findByIdAndOrganizationId(UUID id, UUID organizationId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetUsageRepository.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetUsageRepository.java new file mode 100644 index 000000000..4ef2f7831 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/DataBudgetUsageRepository.java @@ -0,0 +1,43 @@ +package com.bablsoft.accessflow.core.internal.persistence.repo; + +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetUsageEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.UUID; + +public interface DataBudgetUsageRepository extends JpaRepository { + + /** Trailing-window totals for one user on one datasource; zeros when nothing was read. */ + @Query(""" + select coalesce(sum(u.rowsRead), 0) as rowsRead, + coalesce(sum(u.bytesRead), 0) as bytesRead + from DataBudgetUsageEntity u + where u.userId = :userId + and u.datasourceId = :datasourceId + and u.occurredAt >= :since + """) + UsageTotals sumSince(@Param("userId") UUID userId, + @Param("datasourceId") UUID datasourceId, + @Param("since") Instant since); + + /** + * Serializes charges for one (user, datasource) until the transaction ends, so the before-read + * and the insert of one charge are never interleaved with another's. + */ + @Query(value = "SELECT 1 FROM (SELECT pg_advisory_xact_lock(:key)) AS l", nativeQuery = true) + Integer lockUserDatasource(@Param("key") long key); + + @Modifying + @Query("delete from DataBudgetUsageEntity u where u.occurredAt < :cutoff") + int deleteOlderThan(@Param("cutoff") Instant cutoff); + + interface UsageTotals { + Long getRowsRead(); + + Long getBytesRead(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJob.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJob.java new file mode 100644 index 000000000..0489bcb57 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJob.java @@ -0,0 +1,39 @@ +package com.bablsoft.accessflow.core.internal.scheduled; + +import com.bablsoft.accessflow.core.internal.config.DataBudgetProperties; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; + +/** + * Deletes data-budget ledger rows (#942) older than {@code accessflow.core.data-budget + * .usage-retention}. Rows past the longest budget window can no longer count toward any budget. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DataBudgetUsagePruneJob { + + private final DataBudgetUsageRepository usageRepository; + private final DataBudgetProperties properties; + private final Clock clock; + + @Scheduled(fixedDelayString = "${accessflow.core.data-budget.prune-interval:PT1H}") + @SchedulerLock(name = "dataBudgetUsagePruneJob", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M") + @Transactional + public void run() { + var cutoff = clock.instant().minus(properties.usageRetention()); + var deleted = usageRepository.deleteOlderThan(cutoff); + if (deleted > 0) { + log.info("Pruned {} data-budget usage rows older than {}", deleted, cutoff); + } else { + log.debug("No data-budget usage rows older than {}", cutoff); + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/api/NotificationEventType.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/api/NotificationEventType.java index 78032cd22..9b4afe183 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/api/NotificationEventType.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/api/NotificationEventType.java @@ -82,5 +82,17 @@ public enum NotificationEventType { */ SCHEMA_DRIFT_DETECTED, + /** + * A user's reads on a datasource crossed their data budget's warning threshold (#942). + * Advisory — notifies that user only; never pages, never opens a ticket. + */ + DATA_BUDGET_THRESHOLD_REACHED, + /** + * A user used up a data budget on a datasource (#942); further SELECTs are rejected or sent + * to review per the budget's breach action. Notifies the user and every + * {@code DATA_BUDGET_MANAGE} holder; never pages, never opens a ticket. + */ + DATA_BUDGET_EXHAUSTED, + TEST } diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotice.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotice.java new file mode 100644 index 000000000..9d081ea30 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotice.java @@ -0,0 +1,65 @@ +package com.bablsoft.accessflow.notifications.internal; + +import com.bablsoft.accessflow.core.api.ByteSizeFormat; +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; + +import java.util.Locale; +import java.util.UUID; + +/** + * The budget-specific part of a {@code DATA_BUDGET_*} notification (#942). A limit that is not set + * on the budget ({@code maxRows} / {@code maxBytes} null) renders no usage line at all. + */ +public record DataBudgetNotice( + UUID budgetId, + String budgetName, + boolean exhausted, + Integer warnThresholdPercent, + int usedPercent, + Long maxRows, + Long maxBytes, + long usedRows, + long usedBytes, + int windowMinutes, + DataBudgetBreachAction breachAction) { + + private static final int MINUTES_PER_HOUR = 60; + private static final int MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR; + + /** {@code "1200 / 5000 rows"}, or null when the budget caps no rows. */ + public String rowsUsage() { + return maxRows == null ? null : usedRows + " / " + maxRows + " rows"; + } + + /** {@code "1.2 GB (…) / 5 GB (…)"}, or null when the budget caps no bytes. */ + public String bytesUsage() { + return maxBytes == null ? null + : ByteSizeFormat.format(usedBytes) + " / " + ByteSizeFormat.format(maxBytes); + } + + /** The largest whole unit the window divides into: {@code DAYS}, {@code HOURS} or {@code MINUTES}. */ + public String windowUnit() { + if (windowMinutes > 0 && windowMinutes % MINUTES_PER_DAY == 0) { + return "DAYS"; + } + if (windowMinutes > 0 && windowMinutes % MINUTES_PER_HOUR == 0) { + return "HOURS"; + } + return "MINUTES"; + } + + public int windowValue() { + return switch (windowUnit()) { + case "DAYS" -> windowMinutes / MINUTES_PER_DAY; + case "HOURS" -> windowMinutes / MINUTES_PER_HOUR; + default -> windowMinutes; + }; + } + + /** English window label for the chat channels, e.g. {@code "7 days"} or {@code "1 hour"}. */ + public String windowLabel() { + var value = windowValue(); + var unit = windowUnit().toLowerCase(Locale.ROOT); + return value + " " + (value == 1 ? unit.substring(0, unit.length() - 1) : unit); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListener.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListener.java new file mode 100644 index 000000000..c054ccc14 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListener.java @@ -0,0 +1,30 @@ +package com.bablsoft.accessflow.notifications.internal; + +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +/** + * Fans out data-budget crossings (#942). The usage service publishes inside its own + * {@code REQUIRES_NEW} transaction, so the after-commit module listener runs once the usage row is + * durable. Delivery is best-effort and never affects the read that crossed the mark. + */ +@Component +@RequiredArgsConstructor +@Slf4j +class DataBudgetNotificationListener { + + private final NotificationDispatcher dispatcher; + + @ApplicationModuleListener + void onThresholdCrossed(DataBudgetThresholdCrossedEvent event) { + try { + dispatcher.dispatchDataBudget(event); + } catch (RuntimeException ex) { + log.error("Failed to dispatch data-budget notification for budget {} and user {}", + event.budgetId(), event.userId(), ex); + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContext.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContext.java index ff245e81e..af64f95e8 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContext.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContext.java @@ -59,6 +59,9 @@ * promoter. {@code schemaChangeStatus} is the promotion status name, so a * {@code PARTIALLY_APPLIED} run can be told apart from a {@code FAILED} one, and * {@code schemaChangeErrorMessage} is the first failed statement's error. + * + *

{@code dataBudget} is only populated for the {@code DATA_BUDGET_*} events (#942) — see + * {@link #isDataBudgetEvent()}. */ public record NotificationContext( NotificationEventType eventType, @@ -114,7 +117,89 @@ public record NotificationContext( String schemaChangeSetName, String schemaChangeStatus, String schemaChangeErrorMessage, - Integer driftNewFindingCount) { + Integer driftNewFindingCount, + DataBudgetNotice dataBudget) { + + /** + * True for the #942 data-budget events. They reuse {@code datasourceId}/{@code datasourceName} + * for the budgeted datasource and the {@code submitter*} fields for the user whose budget it + * is; everything budget-specific rides in {@code dataBudget}. + */ + public boolean isDataBudgetEvent() { + return eventType == NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED + || eventType == NotificationEventType.DATA_BUDGET_EXHAUSTED; + } + + /** Compatibility constructor without the #942 data-budget notice — every other path. */ + public NotificationContext( + NotificationEventType eventType, + UUID organizationId, + UUID queryRequestId, + QueryType queryType, + String fullSqlText, + String sqlPreview200, + String sqlPreview300, + RiskLevel riskLevel, + Integer riskScore, + String aiSummary, + UUID datasourceId, + String datasourceName, + UUID submittedByUserId, + String submitterEmail, + String submitterDisplayName, + String justification, + UUID reviewerUserId, + String reviewerDisplayName, + String reviewerComment, + URI reviewUrl, + List recipients, + Instant occurredAt, + String locale, + Integer approvalTimeoutHours, + UUID anomalyId, + String anomalyFeature, + Double anomalyScore, + Double anomalyObservedValue, + Double anomalyBaselineMean, + String anomalyUserLabel, + WeeklyDigestData digest, + UUID attestationCampaignId, + String attestationCampaignName, + Instant attestationDueAt, + UUID apiRequestId, + QueryStatus executionStatus, + Long executionRowsAffected, + Long executionDurationMs, + GrantResourceKind grantResourceKind, + Long grantDaysSinceLastUse, + GrantUsageRecommendation grantRecommendation, + String exportFormat, + String exportClassifications, + String exportTrigger, + UUID deploymentRequestId, + String environmentName, + String deploymentVersion, + DeploymentOutcome deploymentOutcome, + String deploymentDecisionReason, + UUID schemaChangePromotionId, + String schemaChangeSetName, + String schemaChangeStatus, + String schemaChangeErrorMessage, + Integer driftNewFindingCount) { + this(eventType, organizationId, queryRequestId, queryType, fullSqlText, sqlPreview200, + sqlPreview300, riskLevel, riskScore, aiSummary, datasourceId, datasourceName, + submittedByUserId, submitterEmail, submitterDisplayName, justification, + reviewerUserId, reviewerDisplayName, reviewerComment, reviewUrl, recipients, + occurredAt, locale, approvalTimeoutHours, anomalyId, anomalyFeature, anomalyScore, + anomalyObservedValue, anomalyBaselineMean, anomalyUserLabel, digest, + attestationCampaignId, attestationCampaignName, attestationDueAt, apiRequestId, + executionStatus, executionRowsAffected, executionDurationMs, grantResourceKind, + grantDaysSinceLastUse, grantRecommendation, exportFormat, exportClassifications, + exportTrigger, deploymentRequestId, environmentName, deploymentVersion, + deploymentOutcome, deploymentDecisionReason, schemaChangePromotionId, + schemaChangeSetName, schemaChangeStatus, schemaChangeErrorMessage, + driftNewFindingCount, null); + } /** * True for the #882 schema-change events, which render their own field set — "Datasource" diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilder.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilder.java index 0361db866..cdb24f718 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilder.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilder.java @@ -8,9 +8,11 @@ import com.bablsoft.accessflow.apigov.api.ApiRequestNotificationView; import com.bablsoft.accessflow.attestation.api.AttestationCampaignLookupService; import com.bablsoft.accessflow.compliance.events.SensitiveResultExportedEvent; +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; import com.bablsoft.accessflow.core.api.AiAnalysisLookupService; import com.bablsoft.accessflow.core.api.ApproverRule; import com.bablsoft.accessflow.core.api.DatasourceAdminService; +import com.bablsoft.accessflow.core.api.DatasourceNotFoundException; import com.bablsoft.accessflow.core.api.LocalizationConfigService; import com.bablsoft.accessflow.core.api.QueryRequestLookupService; import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; @@ -39,6 +41,7 @@ import java.net.URI; import java.time.Instant; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Optional; @@ -191,7 +194,8 @@ private List resolveRecipients(NotificationEventType eventType, DEPLOYMENT_SUBMITTED, DEPLOYMENT_APPROVED, DEPLOYMENT_REJECTED, DEPLOYMENT_OUTCOME_FAILED, DEPLOYMENT_BREAK_GLASS_EXECUTED, SCHEMA_CHANGE_PROMOTION_SUBMITTED, SCHEMA_CHANGE_PROMOTION_APPLIED, - SCHEMA_CHANGE_PROMOTION_FAILED, SCHEMA_DRIFT_DETECTED -> List.of(); + SCHEMA_CHANGE_PROMOTION_FAILED, SCHEMA_DRIFT_DETECTED, + DATA_BUDGET_THRESHOLD_REACHED, DATA_BUDGET_EXHAUSTED -> List.of(); }; } @@ -673,6 +677,77 @@ Optional buildSchemaDrift(SchemaDriftDetectedEvent event) { event.newFindingCount())); } + /** + * Builds the context for a data-budget crossing (#942): the datasource in + * {@code datasourceId}/{@code datasourceName}, the budget's user in the {@code submitter*} + * fields and the usage in {@code dataBudget}. A warning reaches that user only; exhaustion also + * reaches every active {@code DATA_BUDGET_MANAGE} holder, deduplicated when the user is one. + * {@code reviewUrl} opens the query editor. Empty when the user is gone or inactive and nobody + * else is to be told. + */ + Optional buildDataBudget(DataBudgetThresholdCrossedEvent event) { + var eventType = event.exhausted() + ? NotificationEventType.DATA_BUDGET_EXHAUSTED + : NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED; + var ids = new LinkedHashSet(); + ids.add(event.userId()); + if (event.exhausted()) { + ids.addAll(rolePermissionHolderLookupService + .findUserIdsWithPermission(event.organizationId(), Permission.DATA_BUDGET_MANAGE)); + } + var recipients = toActiveRecipients(List.copyOf(ids)); + if (recipients.isEmpty()) { + return Optional.empty(); + } + var user = userQueryService.findById(event.userId()).orElse(null); + var locale = localizationConfigService.getOrDefault(event.organizationId()).defaultLanguage(); + var notice = new DataBudgetNotice(event.budgetId(), event.budgetName(), event.exhausted(), + event.warnThresholdPercent(), event.usedPercent(), event.maxRows(), event.maxBytes(), + event.usedRows(), event.usedBytes(), event.windowMinutes(), event.breachAction()); + return Optional.of(new NotificationContext( + eventType, + event.organizationId(), + null, + null, null, null, null, + null, null, null, + event.datasourceId(), + datasourceName(event.datasourceId(), event.organizationId()), + event.userId(), + user != null ? user.email() : null, + user != null ? user.displayName() : null, + null, + null, null, null, + buildAppUrl("/editor"), + recipients, + Instant.now(), + locale, + null, + null, null, null, null, null, null, + null, + null, null, null, + null, + null, null, null, + null, null, null, + null, null, null, + null, null, null, null, null, + null, null, null, null, + null, + notice)); + } + + private String datasourceName(UUID datasourceId, UUID organizationId) { + if (datasourceId == null) { + return null; + } + try { + var datasource = datasourceAdminService.getForAdmin(datasourceId, organizationId); + return datasource != null ? datasource.name() : null; + } catch (DatasourceNotFoundException ex) { + log.debug("Datasource {} vanished before its data-budget notification", datasourceId); + return null; + } + } + private List schemaChangeRecipients(NotificationEventType eventType, SchemaChangePromotionNotificationView view) { if (eventType == NotificationEventType.SCHEMA_CHANGE_PROMOTION_SUBMITTED) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcher.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcher.java index 7a3e8046c..665cdf7bb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcher.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcher.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.notifications.api.NotificationChannelType; import com.bablsoft.accessflow.access.events.GrantStaleEvent; import com.bablsoft.accessflow.compliance.events.SensitiveResultExportedEvent; +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; import com.bablsoft.accessflow.deploygov.api.DeploymentOutcome; import com.bablsoft.accessflow.notifications.api.NotificationEventType; import com.bablsoft.accessflow.schemachange.events.SchemaDriftDetectedEvent; @@ -216,6 +217,18 @@ void dispatchSchemaDrift(SchemaDriftDetectedEvent event) { deliver(NotificationEventType.SCHEMA_DRIFT_DETECTED, contextOpt.get()); } + /** Dispatch a {@code DATA_BUDGET_THRESHOLD_REACHED} / {@code DATA_BUDGET_EXHAUSTED} + * notification (#942) over all active org channels. */ + void dispatchDataBudget(DataBudgetThresholdCrossedEvent event) { + var contextOpt = contextBuilder.buildDataBudget(event); + if (contextOpt.isEmpty()) { + log.debug("Skipping data-budget notification for budget {} — no active recipient", + event.budgetId()); + return; + } + deliver(contextOpt.get().eventType(), contextOpt.get()); + } + private void deliver(NotificationEventType eventType, NotificationContext ctx) { recordInAppNotifications(ctx); var channels = resolveChannels(eventType, ctx); @@ -326,6 +339,15 @@ private String buildPayload(NotificationContext ctx) { if (ctx.datasourceName() != null) { payload.put("datasource", ctx.datasourceName()); } + if (ctx.dataBudget() != null) { + var budget = ctx.dataBudget(); + // #942: user_notifications has no datasource column — the bell reads it from here. + if (ctx.datasourceId() != null) { + payload.put("datasource_id", ctx.datasourceId().toString()); + } + payload.put("budget", budget.budgetName()); + payload.put("used_percent", budget.usedPercent()); + } if (ctx.submitterEmail() != null) { payload.put("submitter", ctx.submitterEmail()); } @@ -379,7 +401,10 @@ private List resolveChannels(NotificationEventType ev || eventType == NotificationEventType.SCHEMA_CHANGE_PROMOTION_SUBMITTED || eventType == NotificationEventType.SCHEMA_CHANGE_PROMOTION_APPLIED || eventType == NotificationEventType.SCHEMA_CHANGE_PROMOTION_FAILED - || eventType == NotificationEventType.SCHEMA_DRIFT_DETECTED) { + || eventType == NotificationEventType.SCHEMA_DRIFT_DETECTED + // #942: budgets are per user, not per review plan — advisory, org-wide. + || eventType == NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED + || eventType == NotificationEventType.DATA_BUDGET_EXHAUSTED) { return channelRepository.findAllByOrganizationIdAndActiveTrue(ctx.organizationId()); } var planChannels = lookupPlanChannelIds(ctx); diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/codec/PagerDutyTrigger.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/codec/PagerDutyTrigger.java index 7fc8a3a53..8349dadaf 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/codec/PagerDutyTrigger.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/codec/PagerDutyTrigger.java @@ -27,7 +27,9 @@ * deliberately have no trigger either — routine lifecycle progress is not an incident. Nor do the * schema-change events ({@code SCHEMA_CHANGE_PROMOTION_*}, {@code SCHEMA_DRIFT_DETECTED}, #882): * a promotion's lifecycle is not an incident, and a drift finding carries no severity that could - * tell a critical divergence apart from a cosmetic one. + * tell a critical divergence apart from a cosmetic one. Nor do the data-budget events + * ({@code DATA_BUDGET_THRESHOLD_REACHED}, {@code DATA_BUDGET_EXHAUSTED}, #942): a user reaching a + * read quota is an advisory, not an incident. */ public enum PagerDutyTrigger { CRITICAL_RISK(NotificationEventType.AI_HIGH_RISK), diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetText.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetText.java new file mode 100644 index 000000000..6267aff92 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetText.java @@ -0,0 +1,52 @@ +package com.bablsoft.accessflow.notifications.internal.strategy; + +import com.bablsoft.accessflow.notifications.internal.NotificationContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Shared field set for the #942 data-budget events, so Slack, Discord, Teams and Telegram show the + * same lines in the same order. Usage lines appear only for the limits the budget sets; the breach + * action only once the budget is exhausted, since that is when it takes effect. + */ +final class DataBudgetText { + + private DataBudgetText() { + } + + static List> fields(NotificationContext ctx) { + var budget = ctx.dataBudget(); + var fields = new ArrayList>(); + fields.add(Map.entry("Datasource", dash(ctx.datasourceName()))); + fields.add(Map.entry("User", dash(user(ctx)))); + if (budget == null) { + return fields; + } + fields.add(Map.entry("Budget", dash(budget.budgetName()))); + fields.add(Map.entry("Used", budget.usedPercent() + "%")); + if (budget.rowsUsage() != null) { + fields.add(Map.entry("Rows", budget.rowsUsage())); + } + if (budget.bytesUsage() != null) { + fields.add(Map.entry("Bytes", budget.bytesUsage())); + } + fields.add(Map.entry("Window", budget.windowLabel())); + if (budget.exhausted() && budget.breachAction() != null) { + fields.add(Map.entry("On breach", budget.breachAction().name())); + } + return fields; + } + + private static String user(NotificationContext ctx) { + if (ctx.submitterDisplayName() != null && ctx.submitterEmail() != null) { + return ctx.submitterDisplayName() + " (" + ctx.submitterEmail() + ")"; + } + return ctx.submitterEmail() != null ? ctx.submitterEmail() : ctx.submitterDisplayName(); + } + + private static String dash(String value) { + return value == null || value.isBlank() ? "—" : value; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DiscordPayloadFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DiscordPayloadFactory.java index d0d4af49f..cfee2bfe7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DiscordPayloadFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/DiscordPayloadFactory.java @@ -87,6 +87,18 @@ private static Map buildEventEmbed(NotificationContext ctx) { embed.put("fields", fields); return embed; } + // #942: data budgets — shared field set with the other chat channels. + if (ctx.isDataBudgetEvent()) { + for (var field : DataBudgetText.fields(ctx)) { + addField(fields, field.getKey(), field.getValue()); + } + if (ctx.reviewUrl() != null) { + addField(fields, "Open", ctx.reviewUrl().toString()); + embed.put("url", ctx.reviewUrl().toString()); + } + embed.put("fields", fields); + return embed; + } // #882: schema-change promotions and drift carry the pipeline in datasourceName too. if (ctx.isSchemaChangeEvent()) { if (ctx.schemaChangeSetName() != null) { @@ -213,6 +225,8 @@ private static String headerLabel(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "✅ Schema Change Applied"; case SCHEMA_CHANGE_PROMOTION_FAILED -> "🚨 Schema Change Failed"; case SCHEMA_DRIFT_DETECTED -> "⚠️ Schema Drift Detected"; + case DATA_BUDGET_THRESHOLD_REACHED -> "📊 Data Budget Warning Threshold Reached"; + case DATA_BUDGET_EXHAUSTED -> "🛑 Data Budget Exhausted"; }; } diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/EmailNotificationStrategy.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/EmailNotificationStrategy.java index 2c9ca6863..8567d433b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/EmailNotificationStrategy.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/EmailNotificationStrategy.java @@ -258,6 +258,18 @@ private String renderHtml(String template, NotificationContext ctx, boolean resu context.setVariable("schemaChangeStatus", ctx.schemaChangeStatus()); context.setVariable("schemaChangeErrorMessage", ctx.schemaChangeErrorMessage()); context.setVariable("driftNewFindingCount", ctx.driftNewFindingCount()); + var budget = ctx.dataBudget(); + context.setVariable("dataBudgetName", budget != null ? budget.budgetName() : null); + context.setVariable("dataBudgetUsedPercent", budget != null ? budget.usedPercent() : null); + context.setVariable("dataBudgetWarnThresholdPercent", + budget != null ? budget.warnThresholdPercent() : null); + context.setVariable("dataBudgetUsedRows", budget != null ? budget.usedRows() : null); + context.setVariable("dataBudgetMaxRows", budget != null ? budget.maxRows() : null); + context.setVariable("dataBudgetBytesUsage", budget != null ? budget.bytesUsage() : null); + context.setVariable("dataBudgetWindowUnit", budget != null ? budget.windowUnit() : null); + context.setVariable("dataBudgetWindowValue", budget != null ? budget.windowValue() : null); + context.setVariable("dataBudgetBreachAction", + budget != null && budget.breachAction() != null ? budget.breachAction().name() : null); return templateEngine.process(template, context); } @@ -294,6 +306,9 @@ private static String templateName(NotificationEventType eventType) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "email/schema-change-promotion-applied"; case SCHEMA_CHANGE_PROMOTION_FAILED -> "email/schema-change-promotion-failed"; case SCHEMA_DRIFT_DETECTED -> "email/schema-drift-detected"; + // #942: data budgets — the user, and on exhaustion the budget managers. + case DATA_BUDGET_THRESHOLD_REACHED -> "email/data-budget-threshold-reached"; + case DATA_BUDGET_EXHAUSTED -> "email/data-budget-exhausted"; // Access (JIT) events are delivered as in-app notifications by AccessNotificationListener, // not through the channel-strategy email path — no email template. // API-request events (AF-500) deliver as in-app + chat notifications, not email. @@ -332,6 +347,11 @@ private String subject(NotificationContext ctx) { // #882: pipeline, environment, and how many findings the scan opened. case SCHEMA_DRIFT_DETECTED -> new Object[]{ctx.datasourceName(), ctx.environmentName(), ctx.driftNewFindingCount()}; + // #942: "{0} on {1}" is the budget and the datasource; the warning adds the percent. + case DATA_BUDGET_THRESHOLD_REACHED, DATA_BUDGET_EXHAUSTED -> new Object[]{ + ctx.dataBudget() != null ? ctx.dataBudget().budgetName() : null, + ctx.datasourceName(), + ctx.dataBudget() != null ? ctx.dataBudget().usedPercent() : null}; default -> new Object[]{ctx.datasourceName()}; }; return messageSource.getMessage(key, args, resolveLocale(ctx)); @@ -372,6 +392,9 @@ private static String subjectKey(NotificationEventType eventType) { case SCHEMA_CHANGE_PROMOTION_FAILED -> "notification.email.subject.schema_change_promotion_failed"; case SCHEMA_DRIFT_DETECTED -> "notification.email.subject.schema_drift_detected"; + case DATA_BUDGET_THRESHOLD_REACHED -> + "notification.email.subject.data_budget_threshold_reached"; + case DATA_BUDGET_EXHAUSTED -> "notification.email.subject.data_budget_exhausted"; // Unreachable for access events (no email template); kept for switch exhaustiveness. case TEST, ACCESS_REQUEST_SUBMITTED, ACCESS_REQUEST_APPROVED, ACCESS_REQUEST_REJECTED, ACCESS_GRANT_EXPIRED, ACCESS_GRANT_REVOKED, API_REQUEST_SUBMITTED, API_REQUEST_APPROVED, diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/MsTeamsPayloadFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/MsTeamsPayloadFactory.java index 27510f720..573fe45af 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/MsTeamsPayloadFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/MsTeamsPayloadFactory.java @@ -86,6 +86,22 @@ private static Map buildEventCard(NotificationContext ctx) { } return card; } + // #942: data budgets — shared field set with the other chat channels. + if (ctx.isDataBudgetEvent()) { + for (var field : DataBudgetText.fields(ctx)) { + facts.add(fact(field.getKey(), field.getValue())); + } + body.add(factSet(facts)); + card.put("body", body); + if (ctx.reviewUrl() != null) { + var action = new LinkedHashMap(); + action.put("type", "Action.OpenUrl"); + action.put("title", "Open the query editor"); + action.put("url", ctx.reviewUrl().toString()); + card.put("actions", List.of(action)); + } + return card; + } // #882: schema-change promotions and drift carry the pipeline in datasourceName too. if (ctx.isSchemaChangeEvent()) { if (ctx.schemaChangeSetName() != null) { @@ -263,6 +279,8 @@ private static String headerLabel(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "✅ Schema Change Applied"; case SCHEMA_CHANGE_PROMOTION_FAILED -> "🚨 Schema Change Failed"; case SCHEMA_DRIFT_DETECTED -> "⚠️ Schema Drift Detected"; + case DATA_BUDGET_THRESHOLD_REACHED -> "📊 Data Budget Warning Threshold Reached"; + case DATA_BUDGET_EXHAUSTED -> "🛑 Data Budget Exhausted"; }; } diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/PagerDutyPayloadFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/PagerDutyPayloadFactory.java index 300a79b3e..bac061223 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/PagerDutyPayloadFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/PagerDutyPayloadFactory.java @@ -131,6 +131,12 @@ private static Map buildCustomDetails(NotificationContext ctx) { details.put("new_finding_count", ctx.driftNewFindingCount()); } } + if (ctx.dataBudget() != null) { + var budget = ctx.dataBudget(); + details.put("budget_name", budget.budgetName()); + details.put("used_percent", budget.usedPercent()); + details.put("window_minutes", budget.windowMinutes()); + } if (ctx.anomalyId() != null) { details.put("anomaly_id", ctx.anomalyId().toString()); if (ctx.anomalyFeature() != null) { @@ -199,6 +205,11 @@ private static String summaryLine(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_FAILED -> "AccessFlow: schema change failed on pipeline " + datasource; case SCHEMA_DRIFT_DETECTED -> "AccessFlow: schema drift detected on pipeline " + datasource; + // #942: no PagerDutyTrigger maps the data-budget events, so neither pages — spelled + // out so one added later never falls into "for a query" below. + case DATA_BUDGET_THRESHOLD_REACHED -> + "AccessFlow: data budget warning threshold reached on " + datasource; + case DATA_BUDGET_EXHAUSTED -> "AccessFlow: data budget exhausted on " + datasource; case API_CONNECTOR_OAUTH2_TOKEN_FAILED -> "AccessFlow: OAuth2 token fetch repeatedly failing for connector " + datasource; default -> "AccessFlow: " + ctx.eventType().name() + " for a query on " + datasource; diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/SlackBlockKitFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/SlackBlockKitFactory.java index 8a819b104..396249485 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/SlackBlockKitFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/SlackBlockKitFactory.java @@ -99,6 +99,9 @@ private static SectionBlock summarySection(NotificationContext ctx) { if (ctx.isSchemaChangeEvent()) { return schemaChangeSection(ctx); } + if (ctx.isDataBudgetEvent()) { + return dataBudgetSection(ctx); + } var fields = new ArrayList(); fields.add(mrkdwn("*Datasource:*\n" + nullToDash(ctx.datasourceName()))); fields.add(mrkdwn("*Submitted by:*\n" + nullToDash(ctx.submitterEmail()))); @@ -175,6 +178,15 @@ private static SectionBlock schemaChangeSection(NotificationContext ctx) { return SectionBlock.builder().fields(fields).build(); } + // #942: data budgets — shared field set with the other chat channels. + private static SectionBlock dataBudgetSection(NotificationContext ctx) { + var fields = new ArrayList(); + for (var field : DataBudgetText.fields(ctx)) { + fields.add(mrkdwn("*" + field.getKey() + ":*\n" + field.getValue())); + } + return SectionBlock.builder().fields(fields).build(); + } + private static SectionBlock attestationSection(NotificationContext ctx) { var fields = new ArrayList(); fields.add(mrkdwn("*Campaign:*\n" + nullToDash(ctx.attestationCampaignName()))); @@ -280,6 +292,8 @@ private static String headerLabel(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "✅ Schema Change Applied"; case SCHEMA_CHANGE_PROMOTION_FAILED -> "🚨 Schema Change Failed"; case SCHEMA_DRIFT_DETECTED -> "⚠️ Schema Drift Detected"; + case DATA_BUDGET_THRESHOLD_REACHED -> "📊 Data Budget Warning Threshold Reached"; + case DATA_BUDGET_EXHAUSTED -> "🛑 Data Budget Exhausted"; }; } diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TelegramMessageFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TelegramMessageFactory.java index d69244130..5ad33e411 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TelegramMessageFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TelegramMessageFactory.java @@ -68,6 +68,17 @@ private static String buildEventText(NotificationContext ctx) { } return sb.toString(); } + // #942: data budgets — shared field set with the other chat channels. + if (ctx.isDataBudgetEvent()) { + for (var field : DataBudgetText.fields(ctx)) { + appendField(sb, field.getKey(), field.getValue()); + } + if (ctx.reviewUrl() != null) { + sb.append("\n[").append(escape("Open the query editor")).append("](") + .append(escapeUrl(ctx.reviewUrl().toString())).append(")"); + } + return sb.toString(); + } // #882: schema-change promotions and drift carry the pipeline in datasourceName too. if (ctx.isSchemaChangeEvent()) { if (ctx.schemaChangeSetName() != null) { @@ -183,6 +194,8 @@ private static String headerLabel(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "✅ Schema Change Applied"; case SCHEMA_CHANGE_PROMOTION_FAILED -> "🚨 Schema Change Failed"; case SCHEMA_DRIFT_DETECTED -> "⚠️ Schema Drift Detected"; + case DATA_BUDGET_THRESHOLD_REACHED -> "📊 Data Budget Warning Threshold Reached"; + case DATA_BUDGET_EXHAUSTED -> "🛑 Data Budget Exhausted"; }; } diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TicketDescriptionBuilder.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TicketDescriptionBuilder.java index ccaccd8eb..88f14a01e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TicketDescriptionBuilder.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/TicketDescriptionBuilder.java @@ -31,6 +31,10 @@ static String summary(NotificationContext ctx) { case SCHEMA_CHANGE_PROMOTION_APPLIED -> "Schema change applied on pipeline " + datasource; case SCHEMA_CHANGE_PROMOTION_FAILED -> "Schema change failed on pipeline " + datasource; case SCHEMA_DRIFT_DETECTED -> "Schema drift detected on pipeline " + datasource; + // #942: no TicketingTrigger maps these either — spelled out for the same reason. + case DATA_BUDGET_THRESHOLD_REACHED -> + "Data budget warning threshold reached on " + datasource; + case DATA_BUDGET_EXHAUSTED -> "Data budget exhausted on " + datasource; default -> ctx.eventType().name() + " on " + datasource; }; var summary = "[AccessFlow] " + headline; diff --git a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/WebhookPayloadFactory.java b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/WebhookPayloadFactory.java index 395ddf61e..d2ebe2af6 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/WebhookPayloadFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/notifications/internal/strategy/WebhookPayloadFactory.java @@ -74,6 +74,25 @@ String buildBody(NotificationContext ctx) { schemaChange.put("new_finding_count", ctx.driftNewFindingCount()); envelope.put("schema_change", schemaChange); } + // #942: data budgets — additive for the same reason. + if (ctx.dataBudget() != null) { + var budget = ctx.dataBudget(); + var dataBudget = new LinkedHashMap(); + dataBudget.put("budget_id", budget.budgetId()); + dataBudget.put("budget_name", budget.budgetName()); + dataBudget.put("datasource_id", ctx.datasourceId()); + dataBudget.put("user_id", ctx.submittedByUserId()); + dataBudget.put("exhausted", budget.exhausted()); + dataBudget.put("warn_threshold_percent", budget.warnThresholdPercent()); + dataBudget.put("used_percent", budget.usedPercent()); + dataBudget.put("max_rows", budget.maxRows()); + dataBudget.put("used_rows", budget.usedRows()); + dataBudget.put("max_bytes", budget.maxBytes()); + dataBudget.put("used_bytes", budget.usedBytes()); + dataBudget.put("window_minutes", budget.windowMinutes()); + dataBudget.put("breach_action", budget.breachAction()); + envelope.put("data_budget", dataBudget); + } return objectMapper.writeValueAsString(envelope); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java index 07960ec71..46a298f71 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutor.java @@ -83,6 +83,9 @@ public QueryExecutionResult execute(QueryExecutionRequest request) { try (Observation.Scope ignored = observation.openScope()) { QueryExecutionResult result = executeInternal(request, descriptor, effectiveMaxRows, effectiveTimeout, execProps, observation); + if (result instanceof SelectExecutionResult select) { + result = measureBytes(select, request.maxResultBytesOverride()); + } observation.lowCardinalityKeyValue("outcome", "success"); return result; } catch (RuntimeException ex) { @@ -448,6 +451,25 @@ private Duration durationSince(Instant start) { return Duration.between(start, clock.instant()); } + /** + * Stamps the delivered size (#942) and, under a result-byte override — which only the data + * budget sets — trims the rows past it, attributing the cut to the budget. + * Runs over every SELECT result — JDBC, engine plugin, cache hit — so accounting is uniform. + * As in the row mapper, the first row is always kept. + */ + static SelectExecutionResult measureBytes(SelectExecutionResult result, Long maxBytes) { + long total = 0; + var rows = result.rows(); + for (int i = 0; i < rows.size(); i++) { + long next = total + ResultByteEstimator.estimateRow(rows.get(i)); + if (maxBytes != null && next > maxBytes && i > 0) { + return result.truncatedTo(i, SelectExecutionResult.TRUNCATED_DATA_BUDGET, total); + } + total = next; + } + return result.withResultBytes(total); + } + /** An override only ever lowers the cap — never above the datasource cap or global ceiling. */ private static int clampMaxRows(Integer override, int datasourceCap, int globalCap) { int candidate = override != null ? Math.min(override, datasourceCap) : datasourceCap; diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java index d4d4727dd..305d178e7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java @@ -2,6 +2,16 @@ import com.bablsoft.accessflow.core.api.AllowedTables; import com.bablsoft.accessflow.core.api.ColumnMaskDirective; +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetExhaustedException; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageRecord; +import com.bablsoft.accessflow.core.api.DataBudgetUsageService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; import com.bablsoft.accessflow.core.api.DatabaseSchemaView; import com.bablsoft.accessflow.core.api.DatasourceAdminService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; @@ -18,11 +28,13 @@ import com.bablsoft.accessflow.proxy.api.QueryExecutor; import com.bablsoft.accessflow.proxy.api.SampleDataService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -32,6 +44,7 @@ @Service @RequiredArgsConstructor +@Slf4j class DefaultSampleDataService implements SampleDataService { private final DatasourceAdminService datasourceAdminService; @@ -41,6 +54,9 @@ class DefaultSampleDataService implements SampleDataService { private final RowLimitPolicyResolutionService rowLimitPolicyResolutionService; private final QueryExecutor queryExecutor; private final MessageSource messageSource; + private final DataBudgetStatusService dataBudgetStatusService; + private final DataBudgetUsageService dataBudgetUsageService; + private final AuditLogService auditLogService; @Override public SelectExecutionResult sample(UUID datasourceId, UUID organizationId, UUID userId, @@ -97,10 +113,68 @@ public SelectExecutionResult sample(UUID datasourceId, UUID organizationId, UUID rowLimitOverride = appliedRowLimit.get().tighten(rowLimitOverride); } + // #942: a preview reads real rows, so it spends the data budget like a query. It has no + // review to escalate to: an exhausted budget refuses it whatever the breach action. + var budget = dataBudgetStatusService.statusFor(datasourceId, userId); + if (budget.exhausted()) { + var deciding = budget.decidingBudget(); + auditRefusal(organizationId, datasourceId, userId, deciding, qualifiedName(target)); + throw new DataBudgetExhaustedException(messageSource.getMessage( + "error.data_budget.exhausted", new Object[]{deciding.name()}, + LocaleContextHolder.getLocale()), deciding); + } + var policyLimit = effectiveLimit(limit, rowLimitOverride); + var rowLimit = policyLimit; + if (budget.remainingRows() != null) { + rowLimit = (int) Math.min(rowLimit, Math.max(1, budget.remainingRows())); + } + // 3. Execute via the proxy executor — RLS rewrite + post-fetch masking + row cap + timeout. - return queryExecutor.sampleTable(new SampleTableRequest(datasourceId, target.schema(), + var result = queryExecutor.sampleTable(new SampleTableRequest(datasourceId, target.schema(), target.table(), restrictedColumns, columnMasks, rowSecurityPredicates, - effectiveLimit(limit, rowLimitOverride), null)); + rowLimit, null)); + if (budget.isEmpty()) { + return result; + } + var remainingBytes = budget.remainingBytes(); + var measured = DefaultQueryExecutor.measureBytes(result, + remainingBytes == null ? null : Math.max(1, remainingBytes)); + // The budget's row allowance was the binding cap: say so, as the query path does. + if (rowLimit < policyLimit && measured.truncated() + && SelectExecutionResult.TRUNCATED_ROW_LIMIT.equals(measured.truncatedReason()) + && measured.rowCount() == rowLimit) { + measured = measured.withTruncatedReason(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + } + try { + dataBudgetUsageService.record(new DataBudgetUsageRecord(userId, datasourceId, + measured.rowCount(), measured.resultBytes(), DataBudgetUsageSource.SAMPLE_DATA, + null, null)); + } catch (RuntimeException ex) { + log.error("Data-budget usage write failed for a sample of datasource {}", datasourceId, + ex); + } + return measured; + } + + private void auditRefusal(UUID organizationId, UUID datasourceId, UUID userId, + DataBudgetConsumption deciding, String table) { + var metadata = new HashMap(); + metadata.put("trigger", "data_budget"); + metadata.put("stage", "sample"); + metadata.put("action", deciding.breachAction().name()); + metadata.put("data_budget_id", deciding.budgetId()); + metadata.put("used_rows", deciding.usedRows()); + metadata.put("used_bytes", deciding.usedBytes()); + metadata.put("window_minutes", deciding.windowMinutes()); + metadata.put("table", table); + try { + auditLogService.record(new AuditEntry(AuditAction.QUERY_DATA_BUDGET_ENFORCED, + AuditResourceType.DATASOURCE, datasourceId, organizationId, userId, metadata, + null, null)); + } catch (RuntimeException ex) { + log.error("Audit write failed for QUERY_DATA_BUDGET_ENFORCED on datasource {}", + datasourceId, ex); + } } /** The caller's row-limit override (#933) and row-limit policies (#934) cap the preview too, so it can't be used to get around the cap. */ diff --git a/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionService.java b/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionService.java index aec847e09..90ddff73d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionService.java @@ -12,6 +12,13 @@ import com.bablsoft.accessflow.core.api.ColumnMaskDirective; import com.bablsoft.accessflow.core.api.DatasourceLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; +import com.bablsoft.accessflow.core.api.DataBudgetExhaustedException; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageRecord; +import com.bablsoft.accessflow.core.api.DataBudgetUsageService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; +import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.DbType; import com.bablsoft.accessflow.core.api.MaskingPolicyResolutionService; import com.bablsoft.accessflow.core.api.QueryExecutionRequest; @@ -80,6 +87,8 @@ public class GroupExecutionService { private final MessageSource messageSource; private final QueryCostEstimateService queryCostEstimateService; private final GroupReviewDecisionRepository decisionRepository; + private final DataBudgetStatusService dataBudgetStatusService; + private final DataBudgetUsageService dataBudgetUsageService; /** Execute an APPROVED group. Idempotent: silently returns if it is not APPROVED (or not yet due). */ public void execute(UUID groupId, UUID actorUserId, String trigger) { @@ -191,7 +200,14 @@ private void runQuery(RequestGroupEntity group, RequestGroupItemEntity item) { null, restrictedColumns, columnMasks, rowSecurity, parsed.transactional(), parsed.statements(), List.of(), parsed.referencedTables()); enforceBytesScannedCap(group, item, request); + var budget = enforceDataBudget(group, item); + if (budget != null) { + request = request.withAllowance(budget.remainingRows(), budget.remainingBytes()); + } var result = queryExecutor.execute(request); + if (budget != null && result instanceof SelectExecutionResult select) { + chargeDataBudget(group, item, select); + } long rows = switch (result) { case SelectExecutionResult select -> select.rowCount(); case UpdateExecutionResult update -> update.rowsAffected(); @@ -289,6 +305,49 @@ private void enforceBytesScannedCap(RequestGroupEntity group, RequestGroupItemEn throw new BytesScannedCapExceededException(message, cap, estimated, outcome); } + /** + * The submitter's data budget (#942) for a SELECT member: {@code null} when none applies, + * otherwise the standing to cap the member to. An exhausted budget refuses the member whatever + * its action: a group's review never evaluates the budget, so an approval given while allowance + * remained must not lift it (fail closed). + */ + private DataBudgetStatus enforceDataBudget(RequestGroupEntity group, RequestGroupItemEntity item) { + if (item.getQueryType() != QueryType.SELECT) { + return null; + } + var status = dataBudgetStatusService.statusFor(item.getDatasourceId(), group.getSubmittedBy()); + if (status.isEmpty() || !status.exhausted()) { + return status.isEmpty() ? null : status; + } + var deciding = status.decidingBudget(); + var metadata = new HashMap(); + metadata.put("trigger", "data_budget"); + metadata.put("stage", "execution"); + metadata.put("item_id", item.getId().toString()); + metadata.put("action", status.breachAction().name()); + metadata.put("data_budget_id", deciding.budgetId()); + metadata.put("used_rows", deciding.usedRows()); + metadata.put("used_bytes", deciding.usedBytes()); + metadata.put("window_minutes", deciding.windowMinutes()); + audit(AuditAction.QUERY_DATA_BUDGET_ENFORCED, group, null, metadata); + throw new DataBudgetExhaustedException(messageSource.getMessage( + "error.data_budget.exhausted", new Object[]{deciding.name()}, + LocaleContextHolder.getLocale()), deciding); + } + + /** Never fails the member: the rows were already delivered. */ + private void chargeDataBudget(RequestGroupEntity group, RequestGroupItemEntity item, + SelectExecutionResult select) { + try { + dataBudgetUsageService.record(new DataBudgetUsageRecord(group.getSubmittedBy(), + item.getDatasourceId(), select.rowCount(), select.resultBytes(), + DataBudgetUsageSource.REQUEST_GROUP, null, group.getId())); + } catch (RuntimeException ex) { + log.error("Data-budget usage write failed for group {} member {}", group.getId(), + item.getId(), ex); + } + } + private void audit(AuditAction action, RequestGroupEntity group, UUID actorId, Map metadata) { try { diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetController.java new file mode 100644 index 000000000..b3eb90507 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetController.java @@ -0,0 +1,155 @@ +package com.bablsoft.accessflow.security.internal.web; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.core.api.DataBudgetAdminService; +import com.bablsoft.accessflow.core.api.DataBudgetCommand; +import com.bablsoft.accessflow.core.api.DataBudgetView; +import com.bablsoft.accessflow.security.api.JwtClaims; +import com.bablsoft.accessflow.security.internal.web.model.DataBudgetListResponse; +import com.bablsoft.accessflow.security.internal.web.model.DataBudgetRequest; +import com.bablsoft.accessflow.security.internal.web.model.DataBudgetResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/datasources/{datasourceId}/data-budgets") +@Tag(name = "Data budgets", + description = "Per-user data-volume budgets over a rolling window on a datasource (#942)") +@PreAuthorize("hasAuthority('PERM_DATA_BUDGET_MANAGE')") +@RequiredArgsConstructor +@Slf4j +class DataBudgetController { + + private final DataBudgetAdminService dataBudgetAdminService; + private final AuditLogService auditLogService; + + @GetMapping + @Operation(summary = "List data budgets configured on a datasource") + @ApiResponse(responseCode = "200", description = "List of data budgets") + @ApiResponse(responseCode = "404", description = "Datasource not found") + DataBudgetListResponse list(@PathVariable UUID datasourceId, Authentication authentication) { + var caller = currentClaims(authentication); + return new DataBudgetListResponse(dataBudgetAdminService + .listForDatasource(datasourceId, caller.organizationId()).stream() + .map(DataBudgetResponse::from) + .toList()); + } + + @PostMapping + @Operation(summary = "Create a data budget on a datasource") + @ApiResponse(responseCode = "201", description = "Data budget created") + @ApiResponse(responseCode = "400", description = "Bean Validation failure") + @ApiResponse(responseCode = "404", description = "Datasource not found") + @ApiResponse(responseCode = "422", description = "No limit, a limit or window out of range, or an invalid applies-to target") + ResponseEntity create(@PathVariable UUID datasourceId, + @Valid @RequestBody DataBudgetRequest request, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + var view = dataBudgetAdminService.create(datasourceId, caller.organizationId(), + toCommand(request)); + recordAudit(AuditAction.DATA_BUDGET_CREATED, view, caller, auditContext); + var location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{budgetId}") + .buildAndExpand(view.id()) + .toUri(); + return ResponseEntity.created(location).body(DataBudgetResponse.from(view)); + } + + @PutMapping("/{budgetId}") + @Operation(summary = "Update a data budget") + @ApiResponse(responseCode = "200", description = "Data budget updated") + @ApiResponse(responseCode = "400", description = "Bean Validation failure") + @ApiResponse(responseCode = "404", description = "Datasource or budget not found") + @ApiResponse(responseCode = "422", description = "No limit, a limit or window out of range, or an invalid applies-to target") + DataBudgetResponse update(@PathVariable UUID datasourceId, + @PathVariable UUID budgetId, + @Valid @RequestBody DataBudgetRequest request, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + var view = dataBudgetAdminService.update(budgetId, datasourceId, caller.organizationId(), + toCommand(request)); + recordAudit(AuditAction.DATA_BUDGET_UPDATED, view, caller, auditContext); + return DataBudgetResponse.from(view); + } + + @DeleteMapping("/{budgetId}") + @Operation(summary = "Delete a data budget") + @ApiResponse(responseCode = "204", description = "Data budget deleted") + @ApiResponse(responseCode = "404", description = "Datasource or budget not found") + ResponseEntity delete(@PathVariable UUID datasourceId, + @PathVariable UUID budgetId, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + dataBudgetAdminService.delete(budgetId, datasourceId, caller.organizationId()); + var metadata = new HashMap(); + metadata.put("datasource_id", datasourceId.toString()); + recordAudit(AuditAction.DATA_BUDGET_DELETED, budgetId, caller, auditContext, metadata); + return ResponseEntity.noContent().build(); + } + + private static DataBudgetCommand toCommand(DataBudgetRequest request) { + return new DataBudgetCommand(request.name(), request.maxRows(), request.maxBytes(), + request.windowMinutes(), request.breachAction(), request.warnThresholdPercent(), + request.appliesToRoles(), request.appliesToGroupIds(), request.appliesToUserIds(), + request.enabled()); + } + + private void recordAudit(AuditAction action, DataBudgetView view, JwtClaims caller, + RequestAuditContext auditContext) { + var metadata = new HashMap(); + metadata.put("datasource_id", view.datasourceId().toString()); + metadata.put("name", view.name()); + if (view.maxRows() != null) { + metadata.put("max_rows", view.maxRows()); + } + if (view.maxBytes() != null) { + metadata.put("max_bytes", view.maxBytes()); + } + metadata.put("window_minutes", view.windowMinutes()); + metadata.put("breach_action", view.breachAction().name()); + metadata.put("enabled", view.enabled()); + recordAudit(action, view.id(), caller, auditContext, metadata); + } + + private void recordAudit(AuditAction action, UUID budgetId, JwtClaims caller, + RequestAuditContext auditContext, Map metadata) { + try { + auditLogService.record(new AuditEntry(action, AuditResourceType.DATA_BUDGET, budgetId, + caller.organizationId(), caller.userId(), new HashMap<>(metadata), + auditContext.ipAddress(), auditContext.userAgent())); + } catch (RuntimeException ex) { + log.error("Audit write failed for {} on data budget {}", action, budgetId, ex); + } + } + + private static JwtClaims currentClaims(Authentication authentication) { + return (JwtClaims) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetStatusController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetStatusController.java new file mode 100644 index 000000000..b2262db4c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DataBudgetStatusController.java @@ -0,0 +1,69 @@ +package com.bablsoft.accessflow.security.internal.web; + +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DatasourceAdminService; +import com.bablsoft.accessflow.core.api.Permission; +import com.bablsoft.accessflow.core.api.UserNotFoundException; +import com.bablsoft.accessflow.core.api.UserQueryService; +import com.bablsoft.accessflow.security.api.JwtClaims; +import com.bablsoft.accessflow.security.internal.web.model.DataBudgetStatusListResponse; +import com.bablsoft.accessflow.security.internal.web.model.DataBudgetStatusResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** Read-only data-budget standing (#942): the caller's own, and any user's for admins. */ +@RestController +@Tag(name = "Data budgets", description = "Remaining data-volume allowance (#942)") +@RequiredArgsConstructor +class DataBudgetStatusController { + + private final DataBudgetStatusService dataBudgetStatusService; + private final DatasourceAdminService datasourceAdminService; + private final UserQueryService userQueryService; + + @GetMapping("/api/v1/datasources/{datasourceId}/data-budgets/me") + @Operation(summary = "The caller's data-budget standing on a datasource") + @ApiResponse(responseCode = "200", description = "Standing; an empty budgets list when none applies") + @ApiResponse(responseCode = "404", description = "Datasource not found or not visible to the caller") + DataBudgetStatusResponse mine(@PathVariable UUID datasourceId, Authentication authentication) { + var caller = currentClaims(authentication); + // Resolves visibility first so an invisible datasource reads 404, never an empty 200. + if (caller.has(Permission.QUERY_ADMIN) || caller.has(Permission.DATASOURCE_MANAGE)) { + datasourceAdminService.getForAdmin(datasourceId, caller.organizationId()); + } else { + datasourceAdminService.getForUser(datasourceId, caller.organizationId(), + caller.userId()); + } + return DataBudgetStatusResponse.from( + dataBudgetStatusService.statusFor(datasourceId, caller.userId())); + } + + @GetMapping("/api/v1/admin/users/{userId}/data-budget-usage") + @PreAuthorize("hasAuthority('PERM_DATA_BUDGET_MANAGE') or hasAuthority('PERM_USER_MANAGE')") + @Operation(summary = "A user's data-budget standing on every budgeted datasource") + @ApiResponse(responseCode = "200", description = "One entry per datasource where a budget applies to the user") + @ApiResponse(responseCode = "404", description = "User not found in the caller's organization") + DataBudgetStatusListResponse forUser(@PathVariable UUID userId, Authentication authentication) { + var caller = currentClaims(authentication); + userQueryService.findById(userId) + .filter(u -> caller.organizationId().equals(u.organizationId())) + .orElseThrow(() -> new UserNotFoundException(userId)); + return new DataBudgetStatusListResponse(dataBudgetStatusService + .statusesForUser(caller.organizationId(), userId).stream() + .map(DataBudgetStatusResponse::from) + .toList()); + } + + private static JwtClaims currentClaims(Authentication authentication) { + return (JwtClaims) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java index 668b5ca86..ae638bb17 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java @@ -28,6 +28,9 @@ import com.bablsoft.accessflow.core.api.ExportPolicyNotFoundException; import com.bablsoft.accessflow.core.api.IllegalExportPolicyException; import com.bablsoft.accessflow.core.api.IllegalRowLimitPolicyException; +import com.bablsoft.accessflow.core.api.DataBudgetExhaustedException; +import com.bablsoft.accessflow.core.api.DataBudgetNotFoundException; +import com.bablsoft.accessflow.core.api.IllegalDataBudgetException; import com.bablsoft.accessflow.core.api.IllegalRowSecurityPolicyException; import com.bablsoft.accessflow.core.api.InvalidSimulationPeriodException; import com.bablsoft.accessflow.core.api.MaskingPolicyNotFoundException; @@ -653,6 +656,40 @@ ProblemDetail handleIllegalRowLimitPolicy(IllegalRowLimitPolicyException ex) { return pd; } + @ExceptionHandler(DataBudgetNotFoundException.class) + ProblemDetail handleDataBudgetNotFound(DataBudgetNotFoundException ex) { + var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, + msg("error.data_budget.not_found")); + pd.setProperty("error", "DATA_BUDGET_NOT_FOUND"); + pd.setProperty("timestamp", Instant.now().toString()); + return pd; + } + + @ExceptionHandler(IllegalDataBudgetException.class) + ProblemDetail handleIllegalDataBudget(IllegalDataBudgetException ex) { + // Message is resolved at the throw site via MessageSource — see DefaultDataBudgetAdminService. + var pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + pd.setProperty("error", "ILLEGAL_DATA_BUDGET"); + pd.setProperty("timestamp", Instant.now().toString()); + return pd; + } + + @ExceptionHandler(DataBudgetExhaustedException.class) + ProblemDetail handleDataBudgetExhausted(DataBudgetExhaustedException ex) { + // Message is resolved at the throw site — it names the exhausted budget. + var pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + pd.setProperty("error", "DATA_BUDGET_EXHAUSTED"); + var budget = ex.budget(); + if (budget != null) { + pd.setProperty("budgetId", budget.budgetId()); + pd.setProperty("maxRows", budget.maxRows()); + pd.setProperty("maxBytes", budget.maxBytes()); + pd.setProperty("windowMinutes", budget.windowMinutes()); + } + pd.setProperty("timestamp", Instant.now().toString()); + return pd; + } + @ExceptionHandler(QueryExecutionTimeoutException.class) ProblemDetail handleQueryExecutionTimeout(QueryExecutionTimeoutException ex) { var pd = ProblemDetail.forStatusAndDetail(HttpStatus.GATEWAY_TIMEOUT, diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetListResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetListResponse.java new file mode 100644 index 000000000..508682b43 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetListResponse.java @@ -0,0 +1,5 @@ +package com.bablsoft.accessflow.security.internal.web.model; + +import java.util.List; + +public record DataBudgetListResponse(List content) {} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetRequest.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetRequest.java new file mode 100644 index 000000000..b9e9ecc4c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetRequest.java @@ -0,0 +1,32 @@ +package com.bablsoft.accessflow.security.internal.web.model; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.UUID; + +/** Create/update body for a data budget (#942). Updates are total, like create. */ +public record DataBudgetRequest( + @NotBlank(message = "{validation.data_budget.name.required}") + @Size(max = 120, message = "{validation.data_budget.name.size}") + String name, + @Min(value = 1, message = "{validation.data_budget.max_rows.min}") + Long maxRows, + @Min(value = 1, message = "{validation.data_budget.max_bytes.min}") + Long maxBytes, + @Min(value = 60, message = "{validation.data_budget.window.range}") + @Max(value = 44_640, message = "{validation.data_budget.window.range}") + Integer windowMinutes, + DataBudgetBreachAction breachAction, + @Min(value = 1, message = "{validation.data_budget.threshold.range}") + @Max(value = 99, message = "{validation.data_budget.threshold.range}") + Integer warnThresholdPercent, + List appliesToRoles, + List appliesToGroupIds, + List appliesToUserIds, + Boolean enabled +) {} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetResponse.java new file mode 100644 index 000000000..96b65556f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetResponse.java @@ -0,0 +1,43 @@ +package com.bablsoft.accessflow.security.internal.web.model; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetView; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record DataBudgetResponse( + UUID id, + UUID datasourceId, + String name, + Long maxRows, + Long maxBytes, + int windowMinutes, + DataBudgetBreachAction breachAction, + Integer warnThresholdPercent, + List appliesToRoles, + List appliesToGroupIds, + List appliesToUserIds, + boolean enabled, + Instant createdAt, + Instant updatedAt) { + + public static DataBudgetResponse from(DataBudgetView view) { + return new DataBudgetResponse( + view.id(), + view.datasourceId(), + view.name(), + view.maxRows(), + view.maxBytes(), + view.windowMinutes(), + view.breachAction(), + view.warnThresholdPercent(), + view.appliesToRoles(), + view.appliesToGroupIds(), + view.appliesToUserIds(), + view.enabled(), + view.createdAt(), + view.updatedAt()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusListResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusListResponse.java new file mode 100644 index 000000000..46857ca20 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusListResponse.java @@ -0,0 +1,5 @@ +package com.bablsoft.accessflow.security.internal.web.model; + +import java.util.List; + +public record DataBudgetStatusListResponse(List content) {} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusResponse.java new file mode 100644 index 000000000..c19ccac47 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/DataBudgetStatusResponse.java @@ -0,0 +1,62 @@ +package com.bablsoft.accessflow.security.internal.web.model; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; + +import java.util.List; +import java.util.UUID; + +/** + * A user's data-budget standing on one datasource (#942). {@code budgets} is empty when no budget + * applies; the top-level fields are then null / false. + */ +public record DataBudgetStatusResponse( + UUID datasourceId, + String datasourceName, + boolean exhausted, + DataBudgetBreachAction breachAction, + Long remainingRows, + Long remainingBytes, + Integer usedPercent, + List budgets) { + + public record Budget( + UUID id, + String name, + Long maxRows, + Long maxBytes, + int windowMinutes, + DataBudgetBreachAction breachAction, + Integer warnThresholdPercent, + long usedRows, + long usedBytes, + Long remainingRows, + Long remainingBytes, + int usedPercent, + boolean exhausted) { + + static Budget from(DataBudgetConsumption c) { + return new Budget(c.budgetId(), c.name(), c.maxRows(), c.maxBytes(), c.windowMinutes(), + c.breachAction(), c.warnThresholdPercent(), c.usedRows(), c.usedBytes(), + c.remainingRows(), c.remainingBytes(), floor(c.usedPercent()), c.exhausted()); + } + } + + public static DataBudgetStatusResponse from(DataBudgetStatus status) { + var used = status.usedPercent(); + return new DataBudgetStatusResponse( + status.datasourceId(), + status.datasourceName(), + status.exhausted(), + status.breachAction(), + status.remainingRows(), + status.remainingBytes(), + used == null ? null : floor(used), + status.budgets().stream().map(Budget::from).toList()); + } + + private static int floor(double percent) { + return (int) Math.min(Integer.MAX_VALUE, Math.floor(percent)); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java index a7f31d68a..109cd1076 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java @@ -40,6 +40,10 @@ * bytes from the same row — {@code null} for every engine that reports none, and whenever the * estimate is absent or failed. * + *

{@code dataBudgetUsedPercent} (#942) is the share of the submitter's most-used data budget on + * this datasource, as a whole percentage — {@code null} when no budget applies, the statement is not + * a SELECT, and on the historical replay path (usage then is not reconstructable). + * *

{@code queryShapes} are the structural features re-derived from the SQL with the WHERE / LIMIT * signals (#940). {@code shapesAnalyzed} is {@code false} when the SQL could not be parsed or walked * (typically a non-SQL engine); the {@code query_shape} condition then fails closed. @@ -64,7 +68,8 @@ public record ConditionContext( String scanType, Set queryShapes, boolean shapesAnalyzed, - Long estimatedBytesScanned) { + Long estimatedBytesScanned, + Integer dataBudgetUsedPercent) { public ConditionContext { referencedTables = Set.copyOf(referencedTables == null ? Set.of() : referencedTables); @@ -72,6 +77,23 @@ public record ConditionContext( queryShapes = Set.copyOf(queryShapes == null ? Set.of() : queryShapes); } + /** Backward-compatible constructor without the #942 data-budget signal (defaults to absent). */ + public ConditionContext(QueryType queryType, Set referencedTables, RiskLevel riskLevel, + int riskScore, String requesterRoleName, Set requesterGroupIds, + LocalDateTime evaluatedAt, boolean hasWhereClause, + boolean hasLimitClause, boolean transactional, + String requesterIpAddress, String requesterUserAgent, + boolean ciCdOrigin, Integer minutesSinceLastApproval, + boolean anomalyActive, Long estimatedRows, String scanType, + Set queryShapes, boolean shapesAnalyzed, + Long estimatedBytesScanned) { + this(queryType, referencedTables, riskLevel, riskScore, requesterRoleName, + requesterGroupIds, evaluatedAt, hasWhereClause, hasLimitClause, transactional, + requesterIpAddress, requesterUserAgent, ciCdOrigin, minutesSinceLastApproval, + anomalyActive, estimatedRows, scanType, queryShapes, shapesAnalyzed, + estimatedBytesScanned, null); + } + /** Backward-compatible constructor without the #941 bytes estimate (defaults to absent). */ public ConditionContext(QueryType queryType, Set referencedTables, RiskLevel riskLevel, int riskScore, String requesterRoleName, Set requesterGroupIds, @@ -112,7 +134,7 @@ public ConditionContext(QueryType queryType, Set referencedTables, RiskL this(queryType, referencedTables, riskLevel, riskScore, requesterRoleName, requesterGroupIds, evaluatedAt, hasWhereClause, hasLimitClause, transactional, requesterIpAddress, requesterUserAgent, ciCdOrigin, minutesSinceLastApproval, - anomalyActive, null, null, Set.of(), false, null); + anomalyActive, null, null, Set.of(), false, null, null); } /** @return {@code true} when an AI risk level / score signal is present. */ diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java index 1a9e3d3d2..2c650d1e4 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java @@ -234,6 +234,25 @@ record EstimatedBytesScanned(ComparisonOperator operator, long value) implements } } + /** + * Compares the share of the submitter's data-volume budget already used on this datasource + * (#942 — the most-used applying budget, as a whole percentage that can exceed 100) with + * {@code value}. Fails closed: evaluates to {@code false} when no budget applies + * to the submitter, the statement is not a SELECT, or on a historical replay — so it is an + * escalation trigger, never a way to auto-approve on missing context. + */ + record DataBudgetUsedPercent(ComparisonOperator operator, int value) implements ConditionNode { + public DataBudgetUsedPercent { + if (operator == null) { + throw new IllegalArgumentException( + "DataBudgetUsedPercent condition requires an operator"); + } + if (value < 0) { + throw new IllegalArgumentException("DataBudgetUsedPercent value must be >= 0"); + } + } + } + /** * Matches when the pre-flight plan's root scan/operation type (AF-624 — e.g. {@code Seq Scan}, * {@code Index Scan}, {@code COLLSCAN}) matches any glob in {@code patterns} ({@code *} = any diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryDecisionStepKind.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryDecisionStepKind.java index 2b76abb70..e8d4f8015 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryDecisionStepKind.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/QueryDecisionStepKind.java @@ -49,6 +49,14 @@ public enum QueryDecisionStepKind implements DecisionStepKind { */ BYTES_SCANNED_CAP, + /** + * The submitter's data-volume budget (#942): {@code DENY} when an exhausted budget rejects; + * {@code MATCH} when an exhausted budget forces human review (every auto-approve stage below is + * then suppressed); {@code ALLOW} while allowance remains; {@code NO_MATCH} when no budget + * applies or the statement is not a SELECT. + */ + DATA_BUDGET, + /** Every enabled routing policy in ascending priority order, matched and unmatched. */ ROUTING_POLICIES, diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheck.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheck.java new file mode 100644 index 000000000..1fe7bbe9d --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheck.java @@ -0,0 +1,39 @@ +package com.bablsoft.accessflow.workflow.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; + +/** + * The submitter's data-volume budget standing (#942) for one SELECT — the input + * {@link QueryDecisionEvaluator} decides on, like the bytes-scanned cap. + * + * @param deciding the exhausted budget whose action applies, {@code null} while allowance remains + * @param action the strictest action among exhausted budgets, {@code null} while none is + * @param usedPercent the highest share of any applying budget used, 0–100+ + */ +record DataBudgetCheck(DataBudgetConsumption deciding, DataBudgetBreachAction action, + double usedPercent, Long remainingRows, Long remainingBytes) { + + /** {@code null} when no budget applies, so "no budget" and "within budget" stay distinct. */ + static DataBudgetCheck of(DataBudgetStatus status) { + if (status == null || status.isEmpty()) { + return null; + } + var used = status.usedPercent(); + return new DataBudgetCheck(status.decidingBudget(), status.breachAction(), + used == null ? 0d : used, status.remainingRows(), status.remainingBytes()); + } + + boolean exhausted() { + return action != null; + } + + boolean rejects() { + return action == DataBudgetBreachAction.REJECT; + } + + boolean forcesReview() { + return action == DataBudgetBreachAction.REQUIRE_REVIEW; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java index 532972844..7ca06da9b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java @@ -12,6 +12,7 @@ import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; import com.bablsoft.accessflow.core.api.QueryShape; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QuotaExceededException; @@ -89,6 +90,7 @@ class DefaultAccessSimulationService implements AccessSimulationService { private final MaskingPolicyResolutionService maskingPolicyResolutionService; private final BreakGlassEligibilityService breakGlassEligibilityService; private final BytesScannedCapResolutionService bytesScannedCapResolutionService; + private final DataBudgetStatusService dataBudgetStatusService; // Time-of-day / day-of-week routing conditions evaluate in the server's local zone, so the // simulator has to use the same zone the live listener does. Deliberately NOT the injected @@ -149,9 +151,15 @@ public AccessSimulationResult simulate(UUID organizationId, AccessSimulationInpu .resolve(input.datasourceId(), input.userId()) .map(BytesCapCheck::unevaluated) .orElse(null); + // Budget usage is a live, persisted fact about the user, so it is read as it stands (#942). + var dataBudget = parsed.type() == QueryType.SELECT + ? DataBudgetCheck.of(dataBudgetStatusService.statusFor(input.datasourceId(), + input.userId())) + : null; var decision = queryDecisionEvaluator.evaluate( syntheticSnapshot(organizationId, input, parsed), input.aiOutcome(), - input.riskLevel(), input.effectiveRiskScore(), blockingRuleIds, bytesCap, clock); + input.riskLevel(), input.effectiveRiskScore(), blockingRuleIds, bytesCap, dataBudget, + clock); steps.addAll(withFullPolicyList(decision, organizationId, input.datasourceId())); steps.add(reviewerStep(input, decision.nextStatus())); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java index 1449a6bba..f33a3c091 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleService.java @@ -1,6 +1,14 @@ package com.bablsoft.accessflow.workflow.internal; import com.bablsoft.accessflow.core.api.ByteSizeFormat; +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.QueryType; +import com.bablsoft.accessflow.core.api.DataBudgetExhaustedException; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageRecord; +import com.bablsoft.accessflow.core.api.DataBudgetUsageService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; import com.bablsoft.accessflow.core.api.BytesScannedCapExceededException; import com.bablsoft.accessflow.core.api.BytesScannedCapResolutionService; import com.bablsoft.accessflow.core.api.QueryEstimateSnapshot; @@ -94,6 +102,8 @@ class DefaultQueryLifecycleService implements QueryLifecycleService { private final ApplicationEventPublisher eventPublisher; private final BytesScannedCapResolutionService bytesScannedCapResolutionService; private final QueryCostEstimateService queryCostEstimateService; + private final DataBudgetStatusService dataBudgetStatusService; + private final DataBudgetUsageService dataBudgetUsageService; private String msg(String key) { return messageSource.getMessage(key, null, LocaleContextHolder.getLocale()); @@ -288,6 +298,11 @@ private ExecutionOutcome doExecute(QueryRequestSnapshot query, UUID actorUserId, // and break-glass runs execute later or without that decision, and the cap may have // been lowered since. A refusal is recorded as a failed execution. var bytesCap = enforceBytesScannedCap(query); + // #942: the reader's data budget. Allowance left ⇒ the result is capped to it; exhausted + // ⇒ refused, unless the exhausted budget itself forced the review this query passed. + // Break-glass is counted but never capped or refused by a budget. + var budget = enforceDataBudget(query, + successAction == AuditAction.QUERY_BREAK_GLASS_EXECUTED); var permission = permissionLookupService .findFor(query.submittedByUserId(), query.datasourceId()); var restrictedColumns = permission @@ -349,10 +364,20 @@ private ExecutionOutcome doExecute(QueryRequestSnapshot query, UUID actorUserId, softDeleteFilters.stream()).toList(); var softDeletes = lifecycleDirectiveResolutionService .resolveSoftDeletes(query.organizationId(), query.datasourceId()); - var result = queryExecutor.execute(new QueryExecutionRequest( + var executionRequest = new QueryExecutionRequest( query.datasourceId(), query.sqlText(), query.queryType(), rowLimitOverride, null, restrictedColumns, columnMasks, rowSecurityPredicates, parsed.transactional(), - parsed.statements(), softDeletes, parsed.referencedTables())); + parsed.statements(), softDeletes, parsed.referencedTables()); + if (budget != null && budget.capped()) { + executionRequest = executionRequest.withAllowance( + budget.status().remainingRows(), budget.status().remainingBytes()); + } + var result = queryExecutor.execute(executionRequest); + if (budget != null && budget.capped() && result instanceof SelectExecutionResult select) { + result = attributeBudgetTruncation(select, budget.status().remainingRows(), + rowLimitOverride, descriptor.map(DatasourceConnectionDescriptor::maxRowsPerQuery) + .orElse(null)); + } var completedAt = Instant.now(); var durationMs = (int) result.duration().toMillis(); Long rowsAffected; @@ -421,6 +446,11 @@ private ExecutionOutcome doExecute(QueryRequestSnapshot query, UUID actorUserId, appliedRowLimitPolicyIds.stream() .map(UUID::toString).sorted().toList()); } + if (budget != null && result instanceof SelectExecutionResult select) { + successMetadata.put("data_budget_rows_charged", select.rowCount()); + successMetadata.put("data_budget_bytes_charged", select.resultBytes()); + chargeDataBudget(query, select); + } recordAudit(successAction, query.id(), actorUserId, query.organizationId(), successMetadata); eventPublisher.publishEvent(new QueryExecutedEvent( @@ -491,6 +521,90 @@ private BytesCapCheck enforceBytesScannedCap(QueryRequestSnapshot query) { throw new BytesScannedCapExceededException(message, cap, estimated, check.outcome()); } + /** A budget that applies to this read, and whether the result must be capped to it. */ + private record BudgetGate(DataBudgetStatus status, boolean capped) { + } + + /** + * @return {@code null} when no budget applies (or the statement is not a SELECT); otherwise the + * standing and whether to cap the result to the remaining allowance + * @throws DataBudgetExhaustedException when an exhausted budget refuses this run + */ + private BudgetGate enforceDataBudget(QueryRequestSnapshot query, boolean breakGlass) { + if (query.queryType() != QueryType.SELECT) { + return null; + } + var status = dataBudgetStatusService.statusFor(query.datasourceId(), + query.submittedByUserId()); + if (status.isEmpty()) { + return null; + } + if (breakGlass) { + return new BudgetGate(status, false); + } + if (!status.exhausted()) { + return new BudgetGate(status, true); + } + if (status.breachAction() == DataBudgetBreachAction.REQUIRE_REVIEW + && budgetForcedReview(query)) { + return new BudgetGate(status, false); + } + var deciding = status.decidingBudget(); + var metadata = new HashMap(); + metadata.put("trigger", "data_budget"); + metadata.put("stage", "execution"); + metadata.put("action", status.breachAction().name()); + metadata.put("data_budget_id", deciding.budgetId()); + metadata.put("used_rows", deciding.usedRows()); + metadata.put("used_bytes", deciding.usedBytes()); + metadata.put("window_minutes", deciding.windowMinutes()); + recordAudit(AuditAction.QUERY_DATA_BUDGET_ENFORCED, query.id(), null, + query.organizationId(), metadata); + throw new DataBudgetExhaustedException(messageSource.getMessage( + "error.data_budget.exhausted", new Object[]{deciding.name()}, + LocaleContextHolder.getLocale()), deciding); + } + + /** + * The exhausted budget itself sent this query — or, for a recurring occurrence, its series — to + * review, so its approval (by a reviewer or a synced ticket) was given knowing the budget was + * spent. An approval obtained while allowance remained never lifts the budget. + */ + private boolean budgetForcedReview(QueryRequestSnapshot query) { + return queryRequestStateService.isDataBudgetReviewForced(query.id()) + || (query.recurringParentId() != null + && queryRequestStateService.isDataBudgetReviewForced( + query.recurringParentId())); + } + + /** + * The row allowance was the binding cap when the result stopped exactly at it and it sits below + * every other row cap we know of; the global ceiling is covered by the exact-count test. + */ + static SelectExecutionResult attributeBudgetTruncation(SelectExecutionResult select, + Long budgetRows, Integer otherRowCap, + Integer datasourceRowCap) { + if (!select.truncated() || budgetRows == null + || !SelectExecutionResult.TRUNCATED_ROW_LIMIT.equals(select.truncatedReason()) + || select.rowCount() != budgetRows + || (otherRowCap != null && otherRowCap <= budgetRows) + || (datasourceRowCap != null && datasourceRowCap <= budgetRows)) { + return select; + } + return select.withTruncatedReason(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + } + + /** Never fails the execution: the rows were already delivered. */ + private void chargeDataBudget(QueryRequestSnapshot query, SelectExecutionResult select) { + try { + dataBudgetUsageService.record(new DataBudgetUsageRecord(query.submittedByUserId(), + query.datasourceId(), select.rowCount(), select.resultBytes(), + DataBudgetUsageSource.QUERY, query.id(), null)); + } catch (RuntimeException ex) { + log.error("Data-budget usage write failed for query {}", query.id(), ex); + } + } + private ExecutionOutcome recordFailure(QueryRequestSnapshot query, UUID actorUserId, String trigger, Instant startedAt, RuntimeException ex) { var completedAt = Instant.now(); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecision.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecision.java index afbd0d197..84ea6a5fa 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecision.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecision.java @@ -28,12 +28,27 @@ * @param bytesCap the bytes-scanned cap that applied (#941), or {@code null} when none * @param bytesCapChangedOutcome whether the cap refused the query or turned an automatic approval * into human review — the condition for its audit row + * @param dataBudget the submitter's data-budget standing (#942), or {@code null} when no + * budget applies + * @param dataBudgetChangedOutcome whether the exhausted budget refused the query or turned an + * automatic approval into human review — the condition for its audit row */ record QueryDecision(QueryDecisionKind kind, QueryStatus nextStatus, RoutingMatch routingMatch, Integer effectiveApprovals, UUID grantId, String grantApproverEmail, ConditionContext context, DecisionTrace trace, SqlReviewSuppression sqlReviewSuppression, BytesCapCheck bytesCap, - boolean bytesCapChangedOutcome) { + boolean bytesCapChangedOutcome, DataBudgetCheck dataBudget, + boolean dataBudgetChangedOutcome) { + + /** A decision no data budget took part in. */ + QueryDecision(QueryDecisionKind kind, QueryStatus nextStatus, RoutingMatch routingMatch, + Integer effectiveApprovals, UUID grantId, String grantApproverEmail, + ConditionContext context, DecisionTrace trace, + SqlReviewSuppression sqlReviewSuppression, BytesCapCheck bytesCap, + boolean bytesCapChangedOutcome) { + this(kind, nextStatus, routingMatch, effectiveApprovals, grantId, grantApproverEmail, context, + trace, sqlReviewSuppression, bytesCap, bytesCapChangedOutcome, null, false); + } /** A decision no bytes-scanned cap took part in. */ QueryDecision(QueryDecisionKind kind, QueryStatus nextStatus, RoutingMatch routingMatch, diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluator.java index bdfd66c54..1ef28eaec 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluator.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluator.java @@ -75,25 +75,39 @@ QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLeve return evaluate(query, aiOutcome, riskLevel, riskScore, blockingRuleIds, null, clock); } + /** {@link #evaluate(QueryRequestSnapshot, AiOutcome, RiskLevel, int, List, BytesCapCheck, DataBudgetCheck, Clock)} with no data budget. */ + QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLevel riskLevel, + int riskScore, List blockingRuleIds, BytesCapCheck bytesCap, + Clock clock) { + return evaluate(query, aiOutcome, riskLevel, riskScore, blockingRuleIds, bytesCap, null, + clock); + } + /** * @param riskScore the AI's numeric score, or {@code -1} when there is none — the same * "absent" sentinel the live completion event uses * @param blockingRuleIds the distinct SQL review rule ids that fired at {@code BLOCK} for this * request, empty when none did (#864) * @param bytesCap the bytes-scanned cap that applies (#941), {@code null} when none does + * @param dataBudget the submitter's data-budget standing (#942), {@code null} when no budget + * applies or the statement is not a SELECT */ QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLevel riskLevel, int riskScore, List blockingRuleIds, BytesCapCheck bytesCap, - Clock clock) { + DataBudgetCheck dataBudget, Clock clock) { var block = blockingRuleIds == null ? List.of() : List.copyOf(blockingRuleIds); - var guard = new Guard(block, bytesCap); + var guard = new Guard(block, bytesCap, dataBudget); // A hard cap is a refusal, not a routing signal: it decides before routing and before the // AI-failure fallback, so no policy or plan can approve a query the cap has refused. if (bytesCap != null && bytesCap.rejects()) { - return bytesCapRejected(block, bytesCap); + return bytesCapRejected(block, bytesCap, dataBudget); + } + // An exhausted budget under REJECT is the same kind of refusal, decided just after the cap. + if (dataBudget != null && dataBudget.rejects()) { + return dataBudgetRejected(block, bytesCap, dataBudget); } if (aiOutcome == AiOutcome.FAILED) { - return aiFailed(block, bytesCap); + return aiFailed(block, bytesCap, dataBudget); } // Only a COMPLETED analysis carries a risk signal. Normalising here rather than trusting the // caller keeps the SKIPPED branch identical to production, where the listener passes no risk @@ -106,6 +120,7 @@ QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLeve var steps = new ArrayList(5); steps.add(sqlReviewStep(block)); steps.add(bytesCapStep(bytesCap)); + steps.add(dataBudgetStep(dataBudget)); var match = routingPolicyEngine.evaluate(query.organizationId(), query.datasourceId(), context).orElse(null); @@ -122,7 +137,8 @@ QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLeve "workflow.decision.plan.skipped_grant_covered", planDetails(plan))); return new QueryDecision(QueryDecisionKind.GRANT_FAST_PATH, QueryStatus.APPROVED, null, null, grant.id(), grant.approverEmail(), context, - new DecisionTrace(steps, QueryStatus.APPROVED), null, bytesCap, false); + new DecisionTrace(steps, QueryStatus.APPROVED), null, bytesCap, false, + dataBudget, false); } return planned(query, plan, effectiveRisk, context, steps, guard, suppressed); @@ -130,24 +146,81 @@ QueryDecision evaluate(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLeve /** * What may turn an automatic approval into human review: a {@code BLOCK} SQL review finding - * (#864) and a bytes-scanned cap with no estimate to compare against (#941). The trace names - * the SQL review first when both apply. + * (#864), a bytes-scanned cap with no estimate to compare against (#941), and an exhausted data + * budget under {@code REQUIRE_REVIEW} (#942). The trace names them in that order of precedence. */ - private record Guard(List block, BytesCapCheck bytesCap) { + private record Guard(List block, BytesCapCheck bytesCap, DataBudgetCheck dataBudget) { boolean suppresses() { - return !block.isEmpty() || (bytesCap != null && bytesCap.forcesReview()); + return !block.isEmpty() || capForcesReview() || budgetForcesReview(); } boolean capForcesReview() { return bytesCap != null && bytesCap.forcesReview(); } + boolean budgetForcesReview() { + return dataBudget != null && dataBudget.forcesReview(); + } + String reasonSuffix() { - return block.isEmpty() ? "bytes_cap" : "sql_review"; + if (!block.isEmpty()) { + return "sql_review"; + } + return capForcesReview() ? "bytes_cap" : "data_budget"; } } + /** The budget step is always present so the trace keeps one entry per stage. */ + private static DecisionTraceStep dataBudgetStep(DataBudgetCheck budget) { + if (budget == null) { + return DecisionTraceStep.of(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.NO_MATCH, + "workflow.decision.data_budget.none"); + } + var percent = String.valueOf((long) Math.floor(budget.usedPercent())); + var details = new LinkedHashMap(); + details.put("data_budget_used_percent", Math.floor(budget.usedPercent())); + details.put("data_budget_remaining_rows", budget.remainingRows()); + details.put("data_budget_remaining_bytes", budget.remainingBytes()); + details.put("data_budget_action", budget.action() == null ? null : budget.action().name()); + if (budget.deciding() != null) { + details.put("data_budget_id", budget.deciding().budgetId()); + details.put("data_budget_name", budget.deciding().name()); + } + if (budget.rejects()) { + return new DecisionTraceStep(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.DENY, + "workflow.decision.data_budget.exhausted_rejected", + List.of(String.valueOf(budget.deciding().name())), details); + } + if (budget.forcesReview()) { + return new DecisionTraceStep(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.MATCH, + "workflow.decision.data_budget.exhausted_review", + List.of(String.valueOf(budget.deciding().name())), details); + } + return new DecisionTraceStep(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.ALLOW, + "workflow.decision.data_budget.within", List.of(percent), details); + } + + /** + * An exhausted budget refused the query (#942). Like the cap, nothing downstream runs. + */ + private static QueryDecision dataBudgetRejected(List block, BytesCapCheck cap, + DataBudgetCheck budget) { + var steps = List.of( + sqlReviewStep(block), + bytesCapStep(cap), + dataBudgetStep(budget), + DecisionTraceStep.of(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.SKIP, + "workflow.decision.routing.skipped_data_budget"), + DecisionTraceStep.of(QueryDecisionStepKind.GRANT_FAST_PATH, StepOutcome.SKIP, + "workflow.decision.grant.skipped_data_budget"), + DecisionTraceStep.of(QueryDecisionStepKind.REVIEW_PLAN, StepOutcome.SKIP, + "workflow.decision.plan.skipped_data_budget")); + return new QueryDecision(QueryDecisionKind.DATA_BUDGET_REJECTED, QueryStatus.REJECTED, null, + null, null, null, null, new DecisionTrace(steps, QueryStatus.REJECTED), null, cap, + false, budget, true); + } + /** The cap step is always present so the trace keeps one entry per stage. */ private static DecisionTraceStep bytesCapStep(BytesCapCheck cap) { if (cap == null) { @@ -184,10 +257,12 @@ private static DecisionTraceStep bytesCapStep(BytesCapCheck cap) { * The cap refused the query (#941). Nothing downstream runs — there is no routing decision to * record and no plan to consult — which is why the context is not even built. */ - private static QueryDecision bytesCapRejected(List block, BytesCapCheck cap) { + private static QueryDecision bytesCapRejected(List block, BytesCapCheck cap, + DataBudgetCheck budget) { var steps = List.of( sqlReviewStep(block), bytesCapStep(cap), + dataBudgetStep(budget), DecisionTraceStep.of(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.SKIP, "workflow.decision.routing.skipped_bytes_cap"), DecisionTraceStep.of(QueryDecisionStepKind.GRANT_FAST_PATH, StepOutcome.SKIP, @@ -196,7 +271,7 @@ private static QueryDecision bytesCapRejected(List block, BytesCapCheck "workflow.decision.plan.skipped_bytes_cap")); return new QueryDecision(QueryDecisionKind.BYTES_CAP_REJECTED, QueryStatus.REJECTED, null, null, null, null, null, new DecisionTrace(steps, QueryStatus.REJECTED), null, cap, - true); + true, budget, false); } /** @@ -227,10 +302,12 @@ private static SqlReviewSuppression suppression(List block, * auto-decision signal — and neither does the grant fast path or the review plan. Decided before * any lookup, mirroring the live listener, which builds no context at all. */ - private static QueryDecision aiFailed(List block, BytesCapCheck bytesCap) { + private static QueryDecision aiFailed(List block, BytesCapCheck bytesCap, + DataBudgetCheck budget) { var steps = List.of( sqlReviewStep(block), bytesCapStep(bytesCap), + dataBudgetStep(budget), DecisionTraceStep.of(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.SKIP, "workflow.decision.routing.skipped_ai_failed"), DecisionTraceStep.of(QueryDecisionStepKind.GRANT_FAST_PATH, StepOutcome.SKIP, @@ -239,7 +316,8 @@ private static QueryDecision aiFailed(List block, BytesCapCheck bytesCap "workflow.decision.plan.skipped_ai_failed")); return new QueryDecision(QueryDecisionKind.AI_FAILED_PENDING_REVIEW, QueryStatus.PENDING_REVIEW, null, null, null, null, null, - new DecisionTrace(steps, QueryStatus.PENDING_REVIEW), null, bytesCap, false); + new DecisionTrace(steps, QueryStatus.PENDING_REVIEW), null, bytesCap, false, + budget, false); } /** @@ -276,10 +354,12 @@ private QueryDecision routed(RoutingMatch match, ReviewPlanSnapshot plan, details.put("effective_min_approvals", effective); details.put("sql_review_suppressed", suppressedApprove && !block.isEmpty()); details.put("bytes_cap_suppressed", suppressedApprove && guard.capForcesReview()); + details.put("data_budget_suppressed", suppressedApprove && guard.budgetForcesReview()); steps.add(suppressedApprove ? new DecisionTraceStep(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.MATCH, block.isEmpty() - ? "workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap" + ? "workflow.decision.routing.matched_auto_approve_suppressed_" + + guard.reasonSuffix() : "workflow.decision.routing.matched_auto_approve_suppressed", List.of(String.valueOf(match.policyName())), details) : new DecisionTraceStep(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.MATCH, @@ -294,7 +374,8 @@ private QueryDecision routed(RoutingMatch match, ReviewPlanSnapshot plan, suppressedApprove && !block.isEmpty() ? new SqlReviewSuppression(block, List.of(SuppressedAutoApproval.ROUTING_AUTO_APPROVE)) : null, - guard.bytesCap(), suppressedApprove && guard.capForcesReview()); + guard.bytesCap(), suppressedApprove && guard.capForcesReview(), + guard.dataBudget(), suppressedApprove && guard.budgetForcesReview()); } /** @@ -406,6 +487,7 @@ private QueryDecision planned(QueryRequestSnapshot query, ReviewPlanSnapshot pla boolean planSuppressed = suppressed.contains(SuppressedAutoApproval.REVIEW_PLAN); details.put("sql_review_suppressed", planSuppressed && !block.isEmpty()); details.put("bytes_cap_suppressed", planSuppressed && guard.capForcesReview()); + details.put("data_budget_suppressed", planSuppressed && guard.budgetForcesReview()); steps.add(DecisionTraceStep.of(QueryDecisionStepKind.REVIEW_PLAN, nextStatus == QueryStatus.APPROVED ? StepOutcome.ALLOW : StepOutcome.DENY, reasonKey, details)); @@ -414,7 +496,8 @@ private QueryDecision planned(QueryRequestSnapshot query, ReviewPlanSnapshot pla : QueryDecisionKind.PLAN_PENDING_REVIEW; return new QueryDecision(kind, nextStatus, null, null, null, null, context, new DecisionTrace(steps, nextStatus), suppression(block, suppressed), - guard.bytesCap(), guard.capForcesReview() && !suppressed.isEmpty()); + guard.bytesCap(), guard.capForcesReview() && !suppressed.isEmpty(), + guard.dataBudget(), guard.budgetForcesReview() && !suppressed.isEmpty()); } private static LinkedHashMap consideredGrants(List grants) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionKind.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionKind.java index e79fd50d1..22770dbc9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionKind.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionKind.java @@ -36,5 +36,11 @@ enum QueryDecisionKind { * The bytes-scanned cap (#941) refused the query: its estimate exceeds the cap, or it has none * and the datasource rejects on a missing estimate. Decided before routing and AI failure. */ - BYTES_CAP_REJECTED + BYTES_CAP_REJECTED, + + /** + * The submitter's data budget (#942) is exhausted and its breach action is {@code REJECT}. + * Decided right after the bytes-scanned cap, before routing and AI failure. + */ + DATA_BUDGET_REJECTED } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachine.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachine.java index 76d68ce68..59899f726 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachine.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachine.java @@ -7,6 +7,8 @@ import com.bablsoft.accessflow.core.api.ByteSizeFormat; import com.bablsoft.accessflow.core.api.BytesScannedCapResolutionService; import com.bablsoft.accessflow.core.api.AppliedBytesCap; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QueryEstimateLookupService; import com.bablsoft.accessflow.core.api.QueryEstimateSnapshot; import com.bablsoft.accessflow.core.api.QueryRequestLookupService; @@ -92,6 +94,7 @@ class QueryReviewStateMachine { private final QueryCostEstimateService queryCostEstimateService; private final QueryEstimateLookupService queryEstimateLookupService; private final PlatformTransactionManager transactionManager; + private final DataBudgetStatusService dataBudgetStatusService; // Time-of-day / day-of-week routing conditions evaluate in the server's local zone. A field // (not an injected bean) so it can be overridden in tests without colliding with the proxy's @@ -180,8 +183,15 @@ private AppliedBytesCap resolveQuietly(UUID queryRequestId) { private void decide(QueryRequestSnapshot query, AiOutcome aiOutcome, RiskLevel riskLevel, int riskScore, AppliedBytesCap cap) { var bytesCap = cap == null ? null : BytesCapCheck.of(cap, estimatedBytes(query)); + var budget = dataBudget(query); var decision = queryDecisionEvaluator.evaluate(query, aiOutcome, riskLevel, riskScore, - blockingRuleIds(query), bytesCap, clock); + blockingRuleIds(query), bytesCap, budget, clock); + // #942: the reviewer is deciding on a submitter whose budget is used up — only an approval + // given here may later run the query past the exhausted budget. + if (budget != null && budget.forcesReview() + && decision.nextStatus() == QueryStatus.PENDING_REVIEW) { + queryRequestStateService.recordDataBudgetReviewForced(query.id()); + } if (bytesCap != null) { queryRequestStateService.recordBytesScannedCap(query.id(), bytesCap.limit(), bytesCap.source(), bytesCap.outcome()); @@ -197,6 +207,15 @@ private Long estimatedBytes(QueryRequestSnapshot query) { .orElse(null); } + /** The submitter's data-budget standing (#942); reads are the only thing a budget bounds. */ + private DataBudgetCheck dataBudget(QueryRequestSnapshot query) { + if (query.queryType() != QueryType.SELECT) { + return null; + } + return DataBudgetCheck.of(dataBudgetStatusService.statusFor(query.datasourceId(), + query.submittedByUserId())); + } + private List blockingRuleIds(QueryRequestSnapshot query) { return sqlReviewFindingService.blockingRuleIds(query.id()); } @@ -266,6 +285,12 @@ private void apply(QueryRequestSnapshot query, QueryDecision decision) { eventPublisher.publishEvent(new QueryAutoRejectedEvent(query.id(), null, bytesCapReason(decision.bytesCap()))); } + case DATA_BUDGET_REJECTED -> { + queryRequestStateService.transitionTo(query.id(), QueryStatus.PENDING_AI, + QueryStatus.REJECTED); + eventPublisher.publishEvent(new QueryAutoRejectedEvent(query.id(), null, + dataBudgetReason(decision.dataBudget()))); + } // A switch STATEMENT over an enum is not exhaustiveness-checked, so a new kind would // otherwise fall through silently and strand the query in PENDING_AI forever. default -> throw new IllegalStateException("Unhandled decision kind " + decision.kind()); @@ -276,6 +301,9 @@ private void apply(QueryRequestSnapshot query, QueryDecision decision) { if (decision.bytesCapChangedOutcome()) { auditBytesCapEnforced(query, decision); } + if (decision.dataBudgetChangedOutcome()) { + auditDataBudgetEnforced(query, decision); + } // Logged after the fact: a line claiming a query was auto-approved must not outlive a // persistence call that then failed. if (match != null) { @@ -344,6 +372,42 @@ private void auditBytesCapEnforced(QueryRequestSnapshot query, QueryDecision dec } } + /** + * One row per query whose outcome an exhausted data budget changed (#942) — a refusal, or an + * automatic approval turned into review. Swallow-and-log, like the rows above. + */ + private void auditDataBudgetEnforced(QueryRequestSnapshot query, QueryDecision decision) { + var budget = decision.dataBudget(); + var metadata = new LinkedHashMap(); + metadata.put("trigger", "data_budget"); + metadata.put("stage", "decision"); + metadata.put("action", budget.action().name()); + if (budget.deciding() != null) { + metadata.put("data_budget_id", budget.deciding().budgetId()); + metadata.put("used_rows", budget.deciding().usedRows()); + metadata.put("used_bytes", budget.deciding().usedBytes()); + metadata.put("window_minutes", budget.deciding().windowMinutes()); + } + if (decision.routingMatch() != null) { + metadata.put("matched_policy_id", decision.routingMatch().policyId()); + } + try { + auditLogService.record(new AuditEntry(AuditAction.QUERY_DATA_BUDGET_ENFORCED, + AuditResourceType.QUERY_REQUEST, query.id(), query.organizationId(), null, + metadata, null, null)); + } catch (RuntimeException ex) { + log.error("Audit write failed for QUERY_DATA_BUDGET_ENFORCED on query {}", query.id(), + ex); + } + } + + /** Server-default locale for the same reason as {@link #grantReason}. */ + private String dataBudgetReason(DataBudgetCheck budget) { + var name = budget.deciding() == null ? "-" : budget.deciding().name(); + return messageSource.getMessage("workflow.data_budget.rejected", new Object[]{name}, + Locale.getDefault()); + } + /** Server-default locale for the same reason as {@link #grantReason}. */ private String bytesCapReason(BytesCapCheck cap) { var limit = ByteSizeFormat.format(cap.limit()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java index f631a013c..6abab1843 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java @@ -1,10 +1,12 @@ package com.bablsoft.accessflow.workflow.internal.routing; import com.bablsoft.accessflow.ai.api.BehaviorAnomalyLookupService; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; import com.bablsoft.accessflow.core.api.QueryCorpusRow; import com.bablsoft.accessflow.core.api.QueryEstimateLookupService; import com.bablsoft.accessflow.core.api.QueryRequestLookupService; import com.bablsoft.accessflow.core.api.QueryShape; +import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.core.api.UserGroupService; @@ -50,6 +52,7 @@ public class ConditionContextFactory { private final UserGroupService userGroupService; private final BehaviorAnomalyLookupService behaviorAnomalyLookupService; private final QueryEstimateLookupService queryEstimateLookupService; + private final DataBudgetStatusService dataBudgetStatusService; /** The live routing path: signals as they stand right now, which is also when routing runs. */ public ConditionContext forLiveQuery(QueryRequestSnapshot query, RiskLevel riskLevel, @@ -65,7 +68,17 @@ public ConditionContext forLiveQuery(QueryRequestSnapshot query, RiskLevel riskL behaviorAnomalyLookupService.hasActiveAnomaly(query.organizationId(), query.submittedByUserId(), query.datasourceId()), estimate.rows(), estimate.scanType(), parsed.shapes(), parsed.shapesAnalyzed(), - estimate.bytesScanned()); + estimate.bytesScanned(), dataBudgetUsedPercent(query)); + } + + /** Whole percentage of the submitter's most-used data budget (#942); SELECTs only. */ + private Integer dataBudgetUsedPercent(QueryRequestSnapshot query) { + if (query.queryType() != QueryType.SELECT) { + return null; + } + var used = dataBudgetStatusService.statusFor(query.datasourceId(), query.submittedByUserId()) + .usedPercent(); + return used == null ? null : (int) Math.min(Integer.MAX_VALUE, Math.floor(used)); } /** diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java index 0befada1b..ea30e7bc1 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java @@ -35,6 +35,7 @@ @JsonSubTypes.Type(value = ConditionNode.AnomalyDetected.class, name = "anomaly_detected"), @JsonSubTypes.Type(value = ConditionNode.EstimatedRows.class, name = "estimated_rows"), @JsonSubTypes.Type(value = ConditionNode.EstimatedBytesScanned.class, name = "estimated_bytes_scanned"), + @JsonSubTypes.Type(value = ConditionNode.DataBudgetUsedPercent.class, name = "data_budget_used_percent"), @JsonSubTypes.Type(value = ConditionNode.ScanTypeMatches.class, name = "scan_type") }) interface ConditionNodeMixin { diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java index 69eb09520..558ce2613 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java @@ -55,6 +55,8 @@ public boolean matches(ConditionNode node, ConditionContext ctx) { ctx.hasEstimateSignal() && c.operator().test(ctx.estimatedRows(), c.value()); case ConditionNode.EstimatedBytesScanned c -> ctx.estimatedBytesScanned() != null && c.operator().test(ctx.estimatedBytesScanned(), c.value()); + case ConditionNode.DataBudgetUsedPercent c -> ctx.dataBudgetUsedPercent() != null + && c.operator().test(ctx.dataBudgetUsedPercent(), c.value()); case ConditionNode.ScanTypeMatches c -> matchesScanType(c, ctx); }; } diff --git a/backend/src/main/resources/db/migration/V193__create_data_budgets.sql b/backend/src/main/resources/db/migration/V193__create_data_budgets.sql new file mode 100644 index 000000000..7e7bccd4c --- /dev/null +++ b/backend/src/main/resources/db/migration/V193__create_data_budgets.sql @@ -0,0 +1,57 @@ +-- #942: per-user data-volume budgets. A data_budget bounds how many result rows and/or result bytes +-- EACH targeted user may read from one datasource over a rolling window (window_minutes, trailing +-- from now — no reset job, no timezone). Scoping mirrors row_limit_policy: all three applies_to_* +-- arrays empty ⇒ every user of the datasource; a group target gives every member their own +-- allowance, never a shared pool. When several budgets match, each is evaluated on its own window +-- and the most constrained one wins; REJECT beats REQUIRE_REVIEW once exhausted. + +CREATE TYPE data_budget_breach_action AS ENUM ('REJECT', 'REQUIRE_REVIEW'); +CREATE TYPE data_budget_usage_source AS ENUM ('QUERY', 'REQUEST_GROUP', 'SAMPLE_DATA'); + +CREATE TABLE data_budgets ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id), + datasource_id UUID NOT NULL REFERENCES datasources(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + max_rows BIGINT CHECK (max_rows > 0), + max_bytes BIGINT CHECK (max_bytes > 0), + window_minutes INTEGER NOT NULL DEFAULT 1440 + CHECK (window_minutes BETWEEN 60 AND 44640), + breach_action data_budget_breach_action NOT NULL DEFAULT 'REQUIRE_REVIEW', + warn_threshold_percent SMALLINT CHECK (warn_threshold_percent BETWEEN 1 AND 99), + applies_to_roles TEXT[], + applies_to_group_ids UUID[], + applies_to_user_ids UUID[], + enabled BOOLEAN NOT NULL DEFAULT true, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_data_budgets_has_limit CHECK (max_rows IS NOT NULL OR max_bytes IS NOT NULL) +); + +CREATE INDEX idx_data_budgets_ds_enabled ON data_budgets (organization_id, datasource_id, enabled); + +-- Append-only usage ledger: one row per delivered SELECT result (query execution, request-group +-- member, sample-data read). rows_read / bytes_read are what the user actually received — after +-- row-security filtering and every cap. Pruned by DataBudgetUsagePruneJob past the longest window. +-- query_request_id / request_group_id are bare provenance UUIDs (no FK) so a later erasure or +-- retention delete of the source never blocks on the ledger. +CREATE TABLE data_budget_usage ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + datasource_id UUID NOT NULL, + rows_read BIGINT NOT NULL CHECK (rows_read >= 0), + bytes_read BIGINT NOT NULL CHECK (bytes_read >= 0), + source data_budget_usage_source NOT NULL, + query_request_id UUID, + request_group_id UUID, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Backs the trailing-window SUM (user, datasource, occurred_at >= now - window): an index range +-- scan bounded by one user's reads on one datasource inside the window. +CREATE INDEX idx_data_budget_usage_user_ds_time + ON data_budget_usage (user_id, datasource_id, occurred_at); +-- Backs the prune job's range delete. +CREATE INDEX idx_data_budget_usage_occurred_at ON data_budget_usage (occurred_at); diff --git a/backend/src/main/resources/db/migration/V194__add_data_budget_manage_permission.sql b/backend/src/main/resources/db/migration/V194__add_data_budget_manage_permission.sql new file mode 100644 index 000000000..8503ee826 --- /dev/null +++ b/backend/src/main/resources/db/migration/V194__add_data_budget_manage_permission.sql @@ -0,0 +1,7 @@ +-- #942: seeds the new DATA_BUDGET_MANAGE permission for the ADMIN system role, mirroring +-- core.api.SystemRolePermissions (ADMIN holds every catalog value via EnumSet.allOf). +-- role_permissions.permission is VARCHAR, so the new value itself needs no DDL. + +INSERT INTO role_permissions (role_id, permission) VALUES + ('c0000000-0000-0000-0000-000000000001', 'DATA_BUDGET_MANAGE') +ON CONFLICT (role_id, permission) DO NOTHING; diff --git a/backend/src/main/resources/db/migration/V195__add_query_data_budget_review_forced.sql b/backend/src/main/resources/db/migration/V195__add_query_data_budget_review_forced.sql new file mode 100644 index 000000000..288d507a0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V195__add_query_data_budget_review_forced.sql @@ -0,0 +1,5 @@ +-- #942: set when an exhausted REQUIRE_REVIEW data budget forced the query into human review as it +-- left PENDING_AI. Only a query carrying this stamp may run past an exhausted budget at execution: +-- an approval given while allowance remained was never a budget escalation, so it must not lift +-- the budget once the submitter has spent the rest of it on other reads. +ALTER TABLE query_requests ADD COLUMN data_budget_review_forced BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index f28bc607e..7b9eea6fc 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -1604,3 +1604,55 @@ workflow.decision.plan.skipped_bytes_cap=The review plan is not consulted when t workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=Routing policy "{0}" matched with action AUTO_APPROVE, suppressed because no bytes estimate is available under the bytes-scanned cap workflow.decision.grant.suppressed_bytes_cap=Covered by access grant {0}, suppressed because no bytes estimate is available under the bytes-scanned cap workflow.decision.plan.suppressed_bytes_cap=The review plan would have approved, suppressed because no bytes estimate is available under the bytes-scanned cap +# Data-volume budgets (#942) +error.data_budget.name_invalid=Budget name is required and must be at most 120 characters +error.data_budget.limit_required=A data budget needs a row limit, a byte limit, or both +error.data_budget.limit_invalid=Row and byte limits must be positive numbers +error.data_budget.window_invalid=The budget window must be between 1 hour and 31 days +error.data_budget.threshold_invalid=The warning threshold must be between 1 and 99 percent +error.data_budget.unknown_role=Unknown applies-to role: {0} +error.data_budget.user_not_in_org=One or more applies-to users do not belong to this organization +error.data_budget.group_not_in_org=One or more applies-to groups do not belong to this organization +error.data_budget.not_found=Data budget not found +error.data_budget.exhausted=Refused: your data budget "{0}" on this datasource is used up for the current window +validation.data_budget.name.required=Budget name is required +validation.data_budget.name.size=Budget name must be at most 120 characters +validation.data_budget.max_rows.min=Row limit must be at least 1 +validation.data_budget.max_bytes.min=Byte limit must be at least 1 +validation.data_budget.window.range=The budget window must be between 60 and 44,640 minutes (1 hour to 31 days) +validation.data_budget.threshold.range=The warning threshold must be between 1 and 99 percent +workflow.data_budget.rejected=Rejected: the submitter data budget "{0}" on this datasource is used up for the current window +workflow.decision.data_budget.none=No data budget applies +workflow.decision.data_budget.within={0}% of the data budget is used; the result is capped to the remaining allowance +workflow.decision.data_budget.exhausted_rejected=The data budget "{0}" is used up — the request is rejected +workflow.decision.data_budget.exhausted_review=The data budget "{0}" is used up — the request cannot auto-approve +workflow.decision.routing.skipped_data_budget=Routing policies are not evaluated when an exhausted data budget rejects the request +workflow.decision.grant.skipped_data_budget=The grant fast path is not evaluated when an exhausted data budget rejects the request +workflow.decision.plan.skipped_data_budget=The review plan is not consulted when an exhausted data budget rejects the request +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=Routing policy "{0}" matched with action AUTO_APPROVE, suppressed because the data budget is used up +workflow.decision.grant.suppressed_data_budget=Covered by access grant {0}, suppressed because the data budget is used up +workflow.decision.plan.suppressed_data_budget=The review plan would have approved, suppressed because the data budget is used up + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] Data budget "{0}" on {1} is {2}% used +notification.email.subject.data_budget_exhausted=[AccessFlow] Data budget "{0}" on {1} is used up +notification.email.data_budget_threshold_reached.title=Data budget warning +notification.email.data_budget_threshold_reached.heading=You are approaching your data budget +notification.email.data_budget_threshold_reached.body=Your reads on this datasource have crossed the warning threshold of your data budget. Once the budget is used up, further SELECT queries are rejected or sent to review until usage falls back under the limit. +notification.email.data_budget_exhausted.title=Data budget exhausted +notification.email.data_budget_exhausted.heading=A data budget has been used up +notification.email.data_budget_exhausted.body=The data budget below is used up for its current window. Further SELECT queries by this user on this datasource are handled per the breach action until usage falls back under the limit. +notification.email.data_budget.user_label=User: +notification.email.data_budget.budget_label=Budget: +notification.email.data_budget.used_label=Used: +notification.email.data_budget.rows_label=Rows: +notification.email.data_budget.bytes_label=Bytes: +notification.email.data_budget.window_label=Window: +notification.email.data_budget.threshold_label=Warning threshold: +notification.email.data_budget.breach_action_label=When used up: +notification.email.data_budget.breach_action.REJECT=Further reads are rejected +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Further reads go to human review +notification.email.data_budget.window.MINUTES={0,choice,1#1 minute|1<{0} minutes} +notification.email.data_budget.window.HOURS={0,choice,1#1 hour|1<{0} hours} +notification.email.data_budget.window.DAYS={0,choice,1#1 day|1<{0} days} +notification.email.data_budget.cta=Open the query editor diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 77c24973e..a33a9ad1f 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -1583,3 +1583,55 @@ workflow.decision.plan.skipped_bytes_cap=Der Prüfplan wird nicht herangezogen, workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=Routing-Richtlinie "{0}" hat mit Aktion AUTO_APPROVE gegriffen, unterdrückt, weil unter dem Limit für gescannte Bytes keine Byte-Schätzung vorliegt workflow.decision.grant.suppressed_bytes_cap=Durch Zugriffsgewährung {0} abgedeckt, unterdrückt, weil unter dem Limit für gescannte Bytes keine Byte-Schätzung vorliegt workflow.decision.plan.suppressed_bytes_cap=Der Prüfplan hätte genehmigt, unterdrückt, weil unter dem Limit für gescannte Bytes keine Byte-Schätzung vorliegt +# Data-volume budgets (#942) +error.data_budget.name_invalid=Der Budgetname ist erforderlich und darf höchstens 120 Zeichen lang sein +error.data_budget.limit_required=Ein Datenbudget benötigt ein Zeilenlimit, ein Byte-Limit oder beides +error.data_budget.limit_invalid=Zeilen- und Byte-Limits müssen positive Zahlen sein +error.data_budget.window_invalid=Das Budgetfenster muss zwischen 1 Stunde und 31 Tagen liegen +error.data_budget.threshold_invalid=Die Warnschwelle muss zwischen 1 und 99 Prozent liegen +error.data_budget.unknown_role=Unbekannte Anwenden-auf-Rolle: {0} +error.data_budget.user_not_in_org=Ein oder mehrere Anwenden-auf-Benutzer gehören nicht zu dieser Organisation +error.data_budget.group_not_in_org=Eine oder mehrere Anwenden-auf-Gruppen gehören nicht zu dieser Organisation +error.data_budget.not_found=Datenbudget nicht gefunden +error.data_budget.exhausted=Abgelehnt: Ihr Datenbudget „{0}“ für diese Datenquelle ist im aktuellen Zeitfenster aufgebraucht +validation.data_budget.name.required=Der Budgetname ist erforderlich +validation.data_budget.name.size=Der Budgetname darf höchstens 120 Zeichen lang sein +validation.data_budget.max_rows.min=Das Zeilenlimit muss mindestens 1 betragen +validation.data_budget.max_bytes.min=Das Byte-Limit muss mindestens 1 betragen +validation.data_budget.window.range=Das Budgetfenster muss zwischen 60 und 44.640 Minuten (1 Stunde bis 31 Tage) liegen +validation.data_budget.threshold.range=Die Warnschwelle muss zwischen 1 und 99 Prozent liegen +workflow.data_budget.rejected=Abgelehnt: Das Datenbudget „{0}“ des Einreichenden für diese Datenquelle ist im aktuellen Zeitfenster aufgebraucht +workflow.decision.data_budget.none=Es gilt kein Datenbudget +workflow.decision.data_budget.within={0} % des Datenbudgets sind verbraucht; das Ergebnis wird auf das verbleibende Kontingent begrenzt +workflow.decision.data_budget.exhausted_rejected=Das Datenbudget „{0}“ ist aufgebraucht — die Anfrage wird abgelehnt +workflow.decision.data_budget.exhausted_review=Das Datenbudget „{0}“ ist aufgebraucht — die Anfrage kann nicht automatisch genehmigt werden +workflow.decision.routing.skipped_data_budget=Routing-Richtlinien werden nicht ausgewertet, wenn ein aufgebrauchtes Datenbudget die Anfrage ablehnt +workflow.decision.grant.skipped_data_budget=Der Grant-Schnellpfad wird nicht ausgewertet, wenn ein aufgebrauchtes Datenbudget die Anfrage ablehnt +workflow.decision.plan.skipped_data_budget=Der Prüfplan wird nicht herangezogen, wenn ein aufgebrauchtes Datenbudget die Anfrage ablehnt +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=Routing-Richtlinie „{0}“ hat mit der Aktion AUTO_APPROVE gegriffen, unterdrückt, weil das Datenbudget aufgebraucht ist +workflow.decision.grant.suppressed_data_budget=Durch Zugriffsgewährung {0} abgedeckt, unterdrückt, weil das Datenbudget aufgebraucht ist +workflow.decision.plan.suppressed_data_budget=Der Prüfplan hätte genehmigt, unterdrückt, weil das Datenbudget aufgebraucht ist + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] Datenbudget „{0}“ auf {1} zu {2} % verbraucht +notification.email.subject.data_budget_exhausted=[AccessFlow] Datenbudget „{0}“ auf {1} ist aufgebraucht +notification.email.data_budget_threshold_reached.title=Datenbudget-Warnung +notification.email.data_budget_threshold_reached.heading=Sie nähern sich Ihrem Datenbudget +notification.email.data_budget_threshold_reached.body=Ihre Lesezugriffe auf diese Datenquelle haben die Warnschwelle Ihres Datenbudgets überschritten. Ist das Budget aufgebraucht, werden weitere SELECT-Abfragen abgelehnt oder zur Prüfung geleitet, bis die Nutzung wieder unter das Limit fällt. +notification.email.data_budget_exhausted.title=Datenbudget aufgebraucht +notification.email.data_budget_exhausted.heading=Ein Datenbudget wurde aufgebraucht +notification.email.data_budget_exhausted.body=Das folgende Datenbudget ist für das aktuelle Zeitfenster aufgebraucht. Weitere SELECT-Abfragen dieses Benutzers auf dieser Datenquelle werden gemäß der Überschreitungsaktion behandelt, bis die Nutzung wieder unter das Limit fällt. +notification.email.data_budget.user_label=Benutzer: +notification.email.data_budget.budget_label=Budget: +notification.email.data_budget.used_label=Verbraucht: +notification.email.data_budget.rows_label=Zeilen: +notification.email.data_budget.bytes_label=Bytes: +notification.email.data_budget.window_label=Zeitfenster: +notification.email.data_budget.threshold_label=Warnschwelle: +notification.email.data_budget.breach_action_label=Bei Erschöpfung: +notification.email.data_budget.breach_action.REJECT=Weitere Lesezugriffe werden abgelehnt +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Weitere Lesezugriffe gehen zur manuellen Prüfung +notification.email.data_budget.window.MINUTES={0,choice,1#1 Minute|1<{0} Minuten} +notification.email.data_budget.window.HOURS={0,choice,1#1 Stunde|1<{0} Stunden} +notification.email.data_budget.window.DAYS={0,choice,1#1 Tag|1<{0} Tage} +notification.email.data_budget.cta=Abfrage-Editor öffnen diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index de158e904..af22ea23e 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -1583,3 +1583,55 @@ workflow.decision.plan.skipped_bytes_cap=El plan de revisión no se consulta cua workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=La política de enrutamiento "{0}" coincidió con la acción AUTO_APPROVE, suprimida porque no hay estimación de bytes bajo el límite de bytes escaneados workflow.decision.grant.suppressed_bytes_cap=Cubierta por la concesión de acceso {0}, suprimida porque no hay estimación de bytes bajo el límite de bytes escaneados workflow.decision.plan.suppressed_bytes_cap=El plan de revisión habría aprobado, suprimido porque no hay estimación de bytes bajo el límite de bytes escaneados +# Data-volume budgets (#942) +error.data_budget.name_invalid=El nombre del presupuesto es obligatorio y debe tener como máximo 120 caracteres +error.data_budget.limit_required=Un presupuesto de datos necesita un límite de filas, un límite de bytes o ambos +error.data_budget.limit_invalid=Los límites de filas y bytes deben ser números positivos +error.data_budget.window_invalid=La ventana del presupuesto debe estar entre 1 hora y 31 días +error.data_budget.threshold_invalid=El umbral de aviso debe estar entre 1 y 99 por ciento +error.data_budget.unknown_role=Rol de aplicación desconocido: {0} +error.data_budget.user_not_in_org=Uno o más usuarios de aplicación no pertenecen a esta organización +error.data_budget.group_not_in_org=Uno o más grupos de aplicación no pertenecen a esta organización +error.data_budget.not_found=Presupuesto de datos no encontrado +error.data_budget.exhausted=Rechazado: tu presupuesto de datos «{0}» en esta fuente de datos está agotado en la ventana actual +validation.data_budget.name.required=El nombre del presupuesto es obligatorio +validation.data_budget.name.size=El nombre del presupuesto debe tener como máximo 120 caracteres +validation.data_budget.max_rows.min=El límite de filas debe ser al menos 1 +validation.data_budget.max_bytes.min=El límite de bytes debe ser al menos 1 +validation.data_budget.window.range=La ventana del presupuesto debe estar entre 60 y 44.640 minutos (de 1 hora a 31 días) +validation.data_budget.threshold.range=El umbral de aviso debe estar entre 1 y 99 por ciento +workflow.data_budget.rejected=Rechazada: el presupuesto de datos «{0}» del solicitante en esta fuente de datos está agotado en la ventana actual +workflow.decision.data_budget.none=No se aplica ningún presupuesto de datos +workflow.decision.data_budget.within=Se ha usado el {0} % del presupuesto de datos; el resultado se limita a la cuota restante +workflow.decision.data_budget.exhausted_rejected=El presupuesto de datos «{0}» está agotado: la solicitud se rechaza +workflow.decision.data_budget.exhausted_review=El presupuesto de datos «{0}» está agotado: la solicitud no puede aprobarse automáticamente +workflow.decision.routing.skipped_data_budget=Las políticas de enrutamiento no se evalúan cuando un presupuesto de datos agotado rechaza la solicitud +workflow.decision.grant.skipped_data_budget=La vía rápida por concesión no se evalúa cuando un presupuesto de datos agotado rechaza la solicitud +workflow.decision.plan.skipped_data_budget=El plan de revisión no se consulta cuando un presupuesto de datos agotado rechaza la solicitud +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=La política de enrutamiento «{0}» coincidió con la acción AUTO_APPROVE, suprimida porque el presupuesto de datos está agotado +workflow.decision.grant.suppressed_data_budget=Cubierta por la concesión de acceso {0}, suprimida porque el presupuesto de datos está agotado +workflow.decision.plan.suppressed_data_budget=El plan de revisión habría aprobado, suprimido porque el presupuesto de datos está agotado + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] Presupuesto de datos «{0}» en {1} al {2} % de uso +notification.email.subject.data_budget_exhausted=[AccessFlow] Presupuesto de datos «{0}» en {1} agotado +notification.email.data_budget_threshold_reached.title=Aviso de presupuesto de datos +notification.email.data_budget_threshold_reached.heading=Se está acercando a su presupuesto de datos +notification.email.data_budget_threshold_reached.body=Sus lecturas en esta fuente de datos han superado el umbral de aviso de su presupuesto de datos. Cuando el presupuesto se agote, las siguientes consultas SELECT se rechazarán o se enviarán a revisión hasta que el uso vuelva a estar por debajo del límite. +notification.email.data_budget_exhausted.title=Presupuesto de datos agotado +notification.email.data_budget_exhausted.heading=Se ha agotado un presupuesto de datos +notification.email.data_budget_exhausted.body=El siguiente presupuesto de datos está agotado para su ventana actual. Las siguientes consultas SELECT de este usuario en esta fuente de datos se tratan según la acción de incumplimiento hasta que el uso vuelva a estar por debajo del límite. +notification.email.data_budget.user_label=Usuario: +notification.email.data_budget.budget_label=Presupuesto: +notification.email.data_budget.used_label=Usado: +notification.email.data_budget.rows_label=Filas: +notification.email.data_budget.bytes_label=Bytes: +notification.email.data_budget.window_label=Ventana: +notification.email.data_budget.threshold_label=Umbral de aviso: +notification.email.data_budget.breach_action_label=Al agotarse: +notification.email.data_budget.breach_action.REJECT=Las siguientes lecturas se rechazan +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Las siguientes lecturas pasan a revisión manual +notification.email.data_budget.window.MINUTES={0,choice,1#1 minuto|1<{0} minutos} +notification.email.data_budget.window.HOURS={0,choice,1#1 hora|1<{0} horas} +notification.email.data_budget.window.DAYS={0,choice,1#1 día|1<{0} días} +notification.email.data_budget.cta=Abrir el editor de consultas diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index b072b3a59..d604f114f 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -1589,3 +1589,55 @@ workflow.decision.plan.skipped_bytes_cap=Le plan de revue n’est pas consulté workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=La politique de routage "{0}" a correspondu avec l’action AUTO_APPROVE, neutralisée faute d’estimation en octets sous la limite d’octets analysés workflow.decision.grant.suppressed_bytes_cap=Couverte par l’autorisation d’accès {0}, neutralisée faute d’estimation en octets sous la limite d’octets analysés workflow.decision.plan.suppressed_bytes_cap=Le plan de revue aurait approuvé, neutralisé faute d’estimation en octets sous la limite d’octets analysés +# Data-volume budgets (#942) +error.data_budget.name_invalid=Le nom du budget est obligatoire et doit comporter au plus 120 caractères +error.data_budget.limit_required=Un budget de données nécessite une limite de lignes, une limite d’octets, ou les deux +error.data_budget.limit_invalid=Les limites de lignes et d’octets doivent être des nombres positifs +error.data_budget.window_invalid=La fenêtre du budget doit être comprise entre 1 heure et 31 jours +error.data_budget.threshold_invalid=Le seuil d’avertissement doit être compris entre 1 et 99 pour cent +error.data_budget.unknown_role=Rôle d’application inconnu : {0} +error.data_budget.user_not_in_org=Un ou plusieurs utilisateurs ciblés n’appartiennent pas à cette organisation +error.data_budget.group_not_in_org=Un ou plusieurs groupes ciblés n’appartiennent pas à cette organisation +error.data_budget.not_found=Budget de données introuvable +error.data_budget.exhausted=Refusé : votre budget de données « {0} » sur cette source de données est épuisé pour la fenêtre en cours +validation.data_budget.name.required=Le nom du budget est obligatoire +validation.data_budget.name.size=Le nom du budget doit comporter au plus 120 caractères +validation.data_budget.max_rows.min=La limite de lignes doit être d’au moins 1 +validation.data_budget.max_bytes.min=La limite d’octets doit être d’au moins 1 +validation.data_budget.window.range=La fenêtre du budget doit être comprise entre 60 et 44 640 minutes (de 1 heure à 31 jours) +validation.data_budget.threshold.range=Le seuil d’avertissement doit être compris entre 1 et 99 pour cent +workflow.data_budget.rejected=Rejetée : le budget de données « {0} » du demandeur sur cette source de données est épuisé pour la fenêtre en cours +workflow.decision.data_budget.none=Aucun budget de données ne s’applique +workflow.decision.data_budget.within={0} % du budget de données est consommé ; le résultat est plafonné au quota restant +workflow.decision.data_budget.exhausted_rejected=Le budget de données « {0} » est épuisé — la demande est rejetée +workflow.decision.data_budget.exhausted_review=Le budget de données « {0} » est épuisé — la demande ne peut pas être approuvée automatiquement +workflow.decision.routing.skipped_data_budget=Les politiques de routage ne sont pas évaluées lorsqu’un budget de données épuisé rejette la demande +workflow.decision.grant.skipped_data_budget=La voie rapide par autorisation n’est pas évaluée lorsqu’un budget de données épuisé rejette la demande +workflow.decision.plan.skipped_data_budget=Le plan de revue n’est pas consulté lorsqu’un budget de données épuisé rejette la demande +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=La politique de routage « {0} » correspond avec l’action AUTO_APPROVE, suspendue car le budget de données est épuisé +workflow.decision.grant.suppressed_data_budget=Couverte par l’autorisation d’accès {0}, suspendue car le budget de données est épuisé +workflow.decision.plan.suppressed_data_budget=Le plan de revue aurait approuvé, suspendu car le budget de données est épuisé + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] Budget de données « {0} » sur {1} utilisé à {2} % +notification.email.subject.data_budget_exhausted=[AccessFlow] Budget de données « {0} » sur {1} épuisé +notification.email.data_budget_threshold_reached.title=Alerte de budget de données +notification.email.data_budget_threshold_reached.heading=Vous approchez de votre budget de données +notification.email.data_budget_threshold_reached.body=Vos lectures sur cette source de données ont franchi le seuil d’alerte de votre budget de données. Une fois le budget épuisé, les requêtes SELECT suivantes sont rejetées ou envoyées en revue jusqu’à ce que l’utilisation repasse sous la limite. +notification.email.data_budget_exhausted.title=Budget de données épuisé +notification.email.data_budget_exhausted.heading=Un budget de données a été épuisé +notification.email.data_budget_exhausted.body=Le budget de données ci-dessous est épuisé pour sa fenêtre actuelle. Les requêtes SELECT suivantes de cet utilisateur sur cette source de données sont traitées selon l’action de dépassement jusqu’à ce que l’utilisation repasse sous la limite. +notification.email.data_budget.user_label=Utilisateur : +notification.email.data_budget.budget_label=Budget : +notification.email.data_budget.used_label=Utilisé : +notification.email.data_budget.rows_label=Lignes : +notification.email.data_budget.bytes_label=Octets : +notification.email.data_budget.window_label=Fenêtre : +notification.email.data_budget.threshold_label=Seuil d’alerte : +notification.email.data_budget.breach_action_label=Une fois épuisé : +notification.email.data_budget.breach_action.REJECT=Les lectures suivantes sont rejetées +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Les lectures suivantes passent en revue manuelle +notification.email.data_budget.window.MINUTES={0,choice,1#1 minute|1<{0} minutes} +notification.email.data_budget.window.HOURS={0,choice,1#1 heure|1<{0} heures} +notification.email.data_budget.window.DAYS={0,choice,1#1 jour|1<{0} jours} +notification.email.data_budget.cta=Ouvrir l’éditeur de requêtes diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index deb3b475b..a6eb01256 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -1583,3 +1583,55 @@ workflow.decision.plan.skipped_bytes_cap=Ստուգման պլանը չի դիտ workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=«{0}» երթուղավորման քաղաքականությունը համընկավ AUTO_APPROVE գործողությամբ, ճնշվել է, քանի որ սկանավորված բայթերի սահմանաչափի ներքո բայթերի գնահատական չկա workflow.decision.grant.suppressed_bytes_cap=Ծածկված է {0} մուտքի թույլտվությամբ, ճնշվել է, քանի որ սկանավորված բայթերի սահմանաչափի ներքո բայթերի գնահատական չկա workflow.decision.plan.suppressed_bytes_cap=Ստուգման պլանը կհաստատեր, ճնշվել է, քանի որ սկանավորված բայթերի սահմանաչափի ներքո բայթերի գնահատական չկա +# Data-volume budgets (#942) +error.data_budget.name_invalid=Բյուջեի անունը պարտադիր է և պետք է լինի առավելագույնը 120 նիշ +error.data_budget.limit_required=Տվյալների բյուջեին անհրաժեշտ է տողերի սահմանաչափ, բայթերի սահմանաչափ կամ երկուսն էլ +error.data_budget.limit_invalid=Տողերի և բայթերի սահմանաչափերը պետք է լինեն դրական թվեր +error.data_budget.window_invalid=Բյուջեի պատուհանը պետք է լինի 1 ժամից մինչև 31 օր +error.data_budget.threshold_invalid=Զգուշացման շեմը պետք է լինի 1-ից 99 տոկոս +error.data_budget.unknown_role=Կիրառման անհայտ դեր՝ {0} +error.data_budget.user_not_in_org=Կիրառման մեկ կամ մի քանի օգտատերեր չեն պատկանում այս կազմակերպությանը +error.data_budget.group_not_in_org=Կիրառման մեկ կամ մի քանի խմբեր չեն պատկանում այս կազմակերպությանը +error.data_budget.not_found=Տվյալների բյուջեն չի գտնվել +error.data_budget.exhausted=Մերժված է. այս տվյալների աղբյուրում ձեր «{0}» տվյալների բյուջեն սպառված է ընթացիկ պատուհանի համար +validation.data_budget.name.required=Բյուջեի անունը պարտադիր է +validation.data_budget.name.size=Բյուջեի անունը պետք է լինի առավելագույնը 120 նիշ +validation.data_budget.max_rows.min=Տողերի սահմանաչափը պետք է լինի առնվազն 1 +validation.data_budget.max_bytes.min=Բայթերի սահմանաչափը պետք է լինի առնվազն 1 +validation.data_budget.window.range=Բյուջեի պատուհանը պետք է լինի 60-ից 44 640 րոպե (1 ժամից մինչև 31 օր) +validation.data_budget.threshold.range=Զգուշացման շեմը պետք է լինի 1-ից 99 տոկոս +workflow.data_budget.rejected=Մերժված է. ներկայացնողի «{0}» տվյալների բյուջեն այս աղբյուրում սպառված է ընթացիկ պատուհանի համար +workflow.decision.data_budget.none=Տվյալների բյուջե չի կիրառվում +workflow.decision.data_budget.within=Տվյալների բյուջեի {0}%-ն օգտագործված է. արդյունքը սահմանափակվում է մնացած թույլատրելի ծավալով +workflow.decision.data_budget.exhausted_rejected=«{0}» տվյալների բյուջեն սպառված է — հարցումը մերժվում է +workflow.decision.data_budget.exhausted_review=«{0}» տվյալների բյուջեն սպառված է — հարցումը չի կարող ավտոմատ հաստատվել +workflow.decision.routing.skipped_data_budget=Երթուղման քաղաքականությունները չեն գնահատվում, երբ սպառված տվյալների բյուջեն մերժում է հարցումը +workflow.decision.grant.skipped_data_budget=Թույլտվության արագ ուղին չի գնահատվում, երբ սպառված տվյալների բյուջեն մերժում է հարցումը +workflow.decision.plan.skipped_data_budget=Վերանայման պլանը չի դիտարկվում, երբ սպառված տվյալների բյուջեն մերժում է հարցումը +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=«{0}» երթուղման քաղաքականությունը համընկավ AUTO_APPROVE գործողությամբ, կասեցված է, քանի որ տվյալների բյուջեն սպառված է +workflow.decision.grant.suppressed_data_budget=Ծածկված է {0} մուտքի թույլտվությամբ, կասեցված է, քանի որ տվյալների բյուջեն սպառված է +workflow.decision.plan.suppressed_data_budget=Վերանայման պլանը կհաստատեր, կասեցված է, քանի որ տվյալների բյուջեն սպառված է + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] «{0}» տվյալների բյուջեն {1}-ում օգտագործված է {2}%-ով +notification.email.subject.data_budget_exhausted=[AccessFlow] «{0}» տվյալների բյուջեն {1}-ում սպառված է +notification.email.data_budget_threshold_reached.title=Տվյալների բյուջեի զգուշացում +notification.email.data_budget_threshold_reached.heading=Դուք մոտենում եք ձեր տվյալների բյուջեին +notification.email.data_budget_threshold_reached.body=Այս տվյալների աղբյուրում ձեր ընթերցումները անցել են ձեր տվյալների բյուջեի զգուշացման շեմը։ Բյուջեն սպառվելուց հետո հաջորդ SELECT հարցումները կմերժվեն կամ կուղարկվեն վերանայման, մինչև օգտագործումը նորից իջնի սահմանաչափից։ +notification.email.data_budget_exhausted.title=Տվյալների բյուջեն սպառված է +notification.email.data_budget_exhausted.heading=Տվյալների բյուջեն սպառվել է +notification.email.data_budget_exhausted.body=Ստորև նշված տվյալների բյուջեն սպառված է ընթացիկ պատուհանի համար։ Այս օգտատիրոջ հաջորդ SELECT հարցումները այս տվյալների աղբյուրում կմշակվեն խախտման գործողության համաձայն, մինչև օգտագործումը նորից իջնի սահմանաչափից։ +notification.email.data_budget.user_label=Օգտատեր. +notification.email.data_budget.budget_label=Բյուջե. +notification.email.data_budget.used_label=Օգտագործված. +notification.email.data_budget.rows_label=Տողեր. +notification.email.data_budget.bytes_label=Բայթեր. +notification.email.data_budget.window_label=Պատուհան. +notification.email.data_budget.threshold_label=Զգուշացման շեմ. +notification.email.data_budget.breach_action_label=Սպառվելիս. +notification.email.data_budget.breach_action.REJECT=Հաջորդ ընթերցումները մերժվում են +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Հաջորդ ընթերցումներն ուղարկվում են ձեռքով վերանայման +notification.email.data_budget.window.MINUTES={0} րոպե +notification.email.data_budget.window.HOURS={0} ժամ +notification.email.data_budget.window.DAYS={0} օր +notification.email.data_budget.cta=Բացել հարցումների խմբագիրը diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index ccadf8a29..1eed69c19 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -1583,3 +1583,55 @@ workflow.decision.plan.skipped_bytes_cap=План проверки не учит workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=Политика маршрутизации "{0}" сработала с действием AUTO_APPROVE, подавлено, так как при лимите сканируемых байтов оценка в байтах недоступна workflow.decision.grant.suppressed_bytes_cap=Покрыт выдачей доступа {0}, подавлено, так как при лимите сканируемых байтов оценка в байтах недоступна workflow.decision.plan.suppressed_bytes_cap=План проверки одобрил бы запрос, подавлено, так как при лимите сканируемых байтов оценка в байтах недоступна +# Data-volume budgets (#942) +error.data_budget.name_invalid=Название бюджета обязательно и должно содержать не более 120 символов +error.data_budget.limit_required=Для бюджета данных нужен лимит строк, лимит байтов или оба +error.data_budget.limit_invalid=Лимиты строк и байтов должны быть положительными числами +error.data_budget.window_invalid=Окно бюджета должно составлять от 1 часа до 31 дня +error.data_budget.threshold_invalid=Порог предупреждения должен быть от 1 до 99 процентов +error.data_budget.unknown_role=Неизвестная роль применения: {0} +error.data_budget.user_not_in_org=Один или несколько пользователей применения не принадлежат этой организации +error.data_budget.group_not_in_org=Одна или несколько групп применения не принадлежат этой организации +error.data_budget.not_found=Бюджет данных не найден +error.data_budget.exhausted=Отклонено: ваш бюджет данных «{0}» для этого источника данных исчерпан в текущем окне +validation.data_budget.name.required=Название бюджета обязательно +validation.data_budget.name.size=Название бюджета должно содержать не более 120 символов +validation.data_budget.max_rows.min=Лимит строк должен быть не меньше 1 +validation.data_budget.max_bytes.min=Лимит байтов должен быть не меньше 1 +validation.data_budget.window.range=Окно бюджета должно составлять от 60 до 44 640 минут (от 1 часа до 31 дня) +validation.data_budget.threshold.range=Порог предупреждения должен быть от 1 до 99 процентов +workflow.data_budget.rejected=Отклонено: бюджет данных «{0}» автора запроса для этого источника данных исчерпан в текущем окне +workflow.decision.data_budget.none=Бюджет данных не применяется +workflow.decision.data_budget.within=Использовано {0}% бюджета данных; результат ограничен оставшимся объёмом +workflow.decision.data_budget.exhausted_rejected=Бюджет данных «{0}» исчерпан — запрос отклоняется +workflow.decision.data_budget.exhausted_review=Бюджет данных «{0}» исчерпан — запрос не может быть одобрен автоматически +workflow.decision.routing.skipped_data_budget=Политики маршрутизации не оцениваются, когда исчерпанный бюджет данных отклоняет запрос +workflow.decision.grant.skipped_data_budget=Быстрый путь по допуску не оценивается, когда исчерпанный бюджет данных отклоняет запрос +workflow.decision.plan.skipped_data_budget=План проверки не учитывается, когда исчерпанный бюджет данных отклоняет запрос +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=Политика маршрутизации «{0}» сработала с действием AUTO_APPROVE, подавлено, так как бюджет данных исчерпан +workflow.decision.grant.suppressed_data_budget=Покрыто допуском {0}, подавлено, так как бюджет данных исчерпан +workflow.decision.plan.suppressed_data_budget=План проверки одобрил бы запрос, подавлено, так как бюджет данных исчерпан + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] Бюджет данных «{0}» в {1} использован на {2}% +notification.email.subject.data_budget_exhausted=[AccessFlow] Бюджет данных «{0}» в {1} исчерпан +notification.email.data_budget_threshold_reached.title=Предупреждение о бюджете данных +notification.email.data_budget_threshold_reached.heading=Вы приближаетесь к своему бюджету данных +notification.email.data_budget_threshold_reached.body=Ваши чтения из этого источника данных превысили порог предупреждения вашего бюджета данных. Когда бюджет будет исчерпан, последующие запросы SELECT будут отклоняться или отправляться на проверку, пока использование не опустится ниже лимита. +notification.email.data_budget_exhausted.title=Бюджет данных исчерпан +notification.email.data_budget_exhausted.heading=Бюджет данных исчерпан +notification.email.data_budget_exhausted.body=Указанный ниже бюджет данных исчерпан для текущего окна. Последующие запросы SELECT этого пользователя к этому источнику данных обрабатываются согласно действию при превышении, пока использование не опустится ниже лимита. +notification.email.data_budget.user_label=Пользователь: +notification.email.data_budget.budget_label=Бюджет: +notification.email.data_budget.used_label=Использовано: +notification.email.data_budget.rows_label=Строки: +notification.email.data_budget.bytes_label=Байты: +notification.email.data_budget.window_label=Окно: +notification.email.data_budget.threshold_label=Порог предупреждения: +notification.email.data_budget.breach_action_label=При исчерпании: +notification.email.data_budget.breach_action.REJECT=Последующие чтения отклоняются +notification.email.data_budget.breach_action.REQUIRE_REVIEW=Последующие чтения отправляются на ручную проверку +notification.email.data_budget.window.MINUTES={0} мин. +notification.email.data_budget.window.HOURS={0} ч. +notification.email.data_budget.window.DAYS={0} дн. +notification.email.data_budget.cta=Открыть редактор запросов diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 9fff29495..fab378876 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -1583,3 +1583,55 @@ workflow.decision.plan.skipped_bytes_cap=扫描字节上限拒绝请求时不参 workflow.decision.routing.matched_auto_approve_suppressed_bytes_cap=路由策略"{0}"以 AUTO_APPROVE 动作匹配,但因在扫描字节上限下没有可用的字节估算而被抑制 workflow.decision.grant.suppressed_bytes_cap=由访问授权 {0} 覆盖,但因在扫描字节上限下没有可用的字节估算而被抑制 workflow.decision.plan.suppressed_bytes_cap=审查计划本会批准,但因在扫描字节上限下没有可用的字节估算而被抑制 +# Data-volume budgets (#942) +error.data_budget.name_invalid=预算名称为必填项,且不能超过 120 个字符 +error.data_budget.limit_required=数据预算需要设置行数上限、字节上限或两者 +error.data_budget.limit_invalid=行数和字节上限必须为正数 +error.data_budget.window_invalid=预算窗口必须介于 1 小时到 31 天之间 +error.data_budget.threshold_invalid=预警阈值必须介于 1% 到 99% 之间 +error.data_budget.unknown_role=未知的适用角色:{0} +error.data_budget.user_not_in_org=一个或多个适用用户不属于此组织 +error.data_budget.group_not_in_org=一个或多个适用组不属于此组织 +error.data_budget.not_found=未找到数据预算 +error.data_budget.exhausted=已拒绝:你在此数据源上的数据预算“{0}”在当前窗口内已用尽 +validation.data_budget.name.required=预算名称为必填项 +validation.data_budget.name.size=预算名称不能超过 120 个字符 +validation.data_budget.max_rows.min=行数上限至少为 1 +validation.data_budget.max_bytes.min=字节上限至少为 1 +validation.data_budget.window.range=预算窗口必须介于 60 到 44,640 分钟(1 小时到 31 天)之间 +validation.data_budget.threshold.range=预警阈值必须介于 1% 到 99% 之间 +workflow.data_budget.rejected=已拒绝:提交者在此数据源上的数据预算“{0}”在当前窗口内已用尽 +workflow.decision.data_budget.none=没有适用的数据预算 +workflow.decision.data_budget.within=数据预算已使用 {0}%;结果将被限制在剩余额度内 +workflow.decision.data_budget.exhausted_rejected=数据预算“{0}”已用尽——请求被拒绝 +workflow.decision.data_budget.exhausted_review=数据预算“{0}”已用尽——请求无法自动批准 +workflow.decision.routing.skipped_data_budget=当已用尽的数据预算拒绝请求时,不评估路由策略 +workflow.decision.grant.skipped_data_budget=当已用尽的数据预算拒绝请求时,不评估授权快速通道 +workflow.decision.plan.skipped_data_budget=当已用尽的数据预算拒绝请求时,不参考审核计划 +workflow.decision.routing.matched_auto_approve_suppressed_data_budget=路由策略“{0}”以 AUTO_APPROVE 操作匹配,因数据预算已用尽而被抑制 +workflow.decision.grant.suppressed_data_budget=已被访问授权 {0} 覆盖,因数据预算已用尽而被抑制 +workflow.decision.plan.suppressed_data_budget=审核计划本会批准,因数据预算已用尽而被抑制 + +# Data-budget notifications (#942) +notification.email.subject.data_budget_threshold_reached=[AccessFlow] {1} 上的数据预算“{0}”已使用 {2}% +notification.email.subject.data_budget_exhausted=[AccessFlow] {1} 上的数据预算“{0}”已用尽 +notification.email.data_budget_threshold_reached.title=数据预算警告 +notification.email.data_budget_threshold_reached.heading=您即将用完数据预算 +notification.email.data_budget_threshold_reached.body=您在此数据源上的读取已超过数据预算的警告阈值。预算用尽后,后续的 SELECT 查询将被拒绝或送交审核,直到用量回落到限额以下。 +notification.email.data_budget_exhausted.title=数据预算已用尽 +notification.email.data_budget_exhausted.heading=一项数据预算已用尽 +notification.email.data_budget_exhausted.body=以下数据预算在当前时间窗口内已用尽。该用户在此数据源上的后续 SELECT 查询将按超限处理方式处理,直到用量回落到限额以下。 +notification.email.data_budget.user_label=用户: +notification.email.data_budget.budget_label=预算: +notification.email.data_budget.used_label=已使用: +notification.email.data_budget.rows_label=行数: +notification.email.data_budget.bytes_label=字节: +notification.email.data_budget.window_label=时间窗口: +notification.email.data_budget.threshold_label=警告阈值: +notification.email.data_budget.breach_action_label=用尽时: +notification.email.data_budget.breach_action.REJECT=后续读取将被拒绝 +notification.email.data_budget.breach_action.REQUIRE_REVIEW=后续读取将送交人工审核 +notification.email.data_budget.window.MINUTES={0} 分钟 +notification.email.data_budget.window.HOURS={0} 小时 +notification.email.data_budget.window.DAYS={0} 天 +notification.email.data_budget.cta=打开查询编辑器 diff --git a/backend/src/main/resources/templates/email/data-budget-exhausted.html b/backend/src/main/resources/templates/email/data-budget-exhausted.html new file mode 100644 index 000000000..085fccf35 --- /dev/null +++ b/backend/src/main/resources/templates/email/data-budget-exhausted.html @@ -0,0 +1,47 @@ + + + + + Data budget + + + +

Data budget

+

Data budget notice.

+

+ Datasource: + —
+ User: + — +
+ Budget: + —
+ Used: + —
+ + Rows: + —
+
+ + Bytes: + —
+
+ + Window: + —
+
+ + When used up: + — + +

+

+ Open the query editor +

+ + diff --git a/backend/src/main/resources/templates/email/data-budget-threshold-reached.html b/backend/src/main/resources/templates/email/data-budget-threshold-reached.html new file mode 100644 index 000000000..efd59adba --- /dev/null +++ b/backend/src/main/resources/templates/email/data-budget-threshold-reached.html @@ -0,0 +1,47 @@ + + + + + Data budget + + + +

Data budget

+

Data budget notice.

+

+ Datasource: + —
+ User: + — +
+ Budget: + —
+ Used: + —
+ + Rows: + —
+
+ + Bytes: + —
+
+ + Window: + —
+
+ + Warning threshold: + — + +

+

+ Open the query editor +

+ + diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/DataBudgetStatusTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/DataBudgetStatusTest.java new file mode 100644 index 000000000..e1d1fc6f2 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DataBudgetStatusTest.java @@ -0,0 +1,112 @@ +package com.bablsoft.accessflow.core.api; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DataBudgetStatusTest { + + private final UUID datasourceId = UUID.randomUUID(); + + @Test + void emptyStatusHasNoStanding() { + var status = DataBudgetStatus.none(datasourceId); + + assertThat(status.isEmpty()).isTrue(); + assertThat(status.exhausted()).isFalse(); + assertThat(status.breachAction()).isNull(); + assertThat(status.decidingBudget()).isNull(); + assertThat(status.remainingRows()).isNull(); + assertThat(status.remainingBytes()).isNull(); + assertThat(status.usedPercent()).isNull(); + assertThat(new DataBudgetStatus(datasourceId, null, null).budgets()).isEmpty(); + } + + @Test + void theMostConstrainedBudgetWins() { + var rows = consumption(100L, null, 40, 0, DataBudgetBreachAction.REQUIRE_REVIEW); + var bytes = consumption(null, 1_000L, 0, 900, DataBudgetBreachAction.REJECT); + var status = new DataBudgetStatus(datasourceId, "ds", List.of(rows, bytes)); + + assertThat(status.exhausted()).isFalse(); + assertThat(status.remainingRows()).isEqualTo(60L); + assertThat(status.remainingBytes()).isEqualTo(100L); + assertThat(status.usedPercent()).isEqualTo(90d); + } + + @Test + void rejectBeatsReviewAmongExhaustedBudgets() { + var review = consumption(100L, null, 100, 0, DataBudgetBreachAction.REQUIRE_REVIEW); + var reject = consumption(null, 10L, 0, 20, DataBudgetBreachAction.REJECT); + var status = new DataBudgetStatus(datasourceId, "ds", List.of(review, reject)); + + assertThat(status.exhausted()).isTrue(); + assertThat(status.breachAction()).isEqualTo(DataBudgetBreachAction.REJECT); + assertThat(status.decidingBudget()).isEqualTo(reject); + assertThat(status.remainingRows()).isZero(); + assertThat(status.remainingBytes()).isZero(); + } + + @Test + void consumptionArithmetic() { + var c = consumption(200L, 50L, 50, 100, DataBudgetBreachAction.REJECT); + + assertThat(c.usedPercent()).isEqualTo(200d); + assertThat(c.exhausted()).isTrue(); + var more = c.plus(10, 5); + assertThat(more.usedRows()).isEqualTo(60); + assertThat(more.usedBytes()).isEqualTo(105); + assertThat(consumption(null, null, 5, 5, DataBudgetBreachAction.REJECT).usedPercent()).isZero(); + } + + @Test + void strictestAction() { + assertThat(DataBudgetBreachAction.strictest(null, DataBudgetBreachAction.REQUIRE_REVIEW)) + .isEqualTo(DataBudgetBreachAction.REQUIRE_REVIEW); + assertThat(DataBudgetBreachAction.strictest(DataBudgetBreachAction.REQUIRE_REVIEW, null)) + .isEqualTo(DataBudgetBreachAction.REQUIRE_REVIEW); + assertThat(DataBudgetBreachAction.strictest(DataBudgetBreachAction.REQUIRE_REVIEW, + DataBudgetBreachAction.REJECT)).isEqualTo(DataBudgetBreachAction.REJECT); + assertThat(DataBudgetBreachAction.strictest(DataBudgetBreachAction.REQUIRE_REVIEW, + DataBudgetBreachAction.REQUIRE_REVIEW)).isEqualTo(DataBudgetBreachAction.REQUIRE_REVIEW); + } + + @Test + void usageRecordValidatesAndClamps() { + var record = new DataBudgetUsageRecord(UUID.randomUUID(), datasourceId, -5, -1, + DataBudgetUsageSource.SAMPLE_DATA, null, null); + assertThat(record.rowsRead()).isZero(); + assertThat(record.bytesRead()).isZero(); + assertThatThrownBy(() -> new DataBudgetUsageRecord(null, datasourceId, 1, 1, + DataBudgetUsageSource.QUERY, null, null)).isInstanceOf(NullPointerException.class); + } + + @Test + void viewDefensivelyCopiesTargets() { + var view = new DataBudgetView(UUID.randomUUID(), datasourceId, "n", 1L, null, 60, + DataBudgetBreachAction.REJECT, null, null, null, null, true, null, null); + assertThat(view.appliesToRoles()).isEmpty(); + assertThat(view.appliesToGroupIds()).isEmpty(); + assertThat(view.appliesToUserIds()).isEmpty(); + } + + @Test + void exhaustedExceptionCarriesTheBudget() { + var c = consumption(1L, null, 1, 0, DataBudgetBreachAction.REJECT); + var ex = new DataBudgetExhaustedException("used up", c); + assertThat(ex).hasMessage("used up"); + assertThat(ex.budget()).isEqualTo(c); + assertThat(new DataBudgetNotFoundException(c.budgetId())).hasMessageContaining("not found"); + assertThat(new IllegalDataBudgetException("bad")).hasMessage("bad"); + } + + private DataBudgetConsumption consumption(Long maxRows, Long maxBytes, long usedRows, + long usedBytes, DataBudgetBreachAction action) { + return new DataBudgetConsumption(UUID.randomUUID(), "b", maxRows, maxBytes, 60, action, 80, + usedRows, usedBytes); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/QueryExecutionRequestTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/QueryExecutionRequestTest.java index 1eb8d3d3d..6800312ea 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/QueryExecutionRequestTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/QueryExecutionRequestTest.java @@ -138,4 +138,41 @@ void negativeTimeoutOverrideIsRejected() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("statementTimeoutOverride must be positive"); } + + @Test + void nonPositiveResultByteOverrideIsRejected() { + assertThatThrownBy(() -> new QueryExecutionRequest(datasourceId, "SELECT 1", + QueryType.SELECT, null, null, null, null, null, false, null, null, null, 0L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxResultBytesOverride must be positive"); + } + + @Test + void withAllowanceOnlyEverLowersTheCaps() { + var base = new QueryExecutionRequest(datasourceId, "SELECT 1", QueryType.SELECT, 50, + null); + + var lowered = base.withAllowance(10L, 2_048L); + assertThat(lowered.maxRowsOverride()).isEqualTo(10); + assertThat(lowered.maxResultBytesOverride()).isEqualTo(2_048L); + + var kept = base.withAllowance(500L, null); + assertThat(kept.maxRowsOverride()).isEqualTo(50); + assertThat(kept.maxResultBytesOverride()).isNull(); + + var tighterBytes = lowered.withAllowance(null, 4_096L); + assertThat(tighterBytes.maxResultBytesOverride()).isEqualTo(2_048L); + } + + @Test + void withAllowanceFloorsAtOneAndSetsAnAbsentRowCap() { + var base = new QueryExecutionRequest(datasourceId, "SELECT 1", QueryType.SELECT, null, + null); + + var floored = base.withAllowance(0L, 0L); + assertThat(floored.maxRowsOverride()).isEqualTo(1); + assertThat(floored.maxResultBytesOverride()).isEqualTo(1L); + assertThat(base.withAllowance(Long.MAX_VALUE, null).maxRowsOverride()) + .isEqualTo(Integer.MAX_VALUE); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java index d5d0e377b..4ad56e8a7 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/SelectExecutionResultTest.java @@ -74,4 +74,31 @@ void withEffectiveSqlAndWithRowSecurityPolicyIdsPreserveEachOther() { assertThat(result.appliedRowSecurityPolicyIds()).containsExactly(id); assertThat(result.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); } + + @Test + void byteAccountingCopiesPreserveEveryOtherField() { + var id = UUID.randomUUID(); + List> rows = List.of(List.of(1), List.of(2), List.of(3)); + var result = new SelectExecutionResult(List.of(), rows, 3L, false, Duration.ZERO, + Set.of(id), Set.of(), null, "SELECT 1"); + assertThat(result.resultBytes()).isZero(); + + var measured = result.withResultBytes(120L); + assertThat(measured.resultBytes()).isEqualTo(120L); + assertThat(measured.appliedMaskingPolicyIds()).containsExactly(id); + assertThat(measured.effectiveSql()).isEqualTo("SELECT 1"); + + var trimmed = measured.truncatedTo(2, SelectExecutionResult.TRUNCATED_DATA_BUDGET, 80L); + assertThat(trimmed.rows()).hasSize(2); + assertThat(trimmed.rowCount()).isEqualTo(2); + assertThat(trimmed.truncated()).isTrue(); + assertThat(trimmed.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + assertThat(trimmed.resultBytes()).isEqualTo(80L); + + var relabeled = measured.withTruncatedReason(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + assertThat(relabeled.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + assertThat(relabeled.resultBytes()).isEqualTo(120L); + assertThat(relabeled.withEffectiveSql("x").resultBytes()).isEqualTo(120L); + assertThat(relabeled.withRowSecurityPolicyIds(Set.of(id)).resultBytes()).isEqualTo(120L); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/AppliesToMatcherTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/AppliesToMatcherTest.java new file mode 100644 index 000000000..b78bac1de --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/AppliesToMatcherTest.java @@ -0,0 +1,39 @@ +package com.bablsoft.accessflow.core.internal; + +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppliesToMatcherTest { + + private final UUID userId = UUID.randomUUID(); + private final UUID groupId = UUID.randomUUID(); + + @Test + void emptyScopeMatchesEveryone() { + assertThat(AppliesToMatcher.matches(null, null, null, userId, null, Set.of())).isTrue(); + assertThat(AppliesToMatcher.matches(new String[0], new UUID[0], new UUID[0], userId, null, + Set.of())).isTrue(); + } + + @Test + void matchesOnAnyOneList() { + assertThat(AppliesToMatcher.matches(new String[]{" analyst "}, null, null, userId, "ANALYST", + Set.of())).isTrue(); + assertThat(AppliesToMatcher.matches(null, null, new UUID[]{userId}, userId, null, Set.of())) + .isTrue(); + assertThat(AppliesToMatcher.matches(null, new UUID[]{groupId}, null, userId, null, + Set.of(groupId))).isTrue(); + } + + @Test + void rejectsWhenNoListMatches() { + assertThat(AppliesToMatcher.matches(new String[]{"REVIEWER", null}, new UUID[]{groupId}, + new UUID[]{UUID.randomUUID()}, userId, "ANALYST", Set.of())).isFalse(); + assertThat(AppliesToMatcher.matches(new String[]{"ANALYST"}, null, null, userId, null, + Set.of())).isFalse(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminServiceTest.java new file mode 100644 index 000000000..1ee1efe91 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetAdminServiceTest.java @@ -0,0 +1,277 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetCommand; +import com.bablsoft.accessflow.core.api.DataBudgetNotFoundException; +import com.bablsoft.accessflow.core.api.DatasourceNotFoundException; +import com.bablsoft.accessflow.core.api.IllegalDataBudgetException; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.RoleEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserGroupEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.MessageSource; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultDataBudgetAdminServiceTest { + + @Mock DataBudgetRepository dataBudgetRepository; + @Mock RoleRepository roleRepository; + @Mock DatasourceRepository datasourceRepository; + @Mock UserRepository userRepository; + @Mock UserGroupRepository userGroupRepository; + @Mock MessageSource messageSource; + + private DefaultDataBudgetAdminService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID datasourceId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultDataBudgetAdminService(dataBudgetRepository, roleRepository, + datasourceRepository, userRepository, userGroupRepository, messageSource); + when(messageSource.getMessage(any(), any(), any())).thenAnswer(inv -> inv.getArgument(0)); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(datasource(orgId))); + when(dataBudgetRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(roleRepository.findByNameInScope(eq(orgId), any())).thenReturn(Optional.empty()); + var analyst = new RoleEntity(); + analyst.setId(UUID.randomUUID()); + analyst.setName("ANALYST"); + when(roleRepository.findByNameInScope(orgId, "analyst")).thenReturn(Optional.of(analyst)); + } + + @Test + void listMapsEntitiesToViews() { + var entity = entity(); + entity.setAppliesToRoles(new String[]{"ANALYST"}); + entity.setWarnThresholdPercent((short) 80); + when(dataBudgetRepository + .findAllByOrganizationIdAndDatasourceIdOrderByCreatedAtAsc(orgId, datasourceId)) + .thenReturn(List.of(entity)); + + var result = service.listForDatasource(datasourceId, orgId); + + assertThat(result).singleElement().satisfies(v -> { + assertThat(v.name()).isEqualTo("Analysts daily"); + assertThat(v.maxRows()).isEqualTo(100_000L); + assertThat(v.maxBytes()).isNull(); + assertThat(v.windowMinutes()).isEqualTo(1440); + assertThat(v.breachAction()).isEqualTo(DataBudgetBreachAction.REQUIRE_REVIEW); + assertThat(v.warnThresholdPercent()).isEqualTo(80); + assertThat(v.appliesToRoles()).containsExactly("ANALYST"); + assertThat(v.appliesToGroupIds()).isEmpty(); + assertThat(v.appliesToUserIds()).isEmpty(); + }); + } + + @Test + void listRejectsDatasourceOfAnotherOrganization() { + when(datasourceRepository.findById(datasourceId)) + .thenReturn(Optional.of(datasource(UUID.randomUUID()))); + + assertThatThrownBy(() -> service.listForDatasource(datasourceId, orgId)) + .isInstanceOf(DatasourceNotFoundException.class); + } + + @Test + void listRejectsUnknownDatasource() { + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.listForDatasource(datasourceId, orgId)) + .isInstanceOf(DatasourceNotFoundException.class); + } + + @Test + void createAppliesDefaultsAndNormalizesTargets() { + var userId = UUID.randomUUID(); + var groupId = UUID.randomUUID(); + when(userRepository.findAllByOrganization_IdAndIdIn(eq(orgId), anyList())) + .thenReturn(List.of(new UserEntity())); + when(userGroupRepository.findAllByOrganization_IdAndIdIn(eq(orgId), anyList())) + .thenReturn(List.of(new UserGroupEntity())); + + var view = service.create(datasourceId, orgId, new DataBudgetCommand(" Daily cap ", + null, 5_000_000_000L, null, null, null, List.of("analyst", " "), + List.of(groupId, groupId), List.of(userId), null)); + + var captor = ArgumentCaptor.forClass(DataBudgetEntity.class); + verify(dataBudgetRepository).save(captor.capture()); + var saved = captor.getValue(); + assertThat(saved.getOrganizationId()).isEqualTo(orgId); + assertThat(saved.getDatasourceId()).isEqualTo(datasourceId); + assertThat(saved.getName()).isEqualTo("Daily cap"); + assertThat(saved.getMaxRows()).isNull(); + assertThat(saved.getMaxBytes()).isEqualTo(5_000_000_000L); + assertThat(saved.getWindowMinutes()).isEqualTo(1440); + assertThat(saved.getBreachAction()).isEqualTo(DataBudgetBreachAction.REQUIRE_REVIEW); + assertThat(saved.getWarnThresholdPercent()).isNull(); + assertThat(saved.getAppliesToRoles()).containsExactly("ANALYST"); + assertThat(saved.getAppliesToGroupIds()).containsExactly(groupId); + assertThat(saved.getAppliesToUserIds()).containsExactly(userId); + assertThat(saved.isEnabled()).isTrue(); + assertThat(view.id()).isEqualTo(saved.getId()); + } + + @Test + void createKeepsExplicitValues() { + service.create(datasourceId, orgId, new DataBudgetCommand("Strict", 1_000L, null, 60, + DataBudgetBreachAction.REJECT, 75, null, null, null, false)); + + var captor = ArgumentCaptor.forClass(DataBudgetEntity.class); + verify(dataBudgetRepository).save(captor.capture()); + var saved = captor.getValue(); + assertThat(saved.getWindowMinutes()).isEqualTo(60); + assertThat(saved.getBreachAction()).isEqualTo(DataBudgetBreachAction.REJECT); + assertThat(saved.getWarnThresholdPercent()).isEqualTo((short) 75); + assertThat(saved.getAppliesToRoles()).isNull(); + assertThat(saved.isEnabled()).isFalse(); + } + + @Test + void createRejectsInvalidInput() { + assertInvalid(new DataBudgetCommand(" ", 1L, null, null, null, null, null, null, null, null), + "error.data_budget.name_invalid"); + assertInvalid(new DataBudgetCommand(null, 1L, null, null, null, null, null, null, null, null), + "error.data_budget.name_invalid"); + assertInvalid(new DataBudgetCommand("x".repeat(121), 1L, null, null, null, null, null, null, + null, null), "error.data_budget.name_invalid"); + assertInvalid(new DataBudgetCommand("n", null, null, null, null, null, null, null, null, + null), "error.data_budget.limit_required"); + assertInvalid(new DataBudgetCommand("n", 0L, null, null, null, null, null, null, null, null), + "error.data_budget.limit_invalid"); + assertInvalid(new DataBudgetCommand("n", null, -1L, null, null, null, null, null, null, + null), "error.data_budget.limit_invalid"); + assertInvalid(new DataBudgetCommand("n", 1L, null, 59, null, null, null, null, null, null), + "error.data_budget.window_invalid"); + assertInvalid(new DataBudgetCommand("n", 1L, null, 44_641, null, null, null, null, null, + null), "error.data_budget.window_invalid"); + assertInvalid(new DataBudgetCommand("n", 1L, null, null, null, 0, null, null, null, null), + "error.data_budget.threshold_invalid"); + assertInvalid(new DataBudgetCommand("n", 1L, null, null, null, 100, null, null, null, null), + "error.data_budget.threshold_invalid"); + assertInvalid(new DataBudgetCommand("n", 1L, null, null, null, null, List.of("ghost"), null, + null, null), "error.data_budget.unknown_role"); + verify(dataBudgetRepository, never()).save(any()); + } + + @Test + void createRejectsTargetsOutsideOrganization() { + when(userRepository.findAllByOrganization_IdAndIdIn(eq(orgId), anyList())) + .thenReturn(List.of()); + when(userGroupRepository.findAllByOrganization_IdAndIdIn(eq(orgId), anyList())) + .thenReturn(List.of()); + + assertInvalid(new DataBudgetCommand("n", 1L, null, null, null, null, null, null, + List.of(UUID.randomUUID()), null), "error.data_budget.user_not_in_org"); + assertInvalid(new DataBudgetCommand("n", 1L, null, null, null, null, null, + List.of(UUID.randomUUID()), null, null), "error.data_budget.group_not_in_org"); + } + + @Test + void updateReappliesEveryField() { + var entity = entity(); + when(dataBudgetRepository.findByIdAndOrganizationId(entity.getId(), orgId)) + .thenReturn(Optional.of(entity)); + + var view = service.update(entity.getId(), datasourceId, orgId, new DataBudgetCommand( + "Renamed", null, 1_024L, 120, DataBudgetBreachAction.REJECT, 50, null, null, null, + true)); + + assertThat(view.name()).isEqualTo("Renamed"); + assertThat(view.maxRows()).isNull(); + assertThat(view.maxBytes()).isEqualTo(1_024L); + assertThat(view.windowMinutes()).isEqualTo(120); + assertThat(view.breachAction()).isEqualTo(DataBudgetBreachAction.REJECT); + assertThat(view.warnThresholdPercent()).isEqualTo(50); + } + + @Test + void updateRejectsUnknownBudget() { + var id = UUID.randomUUID(); + when(dataBudgetRepository.findByIdAndOrganizationId(id, orgId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.update(id, datasourceId, orgId, + new DataBudgetCommand("n", 1L, null, null, null, null, null, null, null, null))) + .isInstanceOf(DataBudgetNotFoundException.class); + } + + @Test + void updateRejectsBudgetOfAnotherDatasource() { + var entity = entity(); + entity.setDatasourceId(UUID.randomUUID()); + when(dataBudgetRepository.findByIdAndOrganizationId(entity.getId(), orgId)) + .thenReturn(Optional.of(entity)); + + assertThatThrownBy(() -> service.update(entity.getId(), datasourceId, orgId, + new DataBudgetCommand("n", 1L, null, null, null, null, null, null, null, null))) + .isInstanceOf(DataBudgetNotFoundException.class); + } + + @Test + void deleteRemovesTheBudget() { + var entity = entity(); + when(dataBudgetRepository.findByIdAndOrganizationId(entity.getId(), orgId)) + .thenReturn(Optional.of(entity)); + + service.delete(entity.getId(), datasourceId, orgId); + + verify(dataBudgetRepository).delete(entity); + } + + private void assertInvalid(DataBudgetCommand command, String key) { + assertThatThrownBy(() -> service.create(datasourceId, orgId, command)) + .isInstanceOf(IllegalDataBudgetException.class) + .hasMessage(key); + } + + private DatasourceEntity datasource(UUID ownerOrgId) { + var org = new OrganizationEntity(); + org.setId(ownerOrgId); + var ds = new DatasourceEntity(); + ds.setId(datasourceId); + ds.setOrganization(org); + return ds; + } + + private DataBudgetEntity entity() { + var entity = new DataBudgetEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setDatasourceId(datasourceId); + entity.setName("Analysts daily"); + entity.setMaxRows(100_000L); + entity.setWindowMinutes(1440); + entity.setBreachAction(DataBudgetBreachAction.REQUIRE_REVIEW); + entity.setEnabled(true); + return entity; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusServiceTest.java new file mode 100644 index 000000000..c615a454b --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetStatusServiceTest.java @@ -0,0 +1,181 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupMembershipRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultDataBudgetStatusServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-25T12:00:00Z"); + + @Mock DataBudgetRepository dataBudgetRepository; + @Mock DataBudgetUsageRepository usageRepository; + @Mock DatasourceRepository datasourceRepository; + @Mock UserRepository userRepository; + @Mock UserGroupMembershipRepository membershipRepository; + + private DefaultDataBudgetStatusService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID datasourceId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + private final UUID groupId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultDataBudgetStatusService(dataBudgetRepository, usageRepository, + datasourceRepository, userRepository, membershipRepository, + Clock.fixed(NOW, ZoneOffset.UTC)); + var user = new UserEntity(); + user.setId(userId); + user.setRole(UserRoleType.ANALYST); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId)); + var ds = new DatasourceEntity(); + ds.setId(datasourceId); + ds.setName("warehouse"); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(ds)); + when(datasourceRepository.findAllById(any())).thenReturn(List.of(ds)); + when(usageRepository.sumSince(any(), any(), any())).thenReturn(totals(0, 0)); + } + + @Test + void noBudgetsReadsAsEmptyWithoutTouchingTheLedger() { + when(dataBudgetRepository.findAllByDatasourceIdAndEnabledTrue(datasourceId)) + .thenReturn(List.of()); + + var status = service.statusFor(datasourceId, userId); + + assertThat(status.isEmpty()).isTrue(); + verify(usageRepository, never()).sumSince(any(), any(), any()); + } + + @Test + void budgetsScopedToOthersReadAsEmpty() { + var budget = budget(1_000L, null, 60); + budget.setAppliesToUserIds(new UUID[]{UUID.randomUUID()}); + when(dataBudgetRepository.findAllByDatasourceIdAndEnabledTrue(datasourceId)) + .thenReturn(List.of(budget)); + + assertThat(service.statusFor(datasourceId, userId).isEmpty()).isTrue(); + } + + @Test + void sumsTheTrailingWindowOncePerDistinctWindow() { + var daily = budget(1_000L, null, 1440); + var alsoDaily = budget(null, 10_000L, 1440); + alsoDaily.setAppliesToGroupIds(new UUID[]{groupId}); + var hourly = budget(100L, null, 60); + hourly.setAppliesToRoles(new String[]{"analyst"}); + when(dataBudgetRepository.findAllByDatasourceIdAndEnabledTrue(datasourceId)) + .thenReturn(List.of(daily, alsoDaily, hourly)); + when(usageRepository.sumSince(userId, datasourceId, NOW.minusSeconds(86_400))) + .thenReturn(totals(400, 9_000)); + when(usageRepository.sumSince(userId, datasourceId, NOW.minusSeconds(3_600))) + .thenReturn(totals(100, 50)); + + var status = service.statusFor(datasourceId, userId); + + assertThat(status.datasourceName()).isEqualTo("warehouse"); + assertThat(status.budgets()).hasSize(3); + assertThat(status.remainingRows()).isZero(); + assertThat(status.remainingBytes()).isEqualTo(1_000L); + assertThat(status.exhausted()).isTrue(); + verify(usageRepository, times(2)).sumSince(eq(userId), eq(datasourceId), any()); + } + + @Test + void nullTotalsReadAsZero() { + when(dataBudgetRepository.findAllByDatasourceIdAndEnabledTrue(datasourceId)) + .thenReturn(List.of(budget(10L, null, 60))); + when(usageRepository.sumSince(any(), any(), any())).thenReturn(null); + + var status = service.statusFor(datasourceId, userId); + + assertThat(status.budgets().getFirst().usedRows()).isZero(); + assertThat(status.remainingRows()).isEqualTo(10L); + } + + @Test + void statusesForUserGroupsByDatasourceAndSkipsForeignScopes() { + var other = budget(5L, null, 60); + other.setAppliesToUserIds(new UUID[]{UUID.randomUUID()}); + when(dataBudgetRepository.findAllByOrganizationIdAndEnabledTrue(orgId)) + .thenReturn(List.of(budget(10L, null, 60), budget(20L, null, 60), other)); + + var statuses = service.statusesForUser(orgId, userId); + + assertThat(statuses).singleElement().satisfies(s -> { + assertThat(s.datasourceId()).isEqualTo(datasourceId); + assertThat(s.datasourceName()).isEqualTo("warehouse"); + assertThat(s.budgets()).hasSize(2); + }); + } + + @Test + void statusesForUserIsEmptyWithoutBudgets() { + when(dataBudgetRepository.findAllByOrganizationIdAndEnabledTrue(orgId)).thenReturn(List.of()); + + assertThat(service.statusesForUser(orgId, userId)).isEmpty(); + } + + private DataBudgetEntity budget(Long maxRows, Long maxBytes, int windowMinutes) { + var entity = new DataBudgetEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setDatasourceId(datasourceId); + entity.setName("b-" + windowMinutes); + entity.setMaxRows(maxRows); + entity.setMaxBytes(maxBytes); + entity.setWindowMinutes(windowMinutes); + entity.setBreachAction(DataBudgetBreachAction.REQUIRE_REVIEW); + entity.setWarnThresholdPercent((short) 80); + entity.setEnabled(true); + return entity; + } + + private static DataBudgetUsageRepository.UsageTotals totals(long rows, long bytes) { + return new DataBudgetUsageRepository.UsageTotals() { + @Override + public Long getRowsRead() { + return rows; + } + + @Override + public Long getBytesRead() { + return bytes; + } + }; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageServiceTest.java new file mode 100644 index 000000000..8e5b4ddf7 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDataBudgetUsageServiceTest.java @@ -0,0 +1,180 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; +import com.bablsoft.accessflow.core.api.DataBudgetStatusService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageRecord; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetUsageEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultDataBudgetUsageServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-25T12:00:00Z"); + + @Mock DataBudgetStatusService statusService; + @Mock DataBudgetRepository dataBudgetRepository; + @Mock DataBudgetUsageRepository usageRepository; + @Mock ApplicationEventPublisher eventPublisher; + + private DefaultDataBudgetUsageService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID datasourceId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + private final UUID budgetId = UUID.randomUUID(); + private final UUID queryId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultDataBudgetUsageService(statusService, dataBudgetRepository, + usageRepository, eventPublisher, Clock.fixed(NOW, ZoneOffset.UTC)); + var entity = new DataBudgetEntity(); + entity.setId(budgetId); + entity.setOrganizationId(orgId); + when(dataBudgetRepository.findById(budgetId)).thenReturn(Optional.of(entity)); + } + + @Test + void unbudgetedReadWritesNothing() { + when(statusService.statusFor(datasourceId, userId)) + .thenReturn(DataBudgetStatus.none(datasourceId)); + + service.record(usage(10, 100)); + + verify(usageRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void budgetDeletedMidwayWritesNothing() { + givenStatus(consumption(100L, 0)); + when(dataBudgetRepository.findById(budgetId)).thenReturn(Optional.empty()); + + service.record(usage(10, 100)); + + verify(usageRepository, never()).save(any()); + } + + @Test + void recordsTheLedgerRowWithoutEventsBelowTheThreshold() { + givenStatus(consumption(100L, 10)); + + service.record(usage(10, 512)); + + verify(usageRepository).lockUserDatasource( + DefaultDataBudgetUsageService.lockKey(userId, datasourceId)); + var captor = ArgumentCaptor.forClass(DataBudgetUsageEntity.class); + verify(usageRepository).save(captor.capture()); + var row = captor.getValue(); + assertThat(row.getOrganizationId()).isEqualTo(orgId); + assertThat(row.getUserId()).isEqualTo(userId); + assertThat(row.getDatasourceId()).isEqualTo(datasourceId); + assertThat(row.getRowsRead()).isEqualTo(10); + assertThat(row.getBytesRead()).isEqualTo(512); + assertThat(row.getSource()).isEqualTo(DataBudgetUsageSource.QUERY); + assertThat(row.getQueryRequestId()).isEqualTo(queryId); + assertThat(row.getOccurredAt()).isEqualTo(NOW); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void crossingTheWarnThresholdPublishesOnce() { + givenStatus(consumption(100L, 70)); + + service.record(usage(15, 0)); + + var captor = ArgumentCaptor.forClass(DataBudgetThresholdCrossedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + var event = captor.getValue(); + assertThat(event.exhausted()).isFalse(); + assertThat(event.usedPercent()).isEqualTo(85); + assertThat(event.organizationId()).isEqualTo(orgId); + assertThat(event.budgetId()).isEqualTo(budgetId); + assertThat(event.usedRows()).isEqualTo(85); + } + + @Test + void reachingTheLimitPublishesExhaustedOnly() { + givenStatus(consumption(100L, 70)); + + service.record(usage(200, 0)); + + var captor = ArgumentCaptor.forClass(DataBudgetThresholdCrossedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().exhausted()).isTrue(); + assertThat(captor.getValue().usedPercent()).isEqualTo(100); + assertThat(captor.getValue().breachAction()).isEqualTo(DataBudgetBreachAction.REJECT); + } + + @Test + void alreadyExhaustedNeverRepublishes() { + givenStatus(consumption(100L, 150)); + + service.record(usage(10, 0)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void crossingIsStatelessOverBeforeAndAfter() { + var before = consumption(100L, 79); + assertThat(DefaultDataBudgetUsageService.crossing(before, before.plus(1, 0))).contains(false); + assertThat(DefaultDataBudgetUsageService.crossing(before, before.plus(0, 0))).isEmpty(); + var aboveWarn = consumption(100L, 85); + assertThat(DefaultDataBudgetUsageService.crossing(aboveWarn, aboveWarn.plus(5, 0))).isEmpty(); + var noWarn = new DataBudgetConsumption(budgetId, "b", 100L, null, 60, + DataBudgetBreachAction.REJECT, null, 10, 0); + assertThat(DefaultDataBudgetUsageService.crossing(noWarn, noWarn.plus(80, 0))).isEmpty(); + } + + @Test + void theLockKeyIsStablePerUserAndDatasource() { + assertThat(DefaultDataBudgetUsageService.lockKey(userId, datasourceId)) + .isEqualTo(DefaultDataBudgetUsageService.lockKey(userId, datasourceId)) + .isNotEqualTo(DefaultDataBudgetUsageService.lockKey(datasourceId, userId)); + } + + private void givenStatus(DataBudgetConsumption consumption) { + when(statusService.statusFor(datasourceId, userId)) + .thenReturn(new DataBudgetStatus(datasourceId, "warehouse", List.of(consumption))); + } + + private DataBudgetConsumption consumption(Long maxRows, long usedRows) { + return new DataBudgetConsumption(budgetId, "Daily", maxRows, null, 1440, + DataBudgetBreachAction.REJECT, 80, usedRows, 0); + } + + private DataBudgetUsageRecord usage(long rows, long bytes) { + return new DataBudgetUsageRecord(userId, datasourceId, rows, bytes, + DataBudgetUsageSource.QUERY, queryId, null); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateServiceTest.java index 7df5a0565..cd2d4dd84 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultQueryRequestStateServiceTest.java @@ -150,6 +150,29 @@ void recordBytesScannedCapStampsTheCapWithoutTransitioning() { verify(eventPublisher, never()).publishEvent(any()); } + @Test + void recordDataBudgetReviewForcedStampsWithoutTransitioning() { + query.setStatus(QueryStatus.PENDING_AI); + when(queryRequestRepository.findByIdForUpdate(queryId)).thenReturn(Optional.of(query)); + + service.recordDataBudgetReviewForced(queryId); + + assertThat(query.isDataBudgetReviewForced()).isTrue(); + assertThat(query.getStatus()).isEqualTo(QueryStatus.PENDING_AI); + verify(queryRequestRepository).save(query); + } + + @Test + void isDataBudgetReviewForcedReadsTheStamp() { + query.setDataBudgetReviewForced(true); + when(queryRequestRepository.findById(queryId)).thenReturn(Optional.of(query)); + var other = UUID.randomUUID(); + when(queryRequestRepository.findById(other)).thenReturn(Optional.empty()); + + assertThat(service.isDataBudgetReviewForced(queryId)).isTrue(); + assertThat(service.isDataBudgetReviewForced(other)).isFalse(); + } + @Test void recordApprovalAndAdvancePromotesToApprovedAtLastStage() { query.setStatus(QueryStatus.PENDING_REVIEW); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/config/DataBudgetPropertiesTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/config/DataBudgetPropertiesTest.java new file mode 100644 index 000000000..e8b50107a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/config/DataBudgetPropertiesTest.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.core.internal.config; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataBudgetPropertiesTest { + + @Test + void defaultsAndFloorsTheRetention() { + assertThat(new DataBudgetProperties(null).usageRetention()) + .isEqualTo(DataBudgetProperties.DEFAULT_RETENTION); + assertThat(new DataBudgetProperties(Duration.ofDays(1)).usageRetention()) + .isEqualTo(DataBudgetProperties.MIN_RETENTION); + assertThat(new DataBudgetProperties(Duration.ofDays(90)).usageRetention()) + .isEqualTo(Duration.ofDays(90)); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJobTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJobTest.java new file mode 100644 index 000000000..28627d742 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/scheduled/DataBudgetUsagePruneJobTest.java @@ -0,0 +1,48 @@ +package com.bablsoft.accessflow.core.internal.scheduled; + +import com.bablsoft.accessflow.core.internal.config.DataBudgetProperties; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DataBudgetUsagePruneJobTest { + + private static final Instant NOW = Instant.parse("2026-09-25T12:00:00Z"); + + @Mock DataBudgetUsageRepository usageRepository; + + @Test + void deletesRowsOlderThanTheRetention() { + var job = new DataBudgetUsagePruneJob(usageRepository, + new DataBudgetProperties(Duration.ofDays(40)), Clock.fixed(NOW, ZoneOffset.UTC)); + var cutoff = NOW.minus(Duration.ofDays(40)); + when(usageRepository.deleteOlderThan(cutoff)).thenReturn(3); + + job.run(); + + verify(usageRepository).deleteOlderThan(cutoff); + } + + @Test + void nothingToPruneIsQuiet() { + var job = new DataBudgetUsagePruneJob(usageRepository, new DataBudgetProperties(null), + Clock.fixed(NOW, ZoneOffset.UTC)); + var cutoff = NOW.minus(DataBudgetProperties.DEFAULT_RETENTION); + when(usageRepository.deleteOlderThan(cutoff)).thenReturn(0); + + job.run(); + + verify(usageRepository).deleteOlderThan(cutoff); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNoticeTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNoticeTest.java new file mode 100644 index 000000000..8ba1846b5 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNoticeTest.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.notifications.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataBudgetNoticeTest { + + private static DataBudgetNotice notice(Long maxRows, Long maxBytes, int windowMinutes) { + return new DataBudgetNotice(UUID.randomUUID(), "b", false, 80, 82, maxRows, maxBytes, 820L, + 1_500_000L, windowMinutes, DataBudgetBreachAction.REJECT); + } + + @Test + void usageLinesOnlyForTheLimitsTheBudgetSets() { + var rowsOnly = notice(1000L, null, 60); + assertThat(rowsOnly.rowsUsage()).isEqualTo("820 / 1000 rows"); + assertThat(rowsOnly.bytesUsage()).isNull(); + + var bytesOnly = notice(null, 5_000_000L, 60); + assertThat(bytesOnly.rowsUsage()).isNull(); + assertThat(bytesOnly.bytesUsage()).isEqualTo("1.5 MB (1500000 B) / 5 MB (5000000 B)"); + } + + @ParameterizedTest + @CsvSource({ + "1440, DAYS, 1, 1 day", + "10080, DAYS, 7, 7 days", + "60, HOURS, 1, 1 hour", + "180, HOURS, 3, 3 hours", + "90, MINUTES, 90, 90 minutes", + "1, MINUTES, 1, 1 minute", + "0, MINUTES, 0, 0 minutes"}) + void windowRendersInTheLargestWholeUnit(int minutes, String unit, int value, String label) { + var n = notice(1L, null, minutes); + assertThat(n.windowUnit()).isEqualTo(unit); + assertThat(n.windowValue()).isEqualTo(value); + assertThat(n.windowLabel()).isEqualTo(label); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListenerTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListenerTest.java new file mode 100644 index 000000000..3ba9cc006 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/DataBudgetNotificationListenerTest.java @@ -0,0 +1,48 @@ +package com.bablsoft.accessflow.notifications.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class DataBudgetNotificationListenerTest { + + @Mock private NotificationDispatcher dispatcher; + @InjectMocks private DataBudgetNotificationListener listener; + + private static DataBudgetThresholdCrossedEvent event(boolean exhausted) { + return new DataBudgetThresholdCrossedEvent(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), "daily-reads", exhausted, 80, exhausted ? 100 : 80, 1000L, null, + 800L, 0L, 1440, DataBudgetBreachAction.REJECT); + } + + @Test + void forwardsEveryCrossingToTheDispatcher() { + var warning = event(false); + var exhausted = event(true); + + listener.onThresholdCrossed(warning); + listener.onThresholdCrossed(exhausted); + + verify(dispatcher).dispatchDataBudget(warning); + verify(dispatcher).dispatchDataBudget(exhausted); + } + + @Test + void aDispatchFailureNeverPropagates() { + var event = event(true); + doThrow(new IllegalStateException("boom")).when(dispatcher).dispatchDataBudget(event); + + assertThatCode(() -> listener.onThresholdCrossed(event)).doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilderTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilderTest.java index 15840872a..99127f606 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilderTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationContextBuilderTest.java @@ -801,6 +801,68 @@ void buildSchemaDriftEmptyWhenTheTargetIsGone() { UUID.randomUUID(), orgId, UUID.randomUUID(), UUID.randomUUID(), 1))).isEmpty(); } + private com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent budgetEvent(boolean exhausted) { + return new com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent(orgId, submitterId, + datasourceId, UUID.randomUUID(), "daily-reads", exhausted, 80, exhausted ? 100 : 82, + 1000L, 5_000_000L, exhausted ? 1000L : 820L, 1_200_000L, 1440, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT); + } + + @Test + void dataBudgetWarningGoesToTheUserOnly() { + var alice = user(submitterId, "alice@example.com", UserRoleType.ANALYST); + when(userQuery.findByIds(List.of(submitterId))).thenReturn(List.of(alice)); + + var ctx = builder.buildDataBudget(budgetEvent(false)).orElseThrow(); + + assertThat(ctx.eventType()).isEqualTo(NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED); + assertThat(ctx.isDataBudgetEvent()).isTrue(); + assertThat(ctx.recipients()).extracting(RecipientView::userId).containsExactly(submitterId); + assertThat(ctx.datasourceId()).isEqualTo(datasourceId); + assertThat(ctx.datasourceName()).isEqualTo("Production"); + assertThat(ctx.submitterEmail()).isEqualTo("alice@example.com"); + assertThat(ctx.dataBudget().budgetName()).isEqualTo("daily-reads"); + assertThat(ctx.dataBudget().usedPercent()).isEqualTo(82); + assertThat(ctx.reviewUrl()).hasToString("https://app.example.test/editor"); + org.mockito.Mockito.verifyNoInteractions(permissionHolderLookup); + } + + @Test + void dataBudgetExhaustionAlsoReachesBudgetManagersWithoutDuplicates() { + var alice = user(submitterId, "alice@example.com", UserRoleType.ANALYST); + var manager = user(UUID.randomUUID(), "mgr@example.com", UserRoleType.ADMIN); + when(permissionHolderLookup.findUserIdsWithPermission(orgId, + com.bablsoft.accessflow.core.api.Permission.DATA_BUDGET_MANAGE)) + .thenReturn(List.of(manager.id(), submitterId)); + when(userQuery.findByIds(List.of(submitterId, manager.id()))).thenReturn(List.of(alice, manager)); + + var ctx = builder.buildDataBudget(budgetEvent(true)).orElseThrow(); + + assertThat(ctx.eventType()).isEqualTo(NotificationEventType.DATA_BUDGET_EXHAUSTED); + assertThat(ctx.recipients()).extracting(RecipientView::userId) + .containsExactly(submitterId, manager.id()); + assertThat(ctx.dataBudget().exhausted()).isTrue(); + } + + @Test + void dataBudgetSurvivesADeletedDatasource() { + var alice = user(submitterId, "alice@example.com", UserRoleType.ANALYST); + when(userQuery.findByIds(List.of(submitterId))).thenReturn(List.of(alice)); + when(datasourceAdmin.getForAdmin(eq(datasourceId), eq(orgId))) + .thenThrow(new com.bablsoft.accessflow.core.api.DatasourceNotFoundException(datasourceId)); + + var ctx = builder.buildDataBudget(budgetEvent(false)).orElseThrow(); + + assertThat(ctx.datasourceName()).isNull(); + } + + @Test + void dataBudgetEmptyWhenNobodyActiveIsLeft() { + when(userQuery.findByIds(List.of(submitterId))).thenReturn(List.of()); + + assertThat(builder.buildDataBudget(budgetEvent(false))).isEmpty(); + } + private com.bablsoft.accessflow.schemachange.api.SchemaChangePromotionNotificationView promotionView( UUID promotionId, UUID promoterId) { return new com.bablsoft.accessflow.schemachange.api.SchemaChangePromotionNotificationView(promotionId, diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcherTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcherTest.java index 34aea6e8a..89fef86dc 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcherTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/NotificationDispatcherTest.java @@ -406,6 +406,56 @@ void unknownSchemaChangeTargetsShortCircuit() { any(), any(), any()); } + /** #942: budgets are per user, not per plan — both events fan out org-wide and record in-app. */ + @Test + void dataBudgetEventsUseAllActiveChannelsAndCarryTheDatasourceInThePayload() { + var userId = UUID.randomUUID(); + var emailCh = channel(NotificationChannelType.EMAIL); + when(channelRepository.findAllByOrganizationIdAndActiveTrue(orgId)).thenReturn(List.of(emailCh)); + for (var exhausted : List.of(false, true)) { + var event = new com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent(orgId, userId, + datasourceId, UUID.randomUUID(), "daily-reads", exhausted, 80, exhausted ? 100 : 85, + 1000L, null, 850L, 0L, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW); + var type = exhausted ? NotificationEventType.DATA_BUDGET_EXHAUSTED + : NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED; + when(contextBuilder.buildDataBudget(event)).thenReturn(Optional.of(sampleDataBudgetContext(type, + userId, List.of(new RecipientView(userId, "u@x", "U"))))); + dispatcher.dispatchDataBudget(event); + } + + verify(emailStrategy, org.mockito.Mockito.times(2)).deliver(any(), eq(emailCh)); + verify(contextBuilder, never()).lookupPlanChannelIds(any()); + var payloadCaptor = ArgumentCaptor.forClass(String.class); + verify(userNotificationService).recordForUsers( + eq(NotificationEventType.DATA_BUDGET_EXHAUSTED), + eq(Set.of(userId)), + eq(orgId), + isNull(), + isNull(), + isNull(), + isNull(), + payloadCaptor.capture()); + assertThat(payloadCaptor.getValue()) + .contains("\"datasource_id\":\"" + datasourceId + "\"") + .contains("\"datasource\":\"warehouse\"") + .contains("\"budget\":\"daily-reads\"") + .contains("\"used_percent\":100"); + } + + @Test + void dataBudgetWithNoRecipientShortCircuits() { + when(contextBuilder.buildDataBudget(any())).thenReturn(Optional.empty()); + + dispatcher.dispatchDataBudget(new com.bablsoft.accessflow.core.events.DataBudgetThresholdCrossedEvent( + orgId, UUID.randomUUID(), datasourceId, UUID.randomUUID(), "b", false, 80, 80, 10L, null, + 8L, 0L, 60, com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT)); + + verify(channelRepository, never()).findAllByOrganizationIdAndActiveTrue(any()); + verify(userNotificationService, never()).recordForUsers(any(), any(), any(), any(), any(), + any(), any(), any()); + } + @Test void unknownDeploymentRequestShortCircuits() { when(contextBuilder.buildDeployment(any(), any(), any(), any())) @@ -598,6 +648,34 @@ private NotificationContext sampleSchemaChangeContext(NotificationEventType type newFindingCount); } + private NotificationContext sampleDataBudgetContext(NotificationEventType type, UUID userId, + List recipients) { + var exhausted = type == NotificationEventType.DATA_BUDGET_EXHAUSTED; + return new NotificationContext( + type, orgId, null, + null, null, null, null, + null, null, null, + datasourceId, "warehouse", + userId, "u@x", "U", + null, + null, null, null, + null, + recipients, Instant.now(), "en", null, + null, null, null, null, null, null, + null, + null, null, null, + null, + null, null, null, + null, null, null, + null, null, null, + null, null, null, null, null, + null, null, null, null, + null, + new DataBudgetNotice(UUID.randomUUID(), "daily-reads", exhausted, 80, exhausted ? 100 : 85, + 1000L, null, 850L, 0L, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW)); + } + private NotificationContext sampleExecutedContext(List recipients) { return new NotificationContext( NotificationEventType.QUERY_EXECUTED, diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetNotificationTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetNotificationTest.java new file mode 100644 index 000000000..944886ff2 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetNotificationTest.java @@ -0,0 +1,267 @@ +package com.bablsoft.accessflow.notifications.internal.strategy; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.notifications.api.NotificationEventType; +import com.bablsoft.accessflow.notifications.internal.DataBudgetNotice; +import com.bablsoft.accessflow.notifications.internal.NotificationContext; +import com.bablsoft.accessflow.notifications.internal.RecipientView; +import com.bablsoft.accessflow.notifications.internal.codec.DiscordChannelConfig; +import com.bablsoft.accessflow.notifications.internal.codec.PagerDutyChannelConfig; +import com.bablsoft.accessflow.notifications.internal.codec.PagerDutySeverity; +import com.bablsoft.accessflow.notifications.internal.codec.PagerDutyTrigger; +import com.bablsoft.accessflow.notifications.internal.codec.TicketingTrigger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.thymeleaf.context.Context; +import org.thymeleaf.messageresolver.StandardMessageResolver; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.spring6.messageresolver.SpringMessageResolver; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; +import tools.jackson.databind.json.JsonMapper; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers the #942 data-budget fan-out on every channel: both events render a type-specific title + * and the budget field set, never the generic query fields or a default branch — PagerDuty's + * summary, both silent email switches and the ticket headline included. + */ +class DataBudgetNotificationTest { + + private static final String ALL = "DATA_BUDGET_.*"; + + private static final List TEMPLATES = List.of( + "email/data-budget-threshold-reached", + "email/data-budget-exhausted"); + + private final JsonMapper json = JsonMapper.builder().build(); + + /** A fully populated context for one data-budget event, as the context builder shapes it. */ + static NotificationContext dataBudgetCtx(NotificationEventType type) { + var exhausted = type == NotificationEventType.DATA_BUDGET_EXHAUSTED; + return new NotificationContext( + type, UUID.randomUUID(), null, + null, null, null, null, + null, null, null, + UUID.randomUUID(), "warehouse", + UUID.randomUUID(), "ana@example.com", "Ana Analyst", + null, + null, null, null, + URI.create("https://app.example.test/editor"), + List.of(new RecipientView(UUID.randomUUID(), "rcpt@example.com", "R")), + Instant.now(), "en", null, + null, null, null, null, null, null, + null, + null, null, null, + null, + null, null, null, + null, null, null, + null, null, null, + null, null, null, null, null, + null, null, null, null, + null, + new DataBudgetNotice(UUID.randomUUID(), "daily-reads", exhausted, 80, + exhausted ? 100 : 85, 1000L, 5_000_000_000L, exhausted ? 1000L : 850L, + 1_200_000_000L, 1440, DataBudgetBreachAction.REQUIRE_REVIEW)); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void slackRendersADataBudgetTitleAndFieldSet(NotificationEventType type) { + var payload = new SlackBlockKitFactory().buildEventPayload(dataBudgetCtx(type), null); + var text = payload.getText() + "\n" + payload.getBlocks().toString(); + + assertThat(text).contains("Data Budget").contains("warehouse").contains("daily-reads") + .contains("ana@example.com").doesNotContain("Submitted by").doesNotContain(type.name()); + assertExtras(type, text); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void discordRendersADataBudgetTitleAndFieldSet(NotificationEventType type) { + var body = new DiscordPayloadFactory(json).buildEventBody(dataBudgetCtx(type), + new DiscordChannelConfig(URI.create("https://discord.example/hook"), null, null)); + + assertThat(body).contains("Data Budget").contains("warehouse").contains("daily-reads") + .contains("https://app.example.test/editor").doesNotContain("\"Submitted by\""); + assertExtras(type, body); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void teamsRendersADataBudgetTitleAndFieldSet(NotificationEventType type) { + var body = new MsTeamsPayloadFactory(json).buildEventBody(dataBudgetCtx(type)); + + assertThat(body).contains("Data Budget").contains("warehouse").contains("daily-reads") + .contains("Action.OpenUrl").doesNotContain("\"Submitted by\""); + assertExtras(type, body); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void telegramRendersADataBudgetTitleAndFieldSet(NotificationEventType type) { + var body = new TelegramMessageFactory(json).buildEventBody(dataBudgetCtx(type), "42").replace("\\\\", ""); + + assertThat(body).contains("Data Budget").contains("warehouse").contains("daily-reads") + .contains("Open the query editor").doesNotContain("Submitted by"); + assertExtras(type, body); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void pagerDutyHasASpecificSummaryEvenThoughNothingPages(NotificationEventType type) { + var body = new PagerDutyPayloadFactory(json).buildEventBody(dataBudgetCtx(type), + new PagerDutyChannelConfig("KEY", PagerDutySeverity.WARNING, EnumSet.allOf(PagerDutyTrigger.class))); + + assertThat(body).contains("data budget").contains("on warehouse") + .contains("\"budget_name\":\"daily-reads\"").doesNotContain("for a query"); + assertThat(PagerDutyTrigger.forEvent(type)).as("%s must not page", type).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void webhookCarriesTheAdditiveDataBudgetBlock(NotificationEventType type) { + var ctx = dataBudgetCtx(type); + var tree = json.readTree(new WebhookPayloadFactory(json).buildBody(ctx)); + + assertThat(tree.path("event").asString()).isEqualTo(type.name()); + var block = tree.path("data_budget"); + assertThat(block.isObject()).isTrue(); + assertThat(block.path("budget_name").asString()).isEqualTo("daily-reads"); + assertThat(block.path("datasource_id").asString()).isEqualTo(ctx.datasourceId().toString()); + assertThat(block.path("user_id").asString()).isEqualTo(ctx.submittedByUserId().toString()); + assertThat(block.path("exhausted").asBoolean()) + .isEqualTo(type == NotificationEventType.DATA_BUDGET_EXHAUSTED); + assertThat(block.path("window_minutes").asInt()).isEqualTo(1440); + assertThat(block.path("breach_action").asString()).isEqualTo("REQUIRE_REVIEW"); + assertThat(tree.has("query_request")).isTrue(); + assertThat(tree.has("schema_change")).isFalse(); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void ticketHeadlineIsSpecificAndNothingTickets(NotificationEventType type) { + assertThat(TicketDescriptionBuilder.summary(dataBudgetCtx(type))) + .contains("Data budget").contains("on warehouse") + .doesNotContain(type.name()); + assertThat(TicketingTrigger.forEvent(type)).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = NotificationEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = ALL) + void everyEventRoutesToAnEmailTemplate(NotificationEventType type) { + assertThat(EmailNotificationStrategy.hasTemplateFor(type)).isTrue(); + assertThat(EmailNotificationStrategy.hasTemplateFor(dataBudgetCtx(type))).isTrue(); + } + + @Test + void thresholdTemplateShowsUsageThresholdAndCta() { + var html = buildEngine().process("email/data-budget-threshold-reached", templateContext(Locale.ENGLISH)); + + assertThat(html).contains("warehouse").contains("daily-reads").contains("85%") + .contains("850 / 1000").contains("1.2 GB").contains("1 day").contains("80%") + .contains("Ana Analyst").contains("https://app.example.test/editor") + .doesNotContain("When used up"); + } + + @Test + void exhaustedTemplateShowsTheBreachActionAndOmitsUnsetLimits() { + var ctx = templateContext(Locale.ENGLISH); + ctx.setVariable("dataBudgetMaxRows", null); + ctx.setVariable("dataBudgetWindowUnit", "HOURS"); + ctx.setVariable("dataBudgetWindowValue", 3); + ctx.setVariable("reviewUrl", null); + + var html = buildEngine().process("email/data-budget-exhausted", ctx); + + assertThat(html).contains("Further reads go to human review").contains("3 hours") + .doesNotContain("Rows:").doesNotContain("href=\"null\""); + } + + @ParameterizedTest + @ValueSource(strings = {"en", "de", "es", "fr", "hy", "ru", "zh-CN"}) + void everyTemplateRendersInEveryShippedLocale(String locale) { + var engine = buildEngine(); + for (var template : TEMPLATES) { + assertThat(engine.process(template, templateContext(Locale.forLanguageTag(locale)))) + .as("template %s in locale %s", template, locale) + .doesNotContain("??notification.email"); + } + } + + @ParameterizedTest + @ValueSource(strings = {"en", "de", "es", "fr", "hy", "ru", "zh-CN"}) + void subjectsFillEveryPlaceholderInEveryShippedLocale(String locale) { + var messages = messageSource(); + var loc = Locale.forLanguageTag(locale); + var args = new Object[]{"daily-reads", "warehouse", 85}; + assertThat(messages.getMessage("notification.email.subject.data_budget_threshold_reached", args, loc)) + .contains("daily-reads").contains("warehouse").contains("85").doesNotContain("{"); + assertThat(messages.getMessage("notification.email.subject.data_budget_exhausted", args, loc)) + .contains("daily-reads").contains("warehouse").doesNotContain("{"); + } + + private static void assertExtras(NotificationEventType type, String rendered) { + assertThat(rendered).contains("850 / 1000 rows".replace("850", type == NotificationEventType.DATA_BUDGET_EXHAUSTED + ? "1000" : "850")).contains("1 day"); + if (type == NotificationEventType.DATA_BUDGET_EXHAUSTED) { + assertThat(rendered).contains("REQUIRE_REVIEW").contains("100%"); + } else { + assertThat(rendered).doesNotContain("REQUIRE_REVIEW").contains("85%"); + } + } + + private static Context templateContext(Locale locale) { + var ctx = new Context(locale); + ctx.setVariable("datasourceName", "warehouse"); + ctx.setVariable("submitterEmail", "ana@example.com"); + ctx.setVariable("submitterDisplayName", "Ana Analyst"); + ctx.setVariable("dataBudgetName", "daily-reads"); + ctx.setVariable("dataBudgetUsedPercent", 85); + ctx.setVariable("dataBudgetWarnThresholdPercent", 80); + ctx.setVariable("dataBudgetUsedRows", 850L); + ctx.setVariable("dataBudgetMaxRows", 1000L); + ctx.setVariable("dataBudgetBytesUsage", "1.2 GB (1200000000 B) / 5 GB (5000000000 B)"); + ctx.setVariable("dataBudgetWindowUnit", "DAYS"); + ctx.setVariable("dataBudgetWindowValue", 1); + ctx.setVariable("dataBudgetBreachAction", "REQUIRE_REVIEW"); + ctx.setVariable("reviewUrl", "https://app.example.test/editor"); + return ctx; + } + + private static ReloadableResourceBundleMessageSource messageSource() { + var messages = new ReloadableResourceBundleMessageSource(); + messages.setBasename("classpath:i18n/messages"); + messages.setDefaultEncoding(StandardCharsets.UTF_8.name()); + messages.setFallbackToSystemLocale(false); + return messages; + } + + private static SpringTemplateEngine buildEngine() { + var resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix("templates/"); + resolver.setSuffix(".html"); + resolver.setTemplateMode(TemplateMode.HTML); + resolver.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resolver.setCacheable(false); + var springResolver = new SpringMessageResolver(); + springResolver.setMessageSource(messageSource()); + var engine = new SpringTemplateEngine(); + engine.setTemplateResolver(resolver); + engine.setMessageResolvers(Set.of(springResolver, new StandardMessageResolver())); + return engine; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetTextTest.java b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetTextTest.java new file mode 100644 index 000000000..7dc95f7a7 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/notifications/internal/strategy/DataBudgetTextTest.java @@ -0,0 +1,42 @@ +package com.bablsoft.accessflow.notifications.internal.strategy; + +import com.bablsoft.accessflow.notifications.api.NotificationEventType; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataBudgetTextTest { + + @Test + void warningListsUsageButNotTheBreachAction() { + var fields = DataBudgetText.fields( + DataBudgetNotificationTest.dataBudgetCtx(NotificationEventType.DATA_BUDGET_THRESHOLD_REACHED)); + + assertThat(fields).extracting(Map.Entry::getKey) + .containsExactly("Datasource", "User", "Budget", "Used", "Rows", "Bytes", "Window"); + assertThat(fields).contains(Map.entry("User", "Ana Analyst (ana@example.com)"), + Map.entry("Used", "85%"), Map.entry("Window", "1 day")); + } + + @Test + void exhaustionAddsTheBreachAction() { + var fields = DataBudgetText.fields( + DataBudgetNotificationTest.dataBudgetCtx(NotificationEventType.DATA_BUDGET_EXHAUSTED)); + + assertThat(fields).contains(Map.entry("On breach", "REQUIRE_REVIEW")); + } + + @Test + void missingNamesRenderAsDashes() { + var ctx = DataBudgetNotificationTest.dataBudgetCtx(NotificationEventType.DATA_BUDGET_EXHAUSTED); + var bare = new com.bablsoft.accessflow.notifications.internal.NotificationContext( + ctx.eventType(), ctx.organizationId(), null, null, null, null, null, null, null, null, + ctx.datasourceId(), null, null, null, null, null, null, null, null, null, + ctx.recipients(), ctx.occurredAt(), "en", null); + + assertThat(DataBudgetText.fields(bare)) + .containsExactly(Map.entry("Datasource", "—"), Map.entry("User", "—")); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java index 5de6382d4..124929a66 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryExecutorTest.java @@ -912,4 +912,42 @@ private static ResultSet emptyResultSet() throws SQLException { when(rs.next()).thenReturn(false); return rs; } + + @Test + void measureBytesStampsTheDeliveredSizeWithoutAnOverride() { + List> rows = List.of(List.of("abc"), List.of("defg")); + var result = new SelectExecutionResult(List.of(), rows, 2, false, Duration.ZERO); + + var measured = DefaultQueryExecutor.measureBytes(result, null); + + assertThat(measured.resultBytes()).isEqualTo(ResultByteEstimator.estimateRow(rows.get(0)) + + ResultByteEstimator.estimateRow(rows.get(1))); + assertThat(measured.truncated()).isFalse(); + assertThat(measured.rowCount()).isEqualTo(2); + } + + @Test + void measureBytesTrimsPastTheOverrideAndAttributesItToTheBudget() { + List> rows = List.of(List.of("aaaa"), List.of("bbbb"), List.of("cccc")); + var one = ResultByteEstimator.estimateRow(rows.get(0)); + var result = new SelectExecutionResult(List.of(), rows, 3, false, Duration.ZERO); + + var trimmed = DefaultQueryExecutor.measureBytes(result, one + 1); + + assertThat(trimmed.rowCount()).isEqualTo(1); + assertThat(trimmed.truncated()).isTrue(); + assertThat(trimmed.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + assertThat(trimmed.resultBytes()).isEqualTo(one); + } + + @Test + void measureBytesAlwaysKeepsTheFirstRow() { + List> rows = List.of(List.of("a very long value indeed")); + var result = new SelectExecutionResult(List.of(), rows, 1, false, Duration.ZERO); + + var kept = DefaultQueryExecutor.measureBytes(result, 1L); + + assertThat(kept.rowCount()).isEqualTo(1); + assertThat(kept.truncated()).isFalse(); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java index 2549c01b0..5a79ad67e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java @@ -56,6 +56,13 @@ class DefaultSampleDataServiceTest { @Mock private org.springframework.context.MessageSource messageSource; + @Mock + private com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; + @Mock + private com.bablsoft.accessflow.core.api.DataBudgetUsageService dataBudgetUsageService; + @Mock + private com.bablsoft.accessflow.audit.api.AuditLogService auditLogService; + @InjectMocks private DefaultSampleDataService service; @@ -68,6 +75,9 @@ class DefaultSampleDataServiceTest { @BeforeEach void setUp() { + lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none( + inv.getArgument(0))); var schemaView = new DatabaseSchemaView(List.of( new DatabaseSchemaView.Schema("public", List.of( new DatabaseSchemaView.Table("Users", List.of( @@ -475,4 +485,114 @@ private DatasourceUserPermissionView denying(List allowedSchemas, false, false, false, allowedSchemas, List.of(), List.of(), null, deniedSchemas, deniedTables, List.of(), null, null); } + + @Test + void exhaustedBudgetRefusesThePreviewWhateverTheAction() { + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + var budget = new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", 10L, null, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, null, 10, 0); + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", + List.of(budget))); + when(messageSource.getMessage(eq("error.data_budget.exhausted"), any(), any())) + .thenReturn("used up"); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, true, + "public", "users", 50)) + .isInstanceOf(com.bablsoft.accessflow.core.api.DataBudgetExhaustedException.class) + .hasMessage("used up"); + verify(queryExecutor, never()).sampleTable(any()); + var audit = ArgumentCaptor.forClass(com.bablsoft.accessflow.audit.api.AuditEntry.class); + verify(auditLogService).record(audit.capture()); + assertThat(audit.getValue().action()) + .isEqualTo(com.bablsoft.accessflow.audit.api.AuditAction.QUERY_DATA_BUDGET_ENFORCED); + assertThat(audit.getValue().metadata()).containsEntry("stage", "sample") + .containsEntry("table", "public.users"); + } + + @Test + void remainingAllowanceCapsThePreviewAndIsCharged() { + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + var budget = new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", 10L, 1_000_000L, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, null, 7, 0); + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", + List.of(budget))); + List> rows = List.of(List.of("a"), List.of("b")); + when(queryExecutor.sampleTable(any())).thenReturn( + new SelectExecutionResult(List.of(), rows, 2, false, Duration.ZERO)); + + var out = service.sample(datasourceId, organizationId, userId, true, "public", "users", 50); + + var captor = ArgumentCaptor.forClass(SampleTableRequest.class); + verify(queryExecutor).sampleTable(captor.capture()); + assertThat(captor.getValue().maxRowsOverride()).isEqualTo(3); + assertThat(out.resultBytes()).isPositive(); + var usage = ArgumentCaptor.forClass(com.bablsoft.accessflow.core.api.DataBudgetUsageRecord.class); + verify(dataBudgetUsageService).record(usage.capture()); + assertThat(usage.getValue().rowsRead()).isEqualTo(2); + assertThat(usage.getValue().bytesRead()).isEqualTo(out.resultBytes()); + assertThat(usage.getValue().source()) + .isEqualTo(com.bablsoft.accessflow.core.api.DataBudgetUsageSource.SAMPLE_DATA); + } + + @Test + void aPreviewCutAtTheBudgetRowAllowanceIsAttributedToTheBudget() { + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + var budget = new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", 10L, null, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, null, 8, 0); + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", + List.of(budget))); + List> rows = List.of(List.of("a"), List.of("b")); + when(queryExecutor.sampleTable(any())).thenReturn(new SelectExecutionResult(List.of(), rows, + 2, true, Duration.ZERO, java.util.Set.of(), java.util.Set.of(), + SelectExecutionResult.TRUNCATED_ROW_LIMIT)); + + var out = service.sample(datasourceId, organizationId, userId, true, "public", "users", 50); + + assertThat(out.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + } + + @Test + void aPreviewCutByItsOwnLimitKeepsTheRowLimitReason() { + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + var budget = new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", 1_000L, null, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, null, 0, 0); + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", + List.of(budget))); + List> rows = List.of(List.of("a"), List.of("b")); + when(queryExecutor.sampleTable(any())).thenReturn(new SelectExecutionResult(List.of(), rows, + 2, true, Duration.ZERO, java.util.Set.of(), java.util.Set.of(), + SelectExecutionResult.TRUNCATED_ROW_LIMIT)); + + var out = service.sample(datasourceId, organizationId, userId, true, "public", "users", 2); + + assertThat(out.truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + } + + @Test + void aFailedUsageWriteNeverFailsThePreview() { + when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.empty()); + var budget = new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", null, 1_000_000L, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, null, 0, 0); + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", + List.of(budget))); + org.mockito.Mockito.doThrow(new IllegalStateException("db down")) + .when(dataBudgetUsageService).record(any()); + + var out = service.sample(datasourceId, organizationId, userId, true, "public", "users", 50); + + assertThat(out.rowCount()).isZero(); + var captor = ArgumentCaptor.forClass(SampleTableRequest.class); + verify(queryExecutor).sampleTable(captor.capture()); + assertThat(captor.getValue().maxRowsOverride()).isEqualTo(50); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java index d3a70b70b..094cf590c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java @@ -69,6 +69,10 @@ class GroupExecutionServiceTest { private com.bablsoft.accessflow.proxy.api.QueryCostEstimateService queryCostEstimateService; @Mock private com.bablsoft.accessflow.requestgroups.internal.persistence.repo.GroupReviewDecisionRepository decisionRepository; + @Mock + private com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; + @Mock + private com.bablsoft.accessflow.core.api.DataBudgetUsageService dataBudgetUsageService; @InjectMocks private GroupExecutionService service; @@ -76,6 +80,9 @@ class GroupExecutionServiceTest { @BeforeEach void setUp() { + org.mockito.Mockito.lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none( + inv.getArgument(0))); group = new RequestGroupEntity(); group.setId(UUID.randomUUID()); group.setOrganizationId(UUID.randomUUID()); @@ -259,6 +266,94 @@ void aQueryMemberWithoutAnEstimateIsRefusedWhenNoPersonApprovedTheGroup() { verify(queryExecutor, org.mockito.Mockito.never()).execute(any()); } + private void givenBudget(RequestGroupItemEntity item, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction action, + long usedRows) { + when(dataBudgetStatusService.statusFor(item.getDatasourceId(), group.getSubmittedBy())) + .thenReturn(new com.bablsoft.accessflow.core.api.DataBudgetStatus( + item.getDatasourceId(), "ds", List.of( + new com.bablsoft.accessflow.core.api.DataBudgetConsumption( + UUID.randomUUID(), "Daily", 100L, null, 60, action, null, + usedRows, 0)))); + } + + @Test + void aQueryMemberIsCappedToTheRemainingAllowanceAndCharged() { + var item = queryItem(); + givenBudget(item, com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 95); + when(queryExecutor.execute(any())).thenReturn( + new com.bablsoft.accessflow.core.api.SelectExecutionResult( + List.of(), List.of(), 5L, true, java.time.Duration.ofMillis(3))); + + service.execute(group.getId(), null, "manual"); + + assertThat(item.getStatus()).isEqualTo(RequestGroupItemStatus.EXECUTED); + var request = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.core.api.QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxRowsOverride()).isEqualTo(5); + var usage = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.core.api.DataBudgetUsageRecord.class); + verify(dataBudgetUsageService).record(usage.capture()); + assertThat(usage.getValue().requestGroupId()).isEqualTo(group.getId()); + assertThat(usage.getValue().source()) + .isEqualTo(com.bablsoft.accessflow.core.api.DataBudgetUsageSource.REQUEST_GROUP); + } + + @Test + void anExhaustedRejectBudgetFailsTheMemberWithoutRunning() { + var item = queryItem(); + givenBudget(item, com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100); + when(messageSource.getMessage(org.mockito.ArgumentMatchers.eq("error.data_budget.exhausted"), + any(), any())).thenReturn("used up"); + + service.execute(group.getId(), null, "manual"); + + assertThat(item.getStatus()).isEqualTo(RequestGroupItemStatus.FAILED); + assertThat(item.getErrorMessage()).isEqualTo("used up"); + verify(queryExecutor, org.mockito.Mockito.never()).execute(any()); + var audit = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.audit.api.AuditEntry.class); + verify(auditLogService, org.mockito.Mockito.atLeastOnce()).record(audit.capture()); + assertThat(audit.getAllValues()).anySatisfy(entry -> { + assertThat(entry.action()) + .isEqualTo(com.bablsoft.accessflow.audit.api.AuditAction.QUERY_DATA_BUDGET_ENFORCED); + assertThat(entry.metadata()).containsEntry("item_id", item.getId().toString()) + .containsEntry("window_minutes", 60); + }); + } + + @Test + void anExhaustedReviewBudgetFailsTheMemberEvenOnceAPersonApprovedTheGroup() { + var item = queryItem(); + givenBudget(item, com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100); + // A person approved the group — which, fail-closed, must not lift an exhausted budget. + org.mockito.Mockito.lenient().when(decisionRepository.existsByRequestGroupIdAndDecision(group.getId(), + com.bablsoft.accessflow.core.api.DecisionType.APPROVED)).thenReturn(true); + when(messageSource.getMessage(org.mockito.ArgumentMatchers.eq("error.data_budget.exhausted"), + any(), any())).thenReturn("used up"); + + service.execute(group.getId(), null, "manual"); + + assertThat(item.getStatus()).isEqualTo(RequestGroupItemStatus.FAILED); + verify(queryExecutor, org.mockito.Mockito.never()).execute(any()); + } + + @Test + void aFailedUsageWriteNeverFailsTheMember() { + var item = queryItem(); + givenBudget(item, com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 0); + when(queryExecutor.execute(any())).thenReturn( + new com.bablsoft.accessflow.core.api.SelectExecutionResult( + List.of(), List.of(), 1L, false, java.time.Duration.ofMillis(3))); + org.mockito.Mockito.doThrow(new IllegalStateException("ledger down")) + .when(dataBudgetUsageService).record(any()); + + service.execute(group.getId(), null, "manual"); + + assertThat(item.getStatus()).isEqualTo(RequestGroupItemStatus.EXECUTED); + } + @Test void anUncappedQueryMemberIsNeverDryRun() { queryItem(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DataBudgetControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DataBudgetControllerIntegrationTest.java new file mode 100644 index 000000000..722b55b27 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DataBudgetControllerIntegrationTest.java @@ -0,0 +1,375 @@ +package com.bablsoft.accessflow.security.internal.web; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; +import com.bablsoft.accessflow.core.api.DbType; +import com.bablsoft.accessflow.core.api.SslMode; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetUsageEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceUserPermissionEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceUserPermissionRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.security.internal.jwt.JwtService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * #942: admin CRUD, the caller's own standing and the admin per-user view, end to end over the + * real ledger. Identifiers are randomized and cleanup is scoped to what this class created, so it + * never trips over another class's rows in the shared container. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class DataBudgetControllerIntegrationTest { + + @Autowired WebApplicationContext context; + @Autowired UserRepository userRepository; + @Autowired OrganizationRepository organizationRepository; + @Autowired DatasourceRepository datasourceRepository; + @Autowired DatasourceUserPermissionRepository permissionRepository; + @Autowired DataBudgetRepository dataBudgetRepository; + @Autowired DataBudgetUsageRepository usageRepository; + @Autowired PasswordEncoder passwordEncoder; + @Autowired JwtService jwtService; + @Autowired CredentialEncryptionService encryptionService; + + private final String suffix = UUID.randomUUID().toString().substring(0, 8); + private final List createdPermissions = new ArrayList<>(); + private MockMvcTester mvc; + private OrganizationEntity primaryOrg; + private OrganizationEntity otherOrg; + private UserEntity admin; + private UserEntity analyst; + private UserEntity stranger; + private DatasourceEntity datasource; + private DatasourceEntity hidden; + private String adminToken; + private String analystToken; + + @BeforeEach + void setUp() { + mvc = MockMvcTester.from(context, builder -> builder.apply(springSecurity()).build()); + primaryOrg = saveOrg("Primary", "primary-db-" + suffix); + otherOrg = saveOrg("Other", "other-db-" + suffix); + admin = saveUser(primaryOrg, "admin-db-" + suffix + "@example.com", UserRoleType.ADMIN); + analyst = saveUser(primaryOrg, "analyst-db-" + suffix + "@example.com", + UserRoleType.ANALYST); + stranger = saveUser(otherOrg, "stranger-db-" + suffix + "@example.com", + UserRoleType.ANALYST); + datasource = saveDatasource(primaryOrg, "DB-DS-" + suffix); + hidden = saveDatasource(primaryOrg, "DB-HIDDEN-" + suffix); + grantRead(analyst, datasource); + adminToken = token(admin); + analystToken = token(analyst); + } + + @AfterEach + void cleanup() { + var datasourceIds = List.of(datasource.getId(), hidden.getId()); + usageRepository.deleteAll(usageRepository.findAll().stream() + .filter(u -> datasourceIds.contains(u.getDatasourceId())).toList()); + dataBudgetRepository.deleteAll(dataBudgetRepository.findAll().stream() + .filter(b -> datasourceIds.contains(b.getDatasourceId())).toList()); + permissionRepository.deleteAllById(createdPermissions); + datasourceRepository.deleteAllById(datasourceIds); + userRepository.deleteAllById(List.of(admin.getId(), analyst.getId(), stranger.getId())); + organizationRepository.deleteAllById(List.of(primaryOrg.getId(), otherOrg.getId())); + } + + private String base() { + return "/api/v1/datasources/" + datasource.getId() + "/data-budgets"; + } + + @Test + void createReturns201WithDefaults() { + var result = mvc.post().uri(base()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"Analysts daily","max_rows":1000,"applies_to_roles":["ANALYST"], + "enabled":true} + """) + .exchange(); + + assertThat(result).hasStatus(201); + assertThat(result.getResponse().getHeader(HttpHeaders.LOCATION)).contains("/data-budgets/"); + assertThat(result).bodyJson().extractingPath("$.window_minutes").asNumber() + .isEqualTo(1440); + assertThat(result).bodyJson().extractingPath("$.breach_action").asString() + .isEqualTo("REQUIRE_REVIEW"); + assertThat(result).bodyJson().extractingPath("$.applies_to_roles").asArray() + .containsExactly("ANALYST"); + } + + @Test + void listUpdateAndDeleteRoundTrip() { + var id = createBudget("{\"name\":\"B\",\"max_rows\":10,\"enabled\":true}"); + + var list = mvc.get().uri(base()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken).exchange(); + assertThat(list).hasStatus(200); + assertThat(list).bodyJson().extractingPath("$.content[*].name").asArray() + .containsExactly("B"); + + var update = mvc.put().uri(base() + "/" + id) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"B2","max_bytes":2048,"window_minutes":60, + "breach_action":"REJECT","warn_threshold_percent":75,"enabled":false} + """) + .exchange(); + assertThat(update).hasStatus(200); + assertThat(update).bodyJson().extractingPath("$.breach_action").asString() + .isEqualTo("REJECT"); + assertThat(update).bodyJson().extractingPath("$.warn_threshold_percent").asNumber() + .isEqualTo(75); + + var delete = mvc.delete().uri(base() + "/" + id) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken).exchange(); + assertThat(delete).hasStatus(204); + assertThat(dataBudgetRepository.findById(UUID.fromString(id))).isEmpty(); + } + + @Test + void anAnalystCannotManageBudgets() { + var result = mvc.post().uri(base()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"B\",\"max_rows\":10}") + .exchange(); + + assertThat(result).hasStatus(403); + } + + @Test + void beanValidationFailuresReturn400() { + assertThat(post("{\"name\":\"\",\"max_rows\":10}")).hasStatus(400); + assertThat(post("{\"name\":\"B\",\"max_rows\":0}")).hasStatus(400); + assertThat(post("{\"name\":\"B\",\"max_rows\":10,\"window_minutes\":30}")).hasStatus(400); + assertThat(post("{\"name\":\"B\",\"max_rows\":10,\"warn_threshold_percent\":100}")) + .hasStatus(400); + } + + @Test + void aBudgetWithoutAnyLimitReturns422() { + var result = post("{\"name\":\"B\"}"); + + assertThat(result).hasStatus(422); + assertThat(result).bodyJson().extractingPath("$.error").asString() + .isEqualTo("ILLEGAL_DATA_BUDGET"); + } + + @Test + void anAppliesToUserOfAnotherOrgReturns422() { + var result = post("{\"name\":\"B\",\"max_rows\":10,\"applies_to_user_ids\":[\"" + + stranger.getId() + "\"]}"); + + assertThat(result).hasStatus(422); + } + + @Test + void anUnknownBudgetReturns404() { + var result = mvc.put().uri(base() + "/" + UUID.randomUUID()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"B\",\"max_rows\":10}") + .exchange(); + + assertThat(result).hasStatus(404); + assertThat(result).bodyJson().extractingPath("$.error").asString() + .isEqualTo("DATA_BUDGET_NOT_FOUND"); + } + + @Test + void myStandingReflectsTheLedgerOverTheWindow() { + createBudget("{\"name\":\"Daily\",\"max_rows\":100,\"window_minutes\":60," + + "\"breach_action\":\"REJECT\",\"enabled\":true}"); + ledger(analyst, 30, Instant.now().minusSeconds(60)); + // Outside the one-hour window: must not count. + ledger(analyst, 50, Instant.now().minusSeconds(7_200)); + + var result = mvc.get().uri(base() + "/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken).exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.remaining_rows").asNumber().isEqualTo(70); + assertThat(result).bodyJson().extractingPath("$.used_percent").asNumber().isEqualTo(30); + assertThat(result).bodyJson().extractingPath("$.exhausted").asBoolean().isFalse(); + assertThat(result).bodyJson().extractingPath("$.budgets[0].used_rows").asNumber() + .isEqualTo(30); + } + + @Test + void myStandingIsEmptyWithoutABudget() { + var result = mvc.get().uri(base() + "/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken).exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.budgets").asArray().isEmpty(); + } + + @Test + void myStandingOnAnInvisibleDatasourceIs404() { + var result = mvc.get() + .uri("/api/v1/datasources/" + hidden.getId() + "/data-budgets/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken).exchange(); + + assertThat(result).hasStatus(404); + } + + @Test + void anAdminSeesAUsersStandingAcrossDatasources() { + createBudget("{\"name\":\"Daily\",\"max_rows\":10,\"breach_action\":\"REJECT\"," + + "\"enabled\":true}"); + ledger(analyst, 10, Instant.now().minusSeconds(60)); + + var result = mvc.get() + .uri("/api/v1/admin/users/" + analyst.getId() + "/data-budget-usage") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken).exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.content[0].datasource_name").asString() + .isEqualTo(datasource.getName()); + assertThat(result).bodyJson().extractingPath("$.content[0].exhausted").asBoolean() + .isTrue(); + assertThat(result).bodyJson().extractingPath("$.content[0].breach_action").asString() + .isEqualTo("REJECT"); + } + + @Test + void theAdminViewHidesUsersOfOtherOrganizationsAndAnalysts() { + var foreign = mvc.get() + .uri("/api/v1/admin/users/" + stranger.getId() + "/data-budget-usage") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken).exchange(); + var forbidden = mvc.get() + .uri("/api/v1/admin/users/" + analyst.getId() + "/data-budget-usage") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken).exchange(); + + assertThat(foreign).hasStatus(404); + assertThat(forbidden).hasStatus(403); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult post(String body) { + return mvc.post().uri(base()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .exchange(); + } + + private String createBudget(String body) { + var result = post(body); + assertThat(result).hasStatus(201); + return dataBudgetRepository.findAllByOrganizationIdAndDatasourceIdOrderByCreatedAtAsc( + primaryOrg.getId(), datasource.getId()).getLast().getId().toString(); + } + + private void ledger(UserEntity user, long rows, Instant at) { + var entry = new DataBudgetUsageEntity(); + entry.setId(UUID.randomUUID()); + entry.setOrganizationId(primaryOrg.getId()); + entry.setUserId(user.getId()); + entry.setDatasourceId(datasource.getId()); + entry.setRowsRead(rows); + entry.setBytesRead(rows * 10); + entry.setSource(DataBudgetUsageSource.QUERY); + entry.setOccurredAt(at); + usageRepository.save(entry); + } + + private void grantRead(UserEntity user, DatasourceEntity ds) { + var permission = new DatasourceUserPermissionEntity(); + permission.setId(UUID.randomUUID()); + permission.setDatasource(ds); + permission.setUser(user); + permission.setCanRead(true); + permission.setCreatedBy(admin); + createdPermissions.add(permissionRepository.save(permission).getId()); + } + + private OrganizationEntity saveOrg(String name, String slug) { + var org = new OrganizationEntity(); + org.setId(UUID.randomUUID()); + org.setName(name + " " + suffix); + org.setSlug(slug); + return organizationRepository.save(org); + } + + private UserEntity saveUser(OrganizationEntity org, String email, UserRoleType role) { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(email); + user.setPasswordHash(passwordEncoder.encode("Password123!")); + user.setRole(role); + user.setAuthProvider(AuthProviderType.LOCAL); + user.setActive(true); + user.setOrganization(org); + return userRepository.save(user); + } + + private DatasourceEntity saveDatasource(OrganizationEntity org, String name) { + var ds = new DatasourceEntity(); + ds.setId(UUID.randomUUID()); + ds.setOrganization(org); + ds.setName(name); + ds.setDbType(DbType.POSTGRESQL); + ds.setHost("nope.invalid"); + ds.setPort(65000); + ds.setDatabaseName("appdb"); + ds.setUsername("svc"); + ds.setPasswordEncrypted(encryptionService.encrypt("seed-password")); + ds.setSslMode(SslMode.DISABLE); + ds.setConnectionPoolSize(10); + ds.setMaxRowsPerQuery(1000); + ds.setRequireReviewReads(false); + ds.setRequireReviewWrites(true); + ds.setAiAnalysisEnabled(false); + ds.setActive(true); + return datasourceRepository.save(ds); + } + + private String token(UserEntity entity) { + var view = new com.bablsoft.accessflow.core.api.UserView( + entity.getId(), + entity.getEmail(), + entity.getDisplayName(), + entity.getRole(), + entity.getOrganization().getId(), + entity.isActive(), + entity.getAuthProvider(), + entity.getPasswordHash(), + entity.getLastLoginAt(), + entity.getPreferredLanguage(), + entity.isTotpEnabled(), + entity.getCreatedAt()); + return jwtService.generateAccessToken(view); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java index f0b917889..e2c8f8aed 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java @@ -181,6 +181,27 @@ void estimatedBytesScannedRejectsANullOperatorAndANegativeValue() { assertThat(node.value()).isEqualTo(1_000_000L); } + @Test + void dataBudgetUsedPercentRejectsANullOperatorAndANegativeValue() { + assertThatThrownBy(() -> new ConditionNode.DataBudgetUsedPercent(null, 5)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GT, -1)) + .isInstanceOf(IllegalArgumentException.class); + var node = new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GTE, 80); + assertThat(node.operator()).isEqualTo(ComparisonOperator.GTE); + assertThat(node.value()).isEqualTo(80); + } + + @Test + void theBytesCompatibleContextConstructorLeavesTheBudgetSignalAbsent() { + var ctx = new ConditionContext(com.bablsoft.accessflow.core.api.QueryType.SELECT, + java.util.Set.of(), null, -1, null, java.util.Set.of(), + java.time.LocalDateTime.of(2026, 6, 3, 14, 30), false, false, false, null, null, + false, null, false, null, null, java.util.Set.of(), false, 5L); + assertThat(ctx.estimatedBytesScanned()).isEqualTo(5L); + assertThat(ctx.dataBudgetUsedPercent()).isNull(); + } + @Test void theShapeCompatibleContextConstructorLeavesTheBytesEstimateAbsent() { var context = new ConditionContext(QueryType.SELECT, Set.of(), RiskLevel.LOW, 1, "ANALYST", diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheckTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheckTest.java new file mode 100644 index 000000000..95ede5888 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetCheckTest.java @@ -0,0 +1,53 @@ +package com.bablsoft.accessflow.workflow.internal; + +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetConsumption; +import com.bablsoft.accessflow.core.api.DataBudgetStatus; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataBudgetCheckTest { + + private final UUID datasourceId = UUID.randomUUID(); + + @Test + void noStandingMeansNoCheck() { + assertThat(DataBudgetCheck.of(null)).isNull(); + assertThat(DataBudgetCheck.of(DataBudgetStatus.none(datasourceId))).isNull(); + } + + @Test + void allowanceLeftDecidesNothing() { + var check = DataBudgetCheck.of(status(DataBudgetBreachAction.REJECT, 40)); + + assertThat(check.exhausted()).isFalse(); + assertThat(check.rejects()).isFalse(); + assertThat(check.forcesReview()).isFalse(); + assertThat(check.deciding()).isNull(); + assertThat(check.usedPercent()).isEqualTo(40d); + assertThat(check.remainingRows()).isEqualTo(60L); + assertThat(check.remainingBytes()).isNull(); + } + + @Test + void exhaustedBudgetsDecideByAction() { + var reject = DataBudgetCheck.of(status(DataBudgetBreachAction.REJECT, 100)); + assertThat(reject.exhausted()).isTrue(); + assertThat(reject.rejects()).isTrue(); + assertThat(reject.forcesReview()).isFalse(); + assertThat(reject.deciding()).isNotNull(); + + var review = DataBudgetCheck.of(status(DataBudgetBreachAction.REQUIRE_REVIEW, 120)); + assertThat(review.rejects()).isFalse(); + assertThat(review.forcesReview()).isTrue(); + } + + private DataBudgetStatus status(DataBudgetBreachAction action, long usedRows) { + return new DataBudgetStatus(datasourceId, "ds", List.of(new DataBudgetConsumption( + UUID.randomUUID(), "b", 100L, null, 60, action, null, usedRows, 0))); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetEnforcementIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetEnforcementIntegrationTest.java new file mode 100644 index 000000000..f83cf4994 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DataBudgetEnforcementIntegrationTest.java @@ -0,0 +1,378 @@ +package com.bablsoft.accessflow.workflow.internal; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import com.bablsoft.accessflow.core.api.DataBudgetBreachAction; +import com.bablsoft.accessflow.core.api.DataBudgetUsageSource; +import com.bablsoft.accessflow.core.api.DbType; +import com.bablsoft.accessflow.core.api.QueryDryRunResult; +import com.bablsoft.accessflow.core.api.QueryExecutionRequest; +import com.bablsoft.accessflow.core.api.QueryStatus; +import com.bablsoft.accessflow.core.api.QueryType; +import com.bablsoft.accessflow.core.api.ResultColumn; +import com.bablsoft.accessflow.core.api.SelectExecutionResult; +import com.bablsoft.accessflow.core.api.SqlParseResult; +import com.bablsoft.accessflow.core.api.SslMode; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.events.AiAnalysisSkippedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DataBudgetUsageEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.QueryRequestEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.ReviewPlanApproverEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.ReviewPlanEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DataBudgetUsageRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.QueryRequestRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.ReviewPlanApproverRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.ReviewPlanRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.proxy.api.QueryExecutor; +import com.bablsoft.accessflow.proxy.api.QueryParser; +import com.bablsoft.accessflow.workflow.api.ComparisonOperator; +import com.bablsoft.accessflow.workflow.api.ConditionNode; +import com.bablsoft.accessflow.workflow.api.QueryLifecycleService; +import com.bablsoft.accessflow.workflow.api.QueryLifecycleService.ExecuteQueryCommand; +import com.bablsoft.accessflow.workflow.api.RoutingAction; +import com.bablsoft.accessflow.workflow.internal.persistence.entity.RoutingPolicyEntity; +import com.bablsoft.accessflow.workflow.internal.persistence.repo.RoutingDecisionRepository; +import com.bablsoft.accessflow.workflow.internal.persistence.repo.RoutingPolicyRepository; +import com.bablsoft.accessflow.workflow.internal.routing.RoutingConditionCodec; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * #942 end to end over the real ledger: an exhausted budget refuses or escalates a SELECT when it + * leaves {@code PENDING_AI}, a budget with allowance left caps the execution and is charged for + * what was delivered, and a {@code data_budget_used_percent} routing policy sees the live share. + * No customer database is contacted — {@link QueryExecutor} is mocked. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class DataBudgetEnforcementIntegrationTest { + + private static final String SQL = "SELECT * FROM events"; + + @MockitoBean QueryExecutor queryExecutor; + @MockitoBean QueryParser queryParser; + + @Autowired ApplicationEventPublisher eventPublisher; + @Autowired PlatformTransactionManager transactionManager; + @Autowired QueryLifecycleService queryLifecycleService; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired DatasourceRepository datasourceRepository; + @Autowired QueryRequestRepository queryRequestRepository; + @Autowired ReviewPlanRepository reviewPlanRepository; + @Autowired ReviewPlanApproverRepository reviewPlanApproverRepository; + @Autowired RoutingPolicyRepository routingPolicyRepository; + @Autowired RoutingDecisionRepository routingDecisionRepository; + @Autowired RoutingConditionCodec routingConditionCodec; + @Autowired DataBudgetRepository dataBudgetRepository; + @Autowired DataBudgetUsageRepository usageRepository; + @Autowired CredentialEncryptionService encryptionService; + @Autowired JdbcTemplate jdbcTemplate; + + private OrganizationEntity organization; + private UserEntity submitter; + private DatasourceEntity datasource; + private ReviewPlanEntity plan; + + @BeforeEach + void setUp() { + organization = new OrganizationEntity(); + organization.setId(UUID.randomUUID()); + organization.setName("Budget org"); + organization.setSlug("budget-" + UUID.randomUUID()); + organizationRepository.save(organization); + submitter = persistUser(); + plan = persistPlan(); + datasource = persistDatasource(plan); + when(queryParser.parse(any(), any())).thenReturn(new SqlParseResult(QueryType.SELECT, SQL)); + when(queryExecutor.dryRun(any())).thenReturn(QueryDryRunResult.unsupported("postgresql")); + } + + @AfterEach + void cleanup() { + var orgId = organization.getId(); + var dsId = datasource.getId(); + jdbcTemplate.update("DELETE FROM audit_log WHERE organization_id = ?", orgId); + jdbcTemplate.update("DELETE FROM user_notifications WHERE user_id = ?", submitter.getId()); + jdbcTemplate.update("DELETE FROM routing_decision WHERE query_request_id IN " + + "(SELECT id FROM query_requests WHERE datasource_id = ?)", dsId); + jdbcTemplate.update("DELETE FROM routing_policy WHERE organization_id = ?", orgId); + jdbcTemplate.update("UPDATE query_requests SET query_estimate_id = NULL WHERE datasource_id = ?", + dsId); + jdbcTemplate.update("DELETE FROM query_estimates WHERE query_request_id IN " + + "(SELECT id FROM query_requests WHERE datasource_id = ?)", dsId); + jdbcTemplate.update("DELETE FROM query_requests WHERE datasource_id = ?", dsId); + jdbcTemplate.update("DELETE FROM data_budget_usage WHERE datasource_id = ?", dsId); + jdbcTemplate.update("DELETE FROM data_budgets WHERE datasource_id = ?", dsId); + datasourceRepository.deleteById(dsId); + jdbcTemplate.update("DELETE FROM review_plan_approvers WHERE review_plan_id = ?", plan.getId()); + reviewPlanRepository.deleteById(plan.getId()); + userRepository.deleteById(submitter.getId()); + organizationRepository.deleteById(orgId); + } + + @Test + void anExhaustedRejectBudgetRejectsTheQueryWhenItLeavesPendingAi() { + persistBudget(DataBudgetBreachAction.REJECT, 100L); + spend(100); + var query = persistPendingAiQuery(); + + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + + awaitStatus(query.getId(), QueryStatus.REJECTED); + assertThat(budgetAuditMetadata(query.getId())) + .contains("\"stage\": \"decision\"") + .contains("\"action\": \"REJECT\""); + } + + @Test + void anExhaustedReviewBudgetHoldsAnAutoApprovalForAPerson() { + persistBudget(DataBudgetBreachAction.REQUIRE_REVIEW, 100L); + spend(250); + var query = persistPendingAiQuery(); + + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + + awaitStatus(query.getId(), QueryStatus.PENDING_REVIEW); + assertThat(budgetAuditMetadata(query.getId())).contains("REQUIRE_REVIEW"); + assertThat(queryRequestRepository.findById(query.getId()).orElseThrow() + .isDataBudgetReviewForced()).isTrue(); + } + + @Test + void theRemainingAllowanceCapsTheExecutionAndTheDeliveredRowsAreCharged() { + persistBudget(DataBudgetBreachAction.REJECT, 100L); + spend(97); + var query = persistPendingAiQuery(); + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + awaitStatus(query.getId(), QueryStatus.APPROVED); + List> rows = IntStream.range(0, 3).>mapToObj(List::of).toList(); + when(queryExecutor.execute(any())).thenReturn(new SelectExecutionResult( + List.of(new ResultColumn("id", 4, "int4")), rows, 3L, true, Duration.ofMillis(3), + Set.of(), Set.of(), SelectExecutionResult.TRUNCATED_ROW_LIMIT, null, 120L)); + + var outcome = queryLifecycleService.execute(new ExecuteQueryCommand(query.getId(), + submitter.getId(), organization.getId(), false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); + var request = ArgumentCaptor.forClass(QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxRowsOverride()).isEqualTo(3); + assertThat(jdbcTemplate.queryForObject("SELECT truncated_reason FROM query_request_results " + + "WHERE query_request_id = ?", String.class, query.getId())) + .isEqualTo(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + var charged = usageRepository.findAll().stream() + .filter(u -> query.getId().equals(u.getQueryRequestId())).toList(); + assertThat(charged).singleElement().satisfies(u -> { + assertThat(u.getRowsRead()).isEqualTo(3); + assertThat(u.getBytesRead()).isEqualTo(120); + assertThat(u.getSource()).isEqualTo(DataBudgetUsageSource.QUERY); + }); + } + + @Test + void aBudgetExhaustedAfterApprovalFailsTheExecution() { + persistBudget(DataBudgetBreachAction.REJECT, 100L); + var query = persistPendingAiQuery(); + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + awaitStatus(query.getId(), QueryStatus.APPROVED); + spend(100); + + var outcome = queryLifecycleService.execute(new ExecuteQueryCommand(query.getId(), + submitter.getId(), organization.getId(), false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.FAILED); + verify(queryExecutor, never()).execute(any()); + assertThat(budgetAuditMetadata(query.getId())).contains("\"stage\": \"execution\""); + } + + @Test + void aUsedPercentPolicyEscalatesAHeavyReader() { + persistBudget(DataBudgetBreachAction.REJECT, 100L); + spend(85); + var policy = persistPolicy(new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GTE, + 80)); + var query = persistPendingAiQuery(); + + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + + awaitStatus(query.getId(), QueryStatus.PENDING_REVIEW); + assertThat(routingDecisionRepository.findByQueryRequestId(query.getId()).orElseThrow() + .getMatchedPolicyId()).isEqualTo(policy.getId()); + } + + @Test + void noBudgetLeavesBehaviourUnchangedAndWritesNoLedgerRow() { + var query = persistPendingAiQuery(); + publish(new AiAnalysisSkippedEvent(query.getId(), "ai_analysis_enabled=false")); + awaitStatus(query.getId(), QueryStatus.APPROVED); + when(queryExecutor.execute(any())).thenReturn(new SelectExecutionResult(List.of(), + List.of(), 0L, false, Duration.ofMillis(3))); + + queryLifecycleService.execute(new ExecuteQueryCommand(query.getId(), submitter.getId(), + organization.getId(), false)); + + var request = ArgumentCaptor.forClass(QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxResultBytesOverride()).isNull(); + assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM data_budget_usage " + + "WHERE datasource_id = ?", Integer.class, datasource.getId())).isZero(); + } + + // ── Fixtures ────────────────────────────────────────────────────────────── + + private void publish(Object event) { + new TransactionTemplate(transactionManager) + .executeWithoutResult(status -> eventPublisher.publishEvent(event)); + } + + private void awaitStatus(UUID queryId, QueryStatus expected) { + Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> + assertThat(queryRequestRepository.findById(queryId).orElseThrow().getStatus()) + .isEqualTo(expected)); + } + + private String budgetAuditMetadata(UUID queryId) { + var rows = jdbcTemplate.queryForList("SELECT metadata::text FROM audit_log WHERE " + + "resource_id = ? AND action = 'QUERY_DATA_BUDGET_ENFORCED'", String.class, + queryId); + assertThat(rows).hasSize(1); + return rows.get(0); + } + + private void spend(long rows) { + var entry = new DataBudgetUsageEntity(); + entry.setId(UUID.randomUUID()); + entry.setOrganizationId(organization.getId()); + entry.setUserId(submitter.getId()); + entry.setDatasourceId(datasource.getId()); + entry.setRowsRead(rows); + entry.setBytesRead(rows); + entry.setSource(DataBudgetUsageSource.QUERY); + entry.setOccurredAt(Instant.now().minusSeconds(30)); + usageRepository.save(entry); + } + + private void persistBudget(DataBudgetBreachAction action, Long maxRows) { + var budget = new DataBudgetEntity(); + budget.setId(UUID.randomUUID()); + budget.setOrganizationId(organization.getId()); + budget.setDatasourceId(datasource.getId()); + budget.setName("Daily"); + budget.setMaxRows(maxRows); + budget.setWindowMinutes(1440); + budget.setBreachAction(action); + budget.setEnabled(true); + dataBudgetRepository.save(budget); + } + + private UserEntity persistUser() { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail("budget-" + UUID.randomUUID() + "@example.com"); + user.setDisplayName("submitter"); + user.setPasswordHash("hash"); + user.setRole(UserRoleType.ANALYST); + user.setAuthProvider(AuthProviderType.LOCAL); + user.setActive(true); + user.setOrganization(organization); + return userRepository.save(user); + } + + private ReviewPlanEntity persistPlan() { + var reviewPlan = new ReviewPlanEntity(); + reviewPlan.setId(UUID.randomUUID()); + reviewPlan.setOrganization(organization); + reviewPlan.setName("plan-" + UUID.randomUUID()); + reviewPlan.setRequiresAiReview(false); + reviewPlan.setRequiresHumanApproval(false); + reviewPlan.setMinApprovalsRequired(1); + reviewPlan.setApprovalTimeoutHours(24); + reviewPlan.setAutoApproveReads(false); + reviewPlanRepository.save(reviewPlan); + var rule = new ReviewPlanApproverEntity(); + rule.setId(UUID.randomUUID()); + rule.setReviewPlan(reviewPlan); + rule.setRole("REVIEWER"); + rule.setStage(1); + reviewPlanApproverRepository.save(rule); + return reviewPlan; + } + + private DatasourceEntity persistDatasource(ReviewPlanEntity reviewPlan) { + var ds = new DatasourceEntity(); + ds.setId(UUID.randomUUID()); + ds.setOrganization(organization); + ds.setName("BUDGET-" + UUID.randomUUID()); + ds.setDbType(DbType.POSTGRESQL); + ds.setHost("nope.invalid"); + ds.setPort(65000); + ds.setDatabaseName("appdb"); + ds.setUsername("svc"); + ds.setPasswordEncrypted(encryptionService.encrypt("seed-password")); + ds.setSslMode(SslMode.DISABLE); + ds.setConnectionPoolSize(5); + ds.setMaxRowsPerQuery(1000); + ds.setReviewPlan(reviewPlan); + ds.setAiAnalysisEnabled(false); + ds.setActive(true); + return datasourceRepository.save(ds); + } + + private QueryRequestEntity persistPendingAiQuery() { + var query = new QueryRequestEntity(); + query.setId(UUID.randomUUID()); + query.setDatasource(datasource); + query.setSubmittedBy(submitter); + query.setSqlText(SQL); + query.setQueryType(QueryType.SELECT); + query.setStatus(QueryStatus.PENDING_AI); + return queryRequestRepository.save(query); + } + + private RoutingPolicyEntity persistPolicy(ConditionNode condition) { + var policy = new RoutingPolicyEntity(); + policy.setId(UUID.randomUUID()); + policy.setOrganizationId(organization.getId()); + policy.setName("policy-" + UUID.randomUUID()); + policy.setPriority(1); + policy.setEnabled(true); + policy.setAction(RoutingAction.ESCALATE); + policy.setRequiredApprovals(1); + policy.setConditionJson(routingConditionCodec.encode(condition)); + return routingPolicyRepository.save(policy); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java index 99719f337..dde8bdc70 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java @@ -77,6 +77,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -99,6 +100,7 @@ class DefaultAccessSimulationServiceTest { @Mock MaskingPolicyResolutionService maskingPolicyResolutionService; @Mock BreakGlassEligibilityService breakGlassEligibilityService; @Mock com.bablsoft.accessflow.core.api.BytesScannedCapResolutionService bytesScannedCapResolutionService; + @Mock com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; private DefaultAccessSimulationService service; @@ -115,8 +117,11 @@ void buildService() { routingPolicyEngine, reviewPlanLookupService, reviewerEligibilityService, rowSecurityResolutionService, rowSecurityClassificationService, maskingPolicyResolutionService, - breakGlassEligibilityService, bytesScannedCapResolutionService); + breakGlassEligibilityService, bytesScannedCapResolutionService, + dataBudgetStatusService); service.setClock(clock); + lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none(inv.getArgument(0))); } @BeforeEach @@ -136,7 +141,7 @@ void stubHappyPath() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(permission(true, false, false, List.of("public")))); when(queryDecisionEvaluator.evaluate(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), - any(), any(), any())).thenReturn(planDecision(QueryStatus.PENDING_REVIEW)); + any(), any(), any(), any())).thenReturn(planDecision(QueryStatus.PENDING_REVIEW)); when(sqlReviewService.evaluate(eq(organizationId), eq(datasourceId), any())) .thenReturn(SqlReviewResult.clean()); when(routingPolicyEngine.enabledFor(organizationId, datasourceId)).thenReturn(List.of()); @@ -176,7 +181,8 @@ void aSimulationNeverAsksTheEvaluatorToApplyAnything() { service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); verify(queryDecisionEvaluator).evaluate(any(), eq(AiOutcome.COMPLETED), eq(RiskLevel.LOW), - eq(5), eq(List.of()), org.mockito.ArgumentMatchers.isNull(), eq(clock)); + eq(5), eq(List.of()), org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), eq(clock)); verify(datasourceAdminService, never()).update(any(), any(), any()); } @@ -197,7 +203,8 @@ void theSimulatorHandsTheEvaluatorTheSameBlockingRuleIdsTheLivePathWouldRead() { verify(queryDecisionEvaluator).evaluate(any(), eq(AiOutcome.COMPLETED), eq(RiskLevel.LOW), eq(5), eq(List.of("cross_join", "select_star")), - org.mockito.ArgumentMatchers.isNull(), eq(clock)); + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.isNull(), + eq(clock)); verify(sqlReviewService).evaluate(organizationId, datasourceId, "SELECT card_number FROM public.payments"); } @@ -210,7 +217,8 @@ void aNonRelationalDatasourceContributesNoBlockingRuleIds() { service.simulate(organizationId, input(AiOutcome.SKIPPED, null, null)); verify(queryDecisionEvaluator).evaluate(any(), eq(AiOutcome.SKIPPED), eq(null), eq(-1), - eq(List.of()), org.mockito.ArgumentMatchers.isNull(), eq(clock)); + eq(List.of()), org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), eq(clock)); } @Test @@ -224,12 +232,42 @@ void aBytesCapIsHandedToTheEvaluatorUnevaluatedBecauseASimulationHasNoEstimate() var passed = org.mockito.ArgumentCaptor.forClass(BytesCapCheck.class); verify(queryDecisionEvaluator).evaluate(any(), any(), any(), - org.mockito.ArgumentMatchers.anyInt(), any(), passed.capture(), any()); + org.mockito.ArgumentMatchers.anyInt(), any(), passed.capture(), any(), any()); assertThat(passed.getValue().limit()).isEqualTo(500L); assertThat(passed.getValue().outcome()).isNull(); assertThat(passed.getValue().rejects()).isFalse(); } + @Test + void theLiveDataBudgetStandingIsHandedToTheEvaluatorForASelect() { + when(dataBudgetStatusService.statusFor(datasourceId, userId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", List.of( + new com.bablsoft.accessflow.core.api.DataBudgetConsumption(UUID.randomUUID(), + "Daily", 10L, null, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, null, + 10, 0)))); + + service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + var passed = org.mockito.ArgumentCaptor.forClass(DataBudgetCheck.class); + verify(queryDecisionEvaluator).evaluate(any(), any(), any(), + org.mockito.ArgumentMatchers.anyInt(), any(), any(), passed.capture(), any()); + assertThat(passed.getValue().rejects()).isTrue(); + } + + @Test + void aWriteIsNeverCheckedAgainstADataBudget() { + when(queryParser.parse(any(), any())).thenReturn( + new SqlParseResult(QueryType.UPDATE, false, List.of("UPDATE t SET a = 1"), + Set.of("public.payments"), true, false)); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(permission(true, true, false, List.of("public")))); + + service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + verify(dataBudgetStatusService, never()).statusFor(any(), any()); + } + // ── Shape ───────────────────────────────────────────────────────────────── @Test @@ -533,7 +571,7 @@ void theRoutingStepCarriesEveryPolicyNotJustTheWinner() { @Test void reviewersAreSkippedWhenTheRequestWouldNotReachReview() { when(queryDecisionEvaluator.evaluate(any(), any(), any(), - org.mockito.ArgumentMatchers.anyInt(), any(), any(), any())) + org.mockito.ArgumentMatchers.anyInt(), any(), any(), any(), any())) .thenReturn(planDecision(QueryStatus.APPROVED)); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -553,13 +591,14 @@ void theSimulatorEvaluatesInTheSameZoneTheLiveListenerDoes() { routingPolicyEngine, reviewPlanLookupService, reviewerEligibilityService, rowSecurityResolutionService, rowSecurityClassificationService, maskingPolicyResolutionService, - breakGlassEligibilityService, bytesScannedCapResolutionService); + breakGlassEligibilityService, bytesScannedCapResolutionService, + dataBudgetStatusService); fresh.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); var passed = org.mockito.ArgumentCaptor.forClass(Clock.class); verify(queryDecisionEvaluator).evaluate(any(), any(), any(), - org.mockito.ArgumentMatchers.anyInt(), any(), any(), passed.capture()); + org.mockito.ArgumentMatchers.anyInt(), any(), any(), any(), passed.capture()); assertThat(passed.getValue().getZone()).isEqualTo(ZoneId.systemDefault()); } @@ -602,7 +641,7 @@ void theDecisivePolicyIsTheOneTheDecisionNamesNotASecondEvaluationsOwnFirstMatch when(routingPolicyEngine.evaluateAll(any(), any())) .thenReturn(List.of(alsoMatched, decided)); when(queryDecisionEvaluator.evaluate(any(), any(), any(), - org.mockito.ArgumentMatchers.anyInt(), any(), any(), any())) + org.mockito.ArgumentMatchers.anyInt(), any(), any(), any(), any())) .thenReturn(routedDecision(decidedId)); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -744,6 +783,7 @@ private QueryDecision planDecision(QueryStatus status) { var steps = List.of( DecisionTraceStep.of(QueryDecisionStepKind.SQL_REVIEW, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.BYTES_SCANNED_CAP, StepOutcome.NO_MATCH, "k"), + DecisionTraceStep.of(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.GRANT_FAST_PATH, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.REVIEW_PLAN, StepOutcome.DENY, "k")); @@ -764,6 +804,7 @@ private QueryDecision routedDecision(UUID policyId) { var steps = List.of( DecisionTraceStep.of(QueryDecisionStepKind.SQL_REVIEW, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.BYTES_SCANNED_CAP, StepOutcome.NO_MATCH, "k"), + DecisionTraceStep.of(QueryDecisionStepKind.DATA_BUDGET, StepOutcome.NO_MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.MATCH, "k"), DecisionTraceStep.of(QueryDecisionStepKind.GRANT_FAST_PATH, StepOutcome.SKIP, "k"), DecisionTraceStep.of(QueryDecisionStepKind.REVIEW_PLAN, StepOutcome.SKIP, "k")); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java index 19d52f703..eb8ef7c20 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java @@ -97,6 +97,8 @@ class DefaultQueryLifecycleServiceTest { @Mock ApplicationEventPublisher eventPublisher; @Mock com.bablsoft.accessflow.core.api.BytesScannedCapResolutionService bytesScannedCapResolutionService; @Mock com.bablsoft.accessflow.proxy.api.QueryCostEstimateService queryCostEstimateService; + @Mock com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; + @Mock com.bablsoft.accessflow.core.api.DataBudgetUsageService dataBudgetUsageService; DefaultQueryLifecycleService service; @@ -136,7 +138,12 @@ void setUp() { messageSource, eventPublisher, bytesScannedCapResolutionService, - queryCostEstimateService); + queryCostEstimateService, + dataBudgetStatusService, + dataBudgetUsageService); + when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none(inv.getArgument(0))); + when(queryParser.parse(anyString(), any())).thenAnswer(inv -> { String sql = inv.getArgument(0); return new SqlParseResult(QueryType.SELECT, sql); @@ -1425,4 +1432,213 @@ void executeRecurringOccurrenceHaltsWhenSubmitterInactive() { verify(queryRequestPersistenceService).clearRecurrenceNextRun(eq(queryId), anyString()); verify(queryExecutor, never()).execute(any()); } + + // ── Data budget (#942) ──────────────────────────────────────────────────── + + private final UUID budgetId = UUID.randomUUID(); + + private void givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction action, + long usedRows) { + when(dataBudgetStatusService.statusFor(datasourceId, submitterId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", List.of( + new com.bablsoft.accessflow.core.api.DataBudgetConsumption(budgetId, + "Daily", 100L, 10_000L, 1440, action, 80, usedRows, 0)))); + } + + private SelectExecutionResult tenRows(boolean truncated, String reason) { + List> rows = java.util.stream.IntStream.range(0, 10) + .>mapToObj(List::of).toList(); + return new SelectExecutionResult(List.of(new ResultColumn("id", 4, "int4")), rows, 10L, + truncated, Duration.ofMillis(5), java.util.Set.of(), java.util.Set.of(), reason, + null, 480L); + } + + @Test + void executeCapsTheResultToTheRemainingAllowanceAndChargesIt() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 90); + when(queryExecutor.execute(any())).thenReturn(tenRows(true, + SelectExecutionResult.TRUNCATED_ROW_LIMIT)); + + var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, + false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); + var request = ArgumentCaptor.forClass(QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxRowsOverride()).isEqualTo(10); + assertThat(request.getValue().maxResultBytesOverride()).isEqualTo(10_000L); + var usage = ArgumentCaptor.forClass(com.bablsoft.accessflow.core.api.DataBudgetUsageRecord.class); + verify(dataBudgetUsageService).record(usage.capture()); + assertThat(usage.getValue().rowsRead()).isEqualTo(10); + assertThat(usage.getValue().bytesRead()).isEqualTo(480); + assertThat(usage.getValue().queryRequestId()).isEqualTo(queryId); + assertThat(usage.getValue().source()) + .isEqualTo(com.bablsoft.accessflow.core.api.DataBudgetUsageSource.QUERY); + var audit = ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService).record(audit.capture()); + assertThat(audit.getValue().metadata()).containsEntry("data_budget_rows_charged", 10L) + .containsEntry("data_budget_bytes_charged", 480L); + } + + @Test + void executeFailsBeforeTheExecutorWhenARejectBudgetIsExhausted() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100); + when(messageSource.getMessage(eq("error.data_budget.exhausted"), any(), any())) + .thenReturn("budget used up"); + + var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, + false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.FAILED); + verify(queryExecutor, never()).execute(any()); + var exec = ArgumentCaptor.forClass(RecordExecutionCommand.class); + verify(queryRequestStateService).recordExecutionOutcome(exec.capture()); + assertThat(exec.getValue().errorMessage()).isEqualTo("budget used up"); + var audit = ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService, org.mockito.Mockito.times(2)).record(audit.capture()); + assertThat(audit.getAllValues()).extracting(AuditEntry::action).containsExactly( + AuditAction.QUERY_DATA_BUDGET_ENFORCED, AuditAction.QUERY_FAILED); + assertThat(audit.getAllValues().get(0).metadata()) + .containsEntry("stage", "execution") + .containsEntry("action", "REJECT") + .containsEntry("data_budget_id", budgetId); + verify(dataBudgetUsageService, never()).record(any()); + } + + @Test + void executeFailsAnUnreviewedQueryWhenAReviewBudgetIsExhausted() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100); + // Approved while allowance remained: the approval was never a budget escalation. + when(queryRequestStateService.isDataBudgetReviewForced(queryId)).thenReturn(false); + + var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, + false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.FAILED); + verify(queryExecutor, never()).execute(any()); + } + + @Test + void executeRunsABudgetEscalatedQueryUncappedWhenAReviewBudgetIsExhausted() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100); + when(queryRequestStateService.isDataBudgetReviewForced(queryId)).thenReturn(true); + when(queryExecutor.execute(any())).thenReturn(tenRows(false, null)); + + var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, + false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); + var request = ArgumentCaptor.forClass(QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxRowsOverride()).isNull(); + assertThat(request.getValue().maxResultBytesOverride()).isNull(); + verify(dataBudgetUsageService).record(any()); + } + + @Test + void aRecurringOccurrenceInheritsItsSeriesApproval() { + var parentId = UUID.randomUUID(); + var occurrence = new QueryRequestSnapshot(queryId, datasourceId, organizationId, + submitterId, "SELECT 1", QueryType.SELECT, false, QueryStatus.APPROVED, + java.time.Instant.EPOCH, null, null, false, null, null, null, parentId); + when(queryRequestLookupService.findById(queryId)).thenReturn(Optional.of(occurrence)); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100); + when(queryRequestStateService.isDataBudgetReviewForced(queryId)).thenReturn(false); + when(queryRequestStateService.isDataBudgetReviewForced(parentId)).thenReturn(true); + when(queryExecutor.execute(any())).thenReturn(tenRows(false, null)); + + service.executeScheduled(queryId); + + verify(queryExecutor).execute(any()); + verify(dataBudgetUsageService).record(any()); + } + + @Test + void breakGlassRunsDespiteAnExhaustedRejectBudgetAndStillCharges() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100); + when(queryExecutor.execute(any())).thenReturn(tenRows(false, null)); + + var outcome = service.executeBreakGlass(queryId, submitterId); + + assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); + verify(dataBudgetUsageService).record(any()); + } + + @Test + void breakGlassIsNeverCappedByARemainingAllowance() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 99); + when(queryExecutor.execute(any())).thenReturn(tenRows(false, null)); + + service.executeBreakGlass(queryId, submitterId); + + var request = ArgumentCaptor.forClass(QueryExecutionRequest.class); + verify(queryExecutor).execute(request.capture()); + assertThat(request.getValue().maxRowsOverride()).isNull(); + assertThat(request.getValue().maxResultBytesOverride()).isNull(); + verify(dataBudgetUsageService).record(any()); + } + + @Test + void aFailedUsageWriteNeverFailsTheExecution() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 0); + when(queryExecutor.execute(any())).thenReturn(tenRows(false, null)); + org.mockito.Mockito.doThrow(new IllegalStateException("ledger down")) + .when(dataBudgetUsageService).record(any()); + + var outcome = service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, + false)); + + assertThat(outcome.status()).isEqualTo(QueryStatus.EXECUTED); + } + + @Test + void aWriteNeverConsultsTheBudget() { + when(queryRequestLookupService.findById(queryId)) + .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.UPDATE))); + when(queryParser.parse(anyString(), any())).thenReturn( + new SqlParseResult(QueryType.UPDATE, "UPDATE t SET a = 1")); + when(queryExecutor.execute(any())).thenReturn( + new com.bablsoft.accessflow.core.api.UpdateExecutionResult(3, Duration.ofMillis(5))); + + service.execute(new ExecuteQueryCommand(queryId, submitterId, organizationId, false)); + + verify(dataBudgetStatusService, never()).statusFor(any(), any()); + verify(dataBudgetUsageService, never()).record(any()); + } + + @Test + void budgetTruncationIsAttributedOnlyWhenTheAllowanceWasTheBindingRowCap() { + var cut = tenRows(true, SelectExecutionResult.TRUNCATED_ROW_LIMIT); + + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(cut, 10L, null, 1_000) + .truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_DATA_BUDGET); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(cut, 10L, 10, null) + .truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(cut, 10L, null, 10) + .truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(cut, 50L, null, null) + .truncatedReason()).isEqualTo(SelectExecutionResult.TRUNCATED_ROW_LIMIT); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(cut, null, null, null)) + .isSameAs(cut); + var notCut = tenRows(false, null); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(notCut, 10L, null, null)) + .isSameAs(notCut); + var byteCut = tenRows(true, SelectExecutionResult.TRUNCATED_BYTE_LIMIT); + assertThat(DefaultQueryLifecycleService.attributeBudgetTruncation(byteCut, 10L, null, null)) + .isSameAs(byteCut); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluatorTest.java index 5945435eb..c6070c2c3 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluatorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryDecisionEvaluatorTest.java @@ -65,6 +65,7 @@ class QueryDecisionEvaluatorTest { @Mock BehaviorAnomalyLookupService behaviorAnomalyLookupService; @Mock AccessGrantLookupService accessGrantLookupService; @Mock QueryEstimateLookupService queryEstimateLookupService; + @Mock com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; private QueryDecisionEvaluator evaluator; @@ -80,7 +81,9 @@ class QueryDecisionEvaluatorTest { void buildEvaluator() { var contextFactory = new ConditionContextFactory(queryRequestLookupService, sqlParserService, userQueryService, userGroupService, behaviorAnomalyLookupService, - queryEstimateLookupService); + queryEstimateLookupService, dataBudgetStatusService); + lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none(inv.getArgument(0))); evaluator = new QueryDecisionEvaluator(reviewPlanLookupService, contextFactory, sqlParserService, routingPolicyEngine, accessGrantLookupService); } @@ -116,6 +119,8 @@ void aiFailureTraceSkipsEveryDecisionStage() { StepOutcome.NO_MATCH), org.assertj.core.groups.Tuple.tuple(QueryDecisionStepKind.BYTES_SCANNED_CAP, StepOutcome.NO_MATCH), + org.assertj.core.groups.Tuple.tuple(QueryDecisionStepKind.DATA_BUDGET, + StepOutcome.NO_MATCH), org.assertj.core.groups.Tuple.tuple(QueryDecisionStepKind.ROUTING_POLICIES, StepOutcome.SKIP), org.assertj.core.groups.Tuple.tuple(QueryDecisionStepKind.GRANT_FAST_PATH, @@ -435,6 +440,7 @@ void thePlanFallThroughTraceCoversAllThreeStages() { assertThat(trace.steps()).extracting("step").containsExactly( QueryDecisionStepKind.SQL_REVIEW, QueryDecisionStepKind.BYTES_SCANNED_CAP, + QueryDecisionStepKind.DATA_BUDGET, QueryDecisionStepKind.ROUTING_POLICIES, QueryDecisionStepKind.GRANT_FAST_PATH, QueryDecisionStepKind.REVIEW_PLAN); assertThat(step(trace, QueryDecisionStepKind.ROUTING_POLICIES).outcome()) @@ -788,6 +794,165 @@ void theSqlReviewNamesTheSuppressionWhenBothGuardsApply() { .isEqualTo("workflow.decision.plan.suppressed_sql_review"); } + // ── Data budget (#942) ──────────────────────────────────────────────────── + + private static DataBudgetCheck budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction action, + long usedRows) { + var consumption = new com.bablsoft.accessflow.core.api.DataBudgetConsumption( + UUID.randomUUID(), "Daily", 100L, null, 1440, + action == null ? com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT : action, + 80, usedRows, 0); + return DataBudgetCheck.of(new com.bablsoft.accessflow.core.api.DataBudgetStatus( + UUID.randomUUID(), "ds", List.of(consumption))); + } + + @Test + void anExhaustedRejectBudgetRejectsBeforeRoutingIsConsulted() { + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.COMPLETED, + RiskLevel.LOW, 5, List.of(), null, + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100), clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.DATA_BUDGET_REJECTED); + assertThat(decision.nextStatus()).isEqualTo(QueryStatus.REJECTED); + assertThat(decision.dataBudgetChangedOutcome()).isTrue(); + assertThat(decision.bytesCapChangedOutcome()).isFalse(); + var budgetStep = step(decision.trace(), QueryDecisionStepKind.DATA_BUDGET); + assertThat(budgetStep.outcome()).isEqualTo(StepOutcome.DENY); + assertThat(budgetStep.reasonKey()).isEqualTo("workflow.decision.data_budget.exhausted_rejected"); + assertThat(budgetStep.reasonArgs()).containsExactly("Daily"); + assertThat(budgetStep.details()).containsEntry("data_budget_name", "Daily") + .containsEntry("data_budget_action", "REJECT"); + assertThat(step(decision.trace(), QueryDecisionStepKind.ROUTING_POLICIES).reasonKey()) + .isEqualTo("workflow.decision.routing.skipped_data_budget"); + assertThat(step(decision.trace(), QueryDecisionStepKind.GRANT_FAST_PATH).reasonKey()) + .isEqualTo("workflow.decision.grant.skipped_data_budget"); + assertThat(step(decision.trace(), QueryDecisionStepKind.REVIEW_PLAN).reasonKey()) + .isEqualTo("workflow.decision.plan.skipped_data_budget"); + verify(routingPolicyEngine, never()).evaluate(any(), any(), any()); + } + + @Test + void theBytesCapRefusalWinsOverAnExhaustedBudget() { + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.COMPLETED, + RiskLevel.LOW, 5, List.of(), + cap(2_000_000_000_000L, BytesScannedCapOutcome.EXCEEDED), + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100), clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.BYTES_CAP_REJECTED); + assertThat(decision.dataBudget()).isNotNull(); + assertThat(decision.dataBudgetChangedOutcome()).isFalse(); + } + + @Test + void anExhaustedRejectBudgetRejectsEvenWhenAiFailed() { + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.FAILED, null, -1, + List.of(), null, + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100), clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.DATA_BUDGET_REJECTED); + } + + @Test + void theAiFailedPathRecordsTheBudgetStanding() { + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.FAILED, null, -1, + List.of(), null, budget(null, 10), clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.AI_FAILED_PENDING_REVIEW); + assertThat(decision.dataBudget()).isNotNull(); + var budgetStep = step(decision.trace(), QueryDecisionStepKind.DATA_BUDGET); + assertThat(budgetStep.outcome()).isEqualTo(StepOutcome.ALLOW); + assertThat(budgetStep.reasonKey()).isEqualTo("workflow.decision.data_budget.within"); + assertThat(budgetStep.reasonArgs()).containsExactly("10"); + } + + @Test + void anExhaustedReviewBudgetSuppressesRoutingAutoApprove() { + givenPlan(false, true); + givenPolicyMatch(RoutingAction.AUTO_APPROVE, null); + + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.COMPLETED, + RiskLevel.LOW, 5, List.of(), null, + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100), + clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.ROUTING_AUTO_APPROVE_SUPPRESSED); + assertThat(decision.nextStatus()).isEqualTo(QueryStatus.PENDING_REVIEW); + assertThat(decision.dataBudgetChangedOutcome()).isTrue(); + assertThat(decision.bytesCapChangedOutcome()).isFalse(); + var routing = step(decision.trace(), QueryDecisionStepKind.ROUTING_POLICIES); + assertThat(routing.reasonKey()).isEqualTo( + "workflow.decision.routing.matched_auto_approve_suppressed_data_budget"); + assertThat(routing.details()).containsEntry("data_budget_suppressed", true) + .containsEntry("bytes_cap_suppressed", false); + var budgetStep = step(decision.trace(), QueryDecisionStepKind.DATA_BUDGET); + assertThat(budgetStep.outcome()).isEqualTo(StepOutcome.MATCH); + assertThat(budgetStep.reasonKey()).isEqualTo("workflow.decision.data_budget.exhausted_review"); + } + + @Test + void anExhaustedReviewBudgetSuppressesTheGrantAndThePlan() { + givenPlan(false, false); + givenNoPolicyMatch(); + givenActiveGrant(grant(true, false, false, List.of(), List.of())); + + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.SKIPPED, null, -1, + List.of(), null, + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100), + clock); + + assertThat(decision.nextStatus()).isEqualTo(QueryStatus.PENDING_REVIEW); + assertThat(decision.dataBudgetChangedOutcome()).isTrue(); + assertThat(step(decision.trace(), QueryDecisionStepKind.GRANT_FAST_PATH).reasonKey()) + .isEqualTo("workflow.decision.grant.suppressed_data_budget"); + var plan = step(decision.trace(), QueryDecisionStepKind.REVIEW_PLAN); + assertThat(plan.reasonKey()).isEqualTo("workflow.decision.plan.suppressed_data_budget"); + assertThat(plan.details()).containsEntry("data_budget_suppressed", true); + } + + @Test + void aBudgetWithAllowanceLeftChangesNothing() { + givenPlan(false, false); + givenNoPolicyMatch(); + givenNoGrants(); + + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.SKIPPED, null, -1, + List.of(), null, budget(null, 30), clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.PLAN_APPROVED); + assertThat(decision.dataBudgetChangedOutcome()).isFalse(); + } + + @Test + void anExhaustedReviewBudgetNeverSoftensAnAutoReject() { + givenPlan(false, true); + givenPolicyMatch(RoutingAction.AUTO_REJECT, null); + + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.COMPLETED, + RiskLevel.LOW, 5, List.of(), null, + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100), + clock); + + assertThat(decision.kind()).isEqualTo(QueryDecisionKind.ROUTING_AUTO_REJECT); + assertThat(decision.dataBudgetChangedOutcome()).isFalse(); + } + + @Test + void theBytesCapNamesTheSuppressionAheadOfTheBudget() { + givenPlan(false, false); + givenNoPolicyMatch(); + givenNoGrants(); + + var decision = evaluator.evaluate(query(QueryType.SELECT), AiOutcome.SKIPPED, null, -1, + List.of(), cap(null, BytesScannedCapOutcome.NO_ESTIMATE_REVIEW), + budget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 100), + clock); + + assertThat(decision.bytesCapChangedOutcome()).isTrue(); + assertThat(decision.dataBudgetChangedOutcome()).isTrue(); + assertThat(step(decision.trace(), QueryDecisionStepKind.REVIEW_PLAN).reasonKey()) + .isEqualTo("workflow.decision.plan.suppressed_bytes_cap"); + } + // ── Fixtures ────────────────────────────────────────────────────────────── private static com.bablsoft.accessflow.core.api.DecisionTraceStep step( diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachineTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachineTest.java index 5afc2b887..6b11c7852 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachineTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/QueryReviewStateMachineTest.java @@ -75,6 +75,7 @@ class QueryReviewStateMachineTest { @Mock com.bablsoft.accessflow.core.api.BytesScannedCapResolutionService bytesScannedCapResolutionService; @Mock com.bablsoft.accessflow.proxy.api.QueryCostEstimateService queryCostEstimateService; @Mock org.springframework.transaction.PlatformTransactionManager transactionManager; + @Mock com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; // A real ConditionContextFactory over the same mocks, not a mock of it: the context builder is // what turns these signals into routing input, and mocking it away would stop testing that. @@ -93,7 +94,7 @@ void buildStateMachine() { var contextFactory = new com.bablsoft.accessflow.workflow.internal.routing .ConditionContextFactory(queryRequestLookupService, sqlParserService, userQueryService, userGroupService, behaviorAnomalyLookupService, - queryEstimateLookupService); + queryEstimateLookupService, dataBudgetStatusService); // A real QueryDecisionEvaluator too, for the same reason: the assertions below are about the // chain's behaviour, and mocking the decision away would leave only the switch under test. var evaluator = new QueryDecisionEvaluator(reviewPlanLookupService, contextFactory, @@ -101,7 +102,10 @@ void buildStateMachine() { stateMachine = new QueryReviewStateMachine(queryRequestLookupService, evaluator, queryRequestStateService, routingDecisionService, sqlReviewFindingService, auditLogService, messageSource, eventPublisher, bytesScannedCapResolutionService, - queryCostEstimateService, queryEstimateLookupService, transactionManager); + queryCostEstimateService, queryEstimateLookupService, transactionManager, + dataBudgetStatusService); + lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none(inv.getArgument(0))); } @BeforeEach @@ -865,6 +869,89 @@ void aFailingCapAuditNeverUndoesTheRejection() { QueryStatus.REJECTED); } + // ── Data budget (#942) ──────────────────────────────────────────────────── + + private void givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction action, + long usedRows) { + when(dataBudgetStatusService.statusFor(datasourceId, submitterId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", List.of( + new com.bablsoft.accessflow.core.api.DataBudgetConsumption(budgetId, + "Daily", 100L, null, 1440, action, 80, usedRows, 0)))); + } + + private final UUID budgetId = UUID.randomUUID(); + + @Test + void anExhaustedRejectBudgetRejectsAndAudits() { + givenPendingAiQuery(QueryType.SELECT); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100); + when(messageSource.getMessage(eq("workflow.data_budget.rejected"), any(), any())) + .thenReturn("budget used up"); + + stateMachine.onAiCompleted(new AiAnalysisCompletedEvent(queryId, aiAnalysisId, + RiskLevel.LOW)); + + verify(queryRequestStateService).transitionTo(queryId, QueryStatus.PENDING_AI, + QueryStatus.REJECTED); + verify(queryRequestStateService, never()).recordDataBudgetReviewForced(any()); + verify(eventPublisher).publishEvent(new QueryAutoRejectedEvent(queryId, null, + "budget used up")); + var audit = org.mockito.ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService).record(audit.capture()); + assertThat(audit.getValue().action()).isEqualTo(AuditAction.QUERY_DATA_BUDGET_ENFORCED); + assertThat(audit.getValue().metadata()) + .containsEntry("trigger", "data_budget") + .containsEntry("stage", "decision") + .containsEntry("action", "REJECT") + .containsEntry("data_budget_id", budgetId) + .containsEntry("used_rows", 100L) + .containsEntry("window_minutes", 1440); + verify(routingPolicyEngine, never()).evaluate(any(), any(), any()); + } + + @Test + void anExhaustedReviewBudgetHoldsAnAutoApprovalAndAuditsIt() { + givenPendingAiQuery(QueryType.SELECT); + givenPlan(false, false, RiskLevel.LOW); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REQUIRE_REVIEW, 150); + + stateMachine.onAiCompleted(new AiAnalysisCompletedEvent(queryId, aiAnalysisId, + RiskLevel.LOW)); + + verify(queryRequestStateService).transitionTo(queryId, QueryStatus.PENDING_AI, + QueryStatus.PENDING_REVIEW); + verify(queryRequestStateService).recordDataBudgetReviewForced(queryId); + var audit = org.mockito.ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService).record(audit.capture()); + assertThat(audit.getValue().action()).isEqualTo(AuditAction.QUERY_DATA_BUDGET_ENFORCED); + assertThat(audit.getValue().metadata()).containsEntry("action", "REQUIRE_REVIEW"); + } + + @Test + void aWriteIsNeverCheckedAgainstTheBudget() { + givenPendingAiQuery(QueryType.UPDATE); + givenPlan(false, false, RiskLevel.LOW); + + stateMachine.onAiCompleted(new AiAnalysisCompletedEvent(queryId, aiAnalysisId, + RiskLevel.LOW)); + + verify(dataBudgetStatusService, never()).statusFor(any(), any()); + } + + @Test + void aFailingBudgetAuditNeverUndoesTheRejection() { + givenPendingAiQuery(QueryType.SELECT); + givenBudget(com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, 100); + org.mockito.Mockito.doThrow(new IllegalStateException("audit down")) + .when(auditLogService).record(any()); + + stateMachine.onAiCompleted(new AiAnalysisCompletedEvent(queryId, aiAnalysisId, + RiskLevel.LOW)); + + verify(queryRequestStateService).transitionTo(queryId, QueryStatus.PENDING_AI, + QueryStatus.REJECTED); + } + private void givenActiveGrant(AccessGrantView grant) { when(accessGrantLookupService.findActivePreApprovedGrants(organizationId, submitterId, datasourceId)).thenReturn(List.of(grant)); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java index a4f61cb8b..bac048dee 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -43,6 +44,7 @@ class ConditionContextFactoryTest { @Mock UserGroupService userGroupService; @Mock BehaviorAnomalyLookupService behaviorAnomalyLookupService; @Mock QueryEstimateLookupService queryEstimateLookupService; + @Mock com.bablsoft.accessflow.core.api.DataBudgetStatusService dataBudgetStatusService; private ConditionContextFactory factory; @@ -56,7 +58,9 @@ class ConditionContextFactoryTest { void setUp() { factory = new ConditionContextFactory(queryRequestLookupService, sqlParserService, userQueryService, userGroupService, behaviorAnomalyLookupService, - queryEstimateLookupService); + queryEstimateLookupService, dataBudgetStatusService); + lenient().when(dataBudgetStatusService.statusFor(any(), any())) + .thenAnswer(inv -> com.bablsoft.accessflow.core.api.DataBudgetStatus.none(inv.getArgument(0))); when(sqlParserService.parse(any())).thenReturn(new SqlParseResult(QueryType.SELECT, false, List.of("SELECT 1"), Set.of("public.orders"), true, false)); when(userQueryService.findById(submitterId)).thenReturn(Optional.empty()); @@ -237,4 +241,50 @@ void historicalRowCarriesTheParsedQueryShapes() { assertThat(context.queryShapes()).containsExactlyInAnyOrder(QueryShape.JOIN, QueryShape.AGGREGATE); assertThat(context.shapesAnalyzed()).isTrue(); } + + private com.bablsoft.accessflow.core.api.QueryRequestSnapshot live(QueryType type) { + return new com.bablsoft.accessflow.core.api.QueryRequestSnapshot(queryId, datasourceId, + orgId, submitterId, "SELECT 1", type, false, + com.bablsoft.accessflow.core.api.QueryStatus.PENDING_AI, null, null, null, false); + } + + @Test + void aLiveSelectCarriesTheSubmittersMostUsedBudgetShare() { + when(dataBudgetStatusService.statusFor(datasourceId, submitterId)).thenReturn( + new com.bablsoft.accessflow.core.api.DataBudgetStatus(datasourceId, "ds", List.of( + new com.bablsoft.accessflow.core.api.DataBudgetConsumption( + java.util.UUID.randomUUID(), "b", 200L, null, 60, + com.bablsoft.accessflow.core.api.DataBudgetBreachAction.REJECT, + null, 171, 0)))); + + var context = factory.forLiveQuery(live(QueryType.SELECT), RiskLevel.LOW, 10, + java.time.Clock.systemUTC()); + + assertThat(context.dataBudgetUsedPercent()).isEqualTo(85); + } + + @Test + void aLiveSelectWithoutABudgetHasNoBudgetSignal() { + var context = factory.forLiveQuery(live(QueryType.SELECT), RiskLevel.LOW, 10, + java.time.Clock.systemUTC()); + + assertThat(context.dataBudgetUsedPercent()).isNull(); + } + + @Test + void aLiveWriteNeverReadsTheBudget() { + var context = factory.forLiveQuery(live(QueryType.UPDATE), RiskLevel.LOW, 10, + java.time.Clock.systemUTC()); + + assertThat(context.dataBudgetUsedPercent()).isNull(); + org.mockito.Mockito.verify(dataBudgetStatusService, org.mockito.Mockito.never()) + .statusFor(any(), any()); + } + + @Test + void theHistoricalReplayNeverCarriesTheBudgetSignal() { + var context = factory.forHistoricalRow(row(RiskLevel.LOW, 10), ZoneId.of("UTC")); + + assertThat(context.dataBudgetUsedPercent()).isNull(); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java index 6ffdd7627..34248c5b4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java @@ -53,6 +53,7 @@ void roundTripsEveryLeafAndCombinator() { new ConditionNode.CiCdOrigin(true), new ConditionNode.EstimatedRows(ComparisonOperator.GT, 100_000L), new ConditionNode.EstimatedBytesScanned(ComparisonOperator.GT, 1_000_000_000L), + new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GTE, 80), new ConditionNode.ScanTypeMatches(List.of("Seq*", "COLLSCAN")))); var json = codec.encode(tree); @@ -82,6 +83,16 @@ void estimatedBytesScannedUsesItsSnakeCaseDiscriminator() { .isEqualTo(new ConditionNode.EstimatedBytesScanned(ComparisonOperator.GT, 5L)); } + @Test + void dataBudgetUsedPercentUsesItsSnakeCaseDiscriminator() { + var json = codec.encode(new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GTE, 80)); + + assertThat(json).contains("\"type\":\"data_budget_used_percent\""); + assertThat(codec.decode( + "{\"type\":\"data_budget_used_percent\",\"operator\":\"GT\",\"value\":50}")) + .isEqualTo(new ConditionNode.DataBudgetUsedPercent(ComparisonOperator.GT, 50)); + } + @Test void queryShapeUsesItsDiscriminatorAndUpperCaseShapeNames() { var json = codec.encode(new ConditionNode.QueryShapeIn(Set.of(QueryShape.GROUP_BY))); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java index 709a845cc..2a7b15fa2 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java @@ -358,6 +358,28 @@ void estimatedBytesScannedFailsClosedWhenNoEstimate() { ComparisonOperator.GTE, 0), bytesContext(null))).isFalse(); } + @Test + void dataBudgetUsedPercent() { + var ctx = budgetContext(85); + assertThat(evaluator.matches(new ConditionNode.DataBudgetUsedPercent( + ComparisonOperator.GTE, 80), ctx)).isTrue(); + assertThat(evaluator.matches(new ConditionNode.DataBudgetUsedPercent( + ComparisonOperator.GTE, 90), ctx)).isFalse(); + } + + @Test + void dataBudgetUsedPercentFailsClosedWhenNoBudgetApplies() { + assertThat(evaluator.matches(new ConditionNode.DataBudgetUsedPercent( + ComparisonOperator.GTE, 0), budgetContext(null))).isFalse(); + } + + private ConditionContext budgetContext(Integer usedPercent) { + return new ConditionContext(QueryType.SELECT, Set.of("ds.events"), RiskLevel.LOW, 10, + "ANALYST", Set.of(groupId), LocalDateTime.of(2026, 6, 3, 14, 30), + false, false, false, null, null, false, null, false, 10L, null, Set.of(), false, + null, usedPercent); + } + private ConditionContext bytesContext(Long bytes) { return new ConditionContext(QueryType.SELECT, Set.of("ds.events"), RiskLevel.LOW, 10, "ANALYST", Set.of(groupId), LocalDateTime.of(2026, 6, 3, 14, 30), diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AdminAccessSimulationControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AdminAccessSimulationControllerIntegrationTest.java index f7e7d56be..171caac77 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AdminAccessSimulationControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AdminAccessSimulationControllerIntegrationTest.java @@ -119,7 +119,7 @@ void anAdminGetsTheFullOrderedTrace() { .exchange(); assertThat(result).hasStatus(200); - assertThat(result).bodyJson().extractingPath("$.steps.length()").asNumber().isEqualTo(13); + assertThat(result).bodyJson().extractingPath("$.steps.length()").asNumber().isEqualTo(14); assertThat(result).bodyJson().extractingPath("$.steps[0].step").asString() .isEqualTo("DATASOURCE_GATES"); // #864: the SQL review verdict sits between the permission gate and routing. @@ -130,7 +130,12 @@ void anAdminGetsTheFullOrderedTrace() { // #941: the bytes-scanned cap follows it; no cap configured here. assertThat(result).bodyJson().extractingPath("$.steps[5].step").asString() .isEqualTo("BYTES_SCANNED_CAP"); - assertThat(result).bodyJson().extractingPath("$.steps[12].step").asString() + // #942: the data budget follows the cap; no budget configured here. + assertThat(result).bodyJson().extractingPath("$.steps[6].step").asString() + .isEqualTo("DATA_BUDGET"); + assertThat(result).bodyJson().extractingPath("$.steps[6].outcome").asString() + .isEqualTo("NO_MATCH"); + assertThat(result).bodyJson().extractingPath("$.steps[13].step").asString() .isEqualTo("BREAK_GLASS"); assertThat(result).bodyJson().extractingPath("$.resulting_status").asString() .isEqualTo("PENDING_REVIEW"); @@ -295,11 +300,11 @@ void aDetailWithNoValueIsOmittedRatherThanSentAsNull() { .content(body(analyst.getId(), datasource.getId(), "SELECT id FROM orders")) .exchange(); - assertThat(result).bodyJson().extractingPath("$.steps[8].step").asString() + assertThat(result).bodyJson().extractingPath("$.steps[9].step").asString() .isEqualTo("REVIEW_PLAN"); - assertThat(result).bodyJson().doesNotHavePath("$.steps[8].details.review_plan_id"); - assertThat(result).bodyJson().doesNotHavePath("$.steps[8].details.min_approvals_required"); - assertThat(result).bodyJson().extractingPath("$.steps[8].details.requires_human_approval") + assertThat(result).bodyJson().doesNotHavePath("$.steps[9].details.review_plan_id"); + assertThat(result).bodyJson().doesNotHavePath("$.steps[9].details.min_approvals_required"); + assertThat(result).bodyJson().extractingPath("$.steps[9].details.requires_human_approval") .asBoolean().isFalse(); } From 232073873763cf1ccbfaf64bc0edd43ddcec0412 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Fri, 25 Sep 2026 17:00:00 +0400 Subject: [PATCH 2/4] feat(AF-942): data budgets tab, editor indicator and usage drawer Datasource settings gain a Data budgets tab; the query editor shows the caller's remaining allowance; the users page opens a per-user Data usage drawer. Adds the routing operand, trace step, truncation label, bell cases and an e2e spec. Refs #942 --- e2e/helpers/datasources.ts | 46 ++ e2e/tests/data-budgets.spec.ts | 211 +++++++ frontend/src/api/dataBudgets.test.ts | 84 +++ frontend/src/api/dataBudgets.ts | 49 ++ .../admin/UserDataBudgetDrawer.test.tsx | 95 +++ .../components/admin/UserDataBudgetDrawer.tsx | 119 ++++ .../components/common/NotificationBell.tsx | 21 + .../__tests__/NotificationBell.test.tsx | 48 ++ .../datasources/DataBudgetTab.test.tsx | 213 +++++++ .../components/datasources/DataBudgetTab.tsx | 554 ++++++++++++++++++ .../datasources/SampleDataPreview.test.tsx | 15 + .../datasources/SampleDataPreview.tsx | 14 +- .../editor/DataBudgetIndicator.test.tsx | 107 ++++ .../components/editor/DataBudgetIndicator.tsx | 103 ++++ .../policies/decisionTraceDetails.test.ts | 18 + .../policies/decisionTraceDetails.ts | 20 +- .../queries/QueryResultsTable.test.tsx | 10 + .../components/queries/QueryResultsTable.tsx | 6 +- frontend/src/locales/de.json | 124 +++- frontend/src/locales/en.json | 124 +++- frontend/src/locales/es.json | 124 +++- frontend/src/locales/fr.json | 124 +++- frontend/src/locales/hy.json | 124 +++- frontend/src/locales/ru.json | 124 +++- frontend/src/locales/zh-CN.json | 124 +++- frontend/src/pages/admin/AuditLogPage.tsx | 4 + .../src/pages/admin/RoutingPoliciesPage.tsx | 21 + frontend/src/pages/admin/UsersPage.tsx | 11 + .../src/pages/admin/routingPolicyForm.test.ts | 33 ++ frontend/src/pages/admin/routingPolicyForm.ts | 21 + .../datasources/DatasourceSettingsPage.tsx | 14 + frontend/src/pages/editor/QueryEditorPage.tsx | 2 + .../src/pages/queries/QueryDetailPage.tsx | 3 + frontend/src/types/api.ts | 77 ++- .../__tests__/decisionTraceEnums.test.ts | 4 +- frontend/src/utils/dataBudget.test.ts | 53 ++ frontend/src/utils/dataBudget.ts | 54 ++ frontend/src/utils/enumLabels.ts | 12 + frontend/src/utils/permissions.test.ts | 5 +- frontend/src/utils/permissions.ts | 1 + 40 files changed, 2844 insertions(+), 72 deletions(-) create mode 100644 e2e/tests/data-budgets.spec.ts create mode 100644 frontend/src/api/dataBudgets.test.ts create mode 100644 frontend/src/api/dataBudgets.ts create mode 100644 frontend/src/components/admin/UserDataBudgetDrawer.test.tsx create mode 100644 frontend/src/components/admin/UserDataBudgetDrawer.tsx create mode 100644 frontend/src/components/datasources/DataBudgetTab.test.tsx create mode 100644 frontend/src/components/datasources/DataBudgetTab.tsx create mode 100644 frontend/src/components/editor/DataBudgetIndicator.test.tsx create mode 100644 frontend/src/components/editor/DataBudgetIndicator.tsx create mode 100644 frontend/src/utils/dataBudget.test.ts create mode 100644 frontend/src/utils/dataBudget.ts diff --git a/e2e/helpers/datasources.ts b/e2e/helpers/datasources.ts index a2bf8f934..de981ed30 100644 --- a/e2e/helpers/datasources.ts +++ b/e2e/helpers/datasources.ts @@ -842,6 +842,52 @@ export async function createRowLimitPolicyViaApi( return (await res.json()) as CreatedRowLimitPolicy; } +export interface CreatedDataBudget { + id: string; + name: string; + max_rows?: number | null; + max_bytes?: number | null; + window_minutes: number; + breach_action: 'REJECT' | 'REQUIRE_REVIEW'; +} + +// POST /api/v1/datasources/{id}/data-budgets (#942) — bounds the rows / result bytes each +// targeted user may read from the datasource over a rolling window. Requires an ADMIN token. +// Empty applies-to lists apply the budget to every user of the datasource. +export async function createDataBudgetViaApi( + request: APIRequestContext, + adminAccessToken: string, + datasourceId: string, + opts: { + name: string; + maxRows?: number; + maxBytes?: number; + windowMinutes?: number; + breachAction?: 'REJECT' | 'REQUIRE_REVIEW'; + appliesToUserIds?: string[]; + }, +): Promise { + const res = await request.post(`${apiBase()}/api/v1/datasources/${datasourceId}/data-budgets`, { + headers: { Authorization: `Bearer ${adminAccessToken}` }, + data: { + name: opts.name, + max_rows: opts.maxRows ?? null, + max_bytes: opts.maxBytes ?? null, + window_minutes: opts.windowMinutes ?? 1440, + breach_action: opts.breachAction ?? 'REQUIRE_REVIEW', + warn_threshold_percent: null, + applies_to_roles: [], + applies_to_group_ids: [], + applies_to_user_ids: opts.appliesToUserIds ?? [], + enabled: true, + }, + }); + if (!res.ok()) { + throw new Error(`Create data budget failed: ${res.status()} ${await res.text()}`); + } + return (await res.json()) as CreatedDataBudget; +} + // PUT /api/v1/admin/users/{id} (AF-380) — sets the admin-editable attribute map // resolvable in row-security predicates as `:user.`. Requires an ADMIN token. export async function setUserAttributesViaApi( diff --git a/e2e/tests/data-budgets.spec.ts b/e2e/tests/data-budgets.spec.ts new file mode 100644 index 000000000..42d93c38e --- /dev/null +++ b/e2e/tests/data-budgets.spec.ts @@ -0,0 +1,211 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; +import { + acceptInvitationViaApi, + apiBase, + createDataBudgetViaApi, + createPostgresDatasource, + createReviewPlanViaApi, + deleteDatasource, + executeQueryViaApi, + findUserByEmailViaApi, + grantPermissionViaApi, + inviteUserViaApi, + loginViaApi, + submitQueryViaApi, + waitForInviteToken, + waitForQueryStatus, + type CreatedDatasource, + type InvitedUser, +} from '../helpers/datasources'; +import { login } from '../helpers/login'; +import { findRowAcrossPages } from '../helpers/ui'; + +const ADMIN_EMAIL = 'e2e@accessflow.test'; +const ADMIN_PASSWORD = 'E2ePassword!123'; +const ANALYST_PASSWORD = 'Analyst-Pwd!123'; + +// The e2e datasource points at AccessFlow's own database: `role_permissions` holds one row per +// ADMIN permission, comfortably more than the three-row budget used below. +const SELECT_PERMISSIONS = 'SELECT permission FROM role_permissions ORDER BY permission'; + +interface ResultPage { + row_count: number; + truncated: boolean; + truncated_reason: string | null; +} + +interface BudgetStatus { + exhausted: boolean; + remaining_rows?: number | null; + budgets: { used_rows: number }[]; +} + +async function provisionAnalyst( + request: APIRequestContext, + adminToken: string, + label: string, +): Promise<{ user: InvitedUser; email: string; token: string }> { + const email = `${label}-${randomUUID()}@e2e.local`; + await inviteUserViaApi(request, adminToken, email, `Budget ${label}`, 'ANALYST'); + const inviteToken = await waitForInviteToken(request, email); + await acceptInvitationViaApi(request, inviteToken, ANALYST_PASSWORD, `Budget ${label}`); + const token = await loginViaApi(request, email, ANALYST_PASSWORD); + const user = await findUserByEmailViaApi(request, adminToken, email); + return { user, email, token }; +} + +async function myStanding( + request: APIRequestContext, + token: string, + datasourceId: string, +): Promise { + const res = await request.get(`${apiBase()}/api/v1/datasources/${datasourceId}/data-budgets/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) throw new Error(`Budget standing failed: ${res.status()} ${await res.text()}`); + return (await res.json()) as BudgetStatus; +} + +async function selectDatasource(page: Page, name: string): Promise { + await page.goto('/editor'); + await page.getByRole('combobox').first().click(); + await page.locator('.ant-select-item-option').filter({ hasText: name }).click(); +} + +test.describe.configure({ timeout: 90_000 }); + +test.describe.serial('per-user data-volume budgets (#942)', () => { + let adminToken = ''; + let datasource: CreatedDatasource | null = null; + let reviewAnalyst: { user: InvitedUser; email: string; token: string }; + let rejectAnalyst: { user: InvitedUser; email: string; token: string }; + + test.beforeAll(async ({ request }) => { + adminToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD); + reviewAnalyst = await provisionAnalyst(request, adminToken, 'budget-review'); + rejectAnalyst = await provisionAnalyst(request, adminToken, 'budget-reject'); + + // No human approval: an in-budget SELECT auto-approves, so any review below is the budget's. + const plan = await createReviewPlanViaApi(request, adminToken, { + name: `E2E Budget Plan ${Date.now()}`, + requiresHumanApproval: false, + }); + datasource = await createPostgresDatasource(request, adminToken, { + name: `Postgres E2E Budget ${Date.now()}`, + reviewPlanId: plan.id, + }); + for (const analyst of [reviewAnalyst, rejectAnalyst]) { + await grantPermissionViaApi(request, adminToken, datasource.id, analyst.user.id, { + canRead: true, + }); + } + await createDataBudgetViaApi(request, adminToken, datasource.id, { + name: 'Three rows, then review', + maxRows: 3, + breachAction: 'REQUIRE_REVIEW', + appliesToUserIds: [reviewAnalyst.user.id], + }); + await createDataBudgetViaApi(request, adminToken, datasource.id, { + name: 'Three rows, then reject', + maxRows: 3, + breachAction: 'REJECT', + appliesToUserIds: [rejectAnalyst.user.id], + }); + }); + + test.afterAll(async ({ request }) => { + if (datasource) { + await deleteDatasource(request, adminToken, datasource.id); + } + }); + + test('a read is capped at the allowance left, then the next one goes to review', async ({ + request, + }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + const first = await submitQueryViaApi(request, reviewAnalyst.token, datasource.id, + SELECT_PERMISSIONS, '#942 data budget — first read'); + await waitForQueryStatus(request, reviewAnalyst.token, first.id, 'APPROVED'); + const outcome = await executeQueryViaApi(request, reviewAnalyst.token, first.id); + expect(outcome.status).toBe('EXECUTED'); + const res = await request.get(`${apiBase()}/api/v1/queries/${first.id}/results`, { + headers: { Authorization: `Bearer ${reviewAnalyst.token}` }, + }); + expect(res.ok()).toBe(true); + const page = (await res.json()) as ResultPage; + expect(page.row_count).toBe(3); + expect(page.truncated_reason).toBe('DATA_BUDGET'); + + const standing = await myStanding(request, reviewAnalyst.token, datasource.id); + expect(standing.exhausted).toBe(true); + expect(standing.budgets[0]?.used_rows).toBe(3); + + const second = await submitQueryViaApi(request, reviewAnalyst.token, datasource.id, + SELECT_PERMISSIONS, '#942 data budget — over budget'); + await waitForQueryStatus(request, reviewAnalyst.token, second.id, 'PENDING_REVIEW'); + }); + + test('a rejecting budget refuses the read once used up', async ({ request }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + const first = await submitQueryViaApi(request, rejectAnalyst.token, datasource.id, + SELECT_PERMISSIONS, '#942 data budget — first read'); + await waitForQueryStatus(request, rejectAnalyst.token, first.id, 'APPROVED'); + await executeQueryViaApi(request, rejectAnalyst.token, first.id); + + const second = await submitQueryViaApi(request, rejectAnalyst.token, datasource.id, + SELECT_PERMISSIONS, '#942 data budget — refused'); + await waitForQueryStatus(request, rejectAnalyst.token, second.id, 'REJECTED'); + }); + + test('the analyst sees their used-up budget in the editor', async ({ page }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + await login(page, reviewAnalyst.email, ANALYST_PASSWORD); + await selectDatasource(page, datasource.name); + + const indicator = page.getByTestId('data-budget-indicator'); + await expect(indicator).toBeVisible({ timeout: 15_000 }); + await expect(indicator.getByText('Data budget used up')).toBeVisible(); + await expect(indicator.getByText('Three rows, then review')).toBeVisible(); + }); + + test('admin creates a data budget via the Data budgets tab', async ({ page }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`/datasources/${datasource.id}/settings`); + await page.getByRole('tab', { name: /Data budgets/ }).click(); + + // The settings page renders tab content beside , not inside its tabpanel. + await expect(page.getByText('Three rows, then review')).toBeVisible({ timeout: 15_000 }); + + await page.getByRole('button', { name: 'Add budget' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Name').fill('Weekly analyst cap'); + await dialog.getByLabel('Row limit').fill('50000'); + await dialog.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByText('Data budget saved')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('cell', { name: 'Weekly analyst cap', exact: true })).toBeVisible({ + timeout: 10_000, + }); + }); + + test('admin sees a user\'s data usage from the users page', async ({ page }) => { + if (!datasource) throw new Error('datasource not created in beforeAll'); + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/users'); + + // The users table has no server-side search; page through it (parallel specs add users). + // The invitations table below lists the same email; the users table renders first. + const row = page.getByRole('row').filter({ hasText: reviewAnalyst.email }).first(); + await findRowAcrossPages(page, row); + // dispatchEvent: the sticky onboarding panel can overlay the row once the table scrolls. + await row.getByRole('button', { name: 'Edit' }).dispatchEvent('click'); + await page.getByRole('menuitem', { name: /Data usage/ }).click(); + + const drawer = page.getByRole('dialog').filter({ hasText: 'Data usage —' }); + await expect(drawer.getByText(datasource.name)).toBeVisible({ timeout: 15_000 }); + await expect(drawer.getByText('Three rows, then review')).toBeVisible(); + await expect(drawer.getByText('Used up')).toBeVisible(); + }); +}); diff --git a/frontend/src/api/dataBudgets.test.ts b/frontend/src/api/dataBudgets.test.ts new file mode 100644 index 000000000..cb5f96371 --- /dev/null +++ b/frontend/src/api/dataBudgets.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { get, post, put, del } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), +})); + +vi.mock('./client', () => ({ + apiClient: { get, post, put, delete: del }, +})); + +import * as api from './dataBudgets'; +import { dataBudgetKeys } from './dataBudgets'; +import type { DataBudgetInput } from '@/types/api'; + +const budget = { + id: 'b-1', + datasource_id: 'ds-1', + name: 'Daily', + max_rows: 1000, + window_minutes: 1440, + breach_action: 'REQUIRE_REVIEW' as const, + applies_to_roles: [], + applies_to_group_ids: [], + applies_to_user_ids: [], + enabled: true, + created_at: '2026-09-01T10:00:00Z', + updated_at: '2026-09-01T10:00:00Z', +}; + +const input: DataBudgetInput = { + name: 'Daily', + max_rows: 1000, + window_minutes: 1440, + breach_action: 'REQUIRE_REVIEW', + enabled: true, +}; + +describe('api/dataBudgets', () => { + beforeEach(() => { + get.mockReset(); + post.mockReset(); + put.mockReset(); + del.mockReset(); + }); + + it('builds query keys', () => { + expect(dataBudgetKeys.list('ds-1')).toEqual(['data-budgets', 'list', 'ds-1']); + expect(dataBudgetKeys.mine('ds-1')).toEqual(['data-budgets', 'me', 'ds-1']); + expect(dataBudgetKeys.forUser('u-1')).toEqual(['data-budgets', 'user', 'u-1']); + }); + + it('lists budgets for a datasource', async () => { + get.mockResolvedValue({ data: { content: [budget] } }); + await expect(api.listDataBudgets('ds-1')).resolves.toEqual([budget]); + expect(get).toHaveBeenCalledWith('/api/v1/datasources/ds-1/data-budgets'); + }); + + it('creates, updates and deletes a budget', async () => { + post.mockResolvedValue({ data: budget }); + put.mockResolvedValue({ data: budget }); + del.mockResolvedValue({}); + + await expect(api.createDataBudget('ds-1', input)).resolves.toEqual(budget); + expect(post).toHaveBeenCalledWith('/api/v1/datasources/ds-1/data-budgets', input); + await expect(api.updateDataBudget('ds-1', 'b-1', input)).resolves.toEqual(budget); + expect(put).toHaveBeenCalledWith('/api/v1/datasources/ds-1/data-budgets/b-1', input); + await api.deleteDataBudget('ds-1', 'b-1'); + expect(del).toHaveBeenCalledWith('/api/v1/datasources/ds-1/data-budgets/b-1'); + }); + + it('reads the caller standing and a user standing', async () => { + const status = { datasource_id: 'ds-1', exhausted: false, budgets: [] }; + get.mockResolvedValueOnce({ data: status }); + await expect(api.getMyDataBudgetStatus('ds-1')).resolves.toEqual(status); + expect(get).toHaveBeenCalledWith('/api/v1/datasources/ds-1/data-budgets/me'); + + get.mockResolvedValueOnce({ data: { content: [status] } }); + await expect(api.getUserDataBudgetUsage('u-1')).resolves.toEqual([status]); + expect(get).toHaveBeenCalledWith('/api/v1/admin/users/u-1/data-budget-usage'); + }); +}); diff --git a/frontend/src/api/dataBudgets.ts b/frontend/src/api/dataBudgets.ts new file mode 100644 index 000000000..d7274f31b --- /dev/null +++ b/frontend/src/api/dataBudgets.ts @@ -0,0 +1,49 @@ +import { apiClient } from './client'; +import type { DataBudget, DataBudgetInput, DataBudgetStatus } from '@/types/api'; + +const base = (datasourceId: string) => `/api/v1/datasources/${datasourceId}/data-budgets`; + +export const dataBudgetKeys = { + all: ['data-budgets'] as const, + list: (datasourceId: string) => ['data-budgets', 'list', datasourceId] as const, + mine: (datasourceId: string) => ['data-budgets', 'me', datasourceId] as const, + forUser: (userId: string) => ['data-budgets', 'user', userId] as const, +}; + +export async function listDataBudgets(datasourceId: string): Promise { + const { data } = await apiClient.get<{ content: DataBudget[] }>(base(datasourceId)); + return data.content; +} + +export async function createDataBudget( + datasourceId: string, + input: DataBudgetInput, +): Promise { + const { data } = await apiClient.post(base(datasourceId), input); + return data; +} + +export async function updateDataBudget( + datasourceId: string, + budgetId: string, + input: DataBudgetInput, +): Promise { + const { data } = await apiClient.put(`${base(datasourceId)}/${budgetId}`, input); + return data; +} + +export async function deleteDataBudget(datasourceId: string, budgetId: string): Promise { + await apiClient.delete(`${base(datasourceId)}/${budgetId}`); +} + +export async function getMyDataBudgetStatus(datasourceId: string): Promise { + const { data } = await apiClient.get(`${base(datasourceId)}/me`); + return data; +} + +export async function getUserDataBudgetUsage(userId: string): Promise { + const { data } = await apiClient.get<{ content: DataBudgetStatus[] }>( + `/api/v1/admin/users/${userId}/data-budget-usage`, + ); + return data.content; +} diff --git a/frontend/src/components/admin/UserDataBudgetDrawer.test.tsx b/frontend/src/components/admin/UserDataBudgetDrawer.test.tsx new file mode 100644 index 000000000..3a65fb97f --- /dev/null +++ b/frontend/src/components/admin/UserDataBudgetDrawer.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@/i18n'; +import type { DataBudgetStatus, User } from '@/types/api'; + +const getUserDataBudgetUsage = vi.fn(); + +vi.mock('@/api/dataBudgets', async (importOriginal) => { + const original = await importOriginal(); + return { + dataBudgetKeys: original.dataBudgetKeys, + getUserDataBudgetUsage: (...args: unknown[]) => getUserDataBudgetUsage(...args), + }; +}); + +const { UserDataBudgetDrawer } = await import('./UserDataBudgetDrawer'); + +const user = { + id: 'u-1', + email: 'ana@example.com', + display_name: 'Ana Analyst', + active: true, +} as User; + +const status: DataBudgetStatus = { + datasource_id: 'ds-1', + datasource_name: 'Warehouse', + exhausted: true, + breach_action: 'REJECT', + budgets: [ + { + id: 'b-1', + name: 'Daily rows', + max_rows: 1000, + max_bytes: 2_000_000_000, + window_minutes: 1440, + breach_action: 'REJECT', + used_rows: 1000, + used_bytes: 500_000_000, + remaining_rows: 0, + remaining_bytes: 1_500_000_000, + used_percent: 100, + exhausted: true, + }, + ], +}; + +function renderDrawer(u: User | null) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + undefined} /> + , + ); +} + +describe('UserDataBudgetDrawer', () => { + beforeEach(() => { + getUserDataBudgetUsage.mockReset(); + }); + + it('does not load anything while closed', () => { + renderDrawer(null); + expect(getUserDataBudgetUsage).not.toHaveBeenCalled(); + }); + + it('shows usage per datasource and budget', async () => { + getUserDataBudgetUsage.mockResolvedValue([status]); + renderDrawer(user); + + expect(await screen.findByText('Warehouse')).toBeInTheDocument(); + expect(screen.getByText('Data usage — Ana Analyst')).toBeInTheDocument(); + expect(screen.getByText('Used up')).toBeInTheDocument(); + expect(screen.getByText('Daily rows')).toBeInTheDocument(); + expect(screen.getByText(/1,000 of 1,000 rows/)).toBeInTheDocument(); + expect(getUserDataBudgetUsage).toHaveBeenCalledWith('u-1'); + }); + + it('says when no budget applies', async () => { + getUserDataBudgetUsage.mockResolvedValue([]); + renderDrawer(user); + + expect(await screen.findByText('No data budget applies to this user.')).toBeInTheDocument(); + }); + + it('reports a load failure', async () => { + getUserDataBudgetUsage.mockRejectedValue(new Error('boom')); + renderDrawer(user); + + await waitFor(() => + expect(screen.getByText('Could not load this user’s data usage')).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/components/admin/UserDataBudgetDrawer.tsx b/frontend/src/components/admin/UserDataBudgetDrawer.tsx new file mode 100644 index 000000000..1fb4838e0 --- /dev/null +++ b/frontend/src/components/admin/UserDataBudgetDrawer.tsx @@ -0,0 +1,119 @@ +import { Alert, Drawer, Empty, Progress, Skeleton, Tag } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { dataBudgetKeys, getUserDataBudgetUsage } from '@/api/dataBudgets'; +import type { DataBudgetConsumption, DataBudgetStatus, User } from '@/types/api'; +import { dataBudgetBreachActionLabel } from '@/utils/enumLabels'; +import { formatWindow } from '@/utils/dataBudget'; +import { formatBytes } from '@/utils/queryPlan'; +import { userDisplay } from '@/utils/userDisplay'; + +interface Props { + user: User | null; + onClose: () => void; +} + +/** + * An admin's view of one user's data-volume usage (#942) against every budget that applies to them, + * grouped by datasource. There is no user detail page, so the drawer opens from the users list. + */ +export function UserDataBudgetDrawer({ user, onClose }: Props) { + const { t } = useTranslation(); + const usageQuery = useQuery({ + queryKey: user ? dataBudgetKeys.forUser(user.id) : ['data-budgets', 'user', 'idle'], + queryFn: () => getUserDataBudgetUsage(user!.id), + enabled: !!user, + }); + + return ( + +
+ {t('dataBudgets.usage.description')} +
+ {usageQuery.isLoading ? ( + + ) : usageQuery.isError ? ( + + ) : (usageQuery.data ?? []).length === 0 ? ( + + ) : ( + (usageQuery.data ?? []).map((status) => ( + + )) + )} +
+ ); +} + +function DatasourceUsage({ status }: { status: DataBudgetStatus }) { + const { t } = useTranslation(); + return ( +
+
+ {status.datasource_name ?? status.datasource_id} + {status.exhausted && {t('dataBudgets.usage.exhausted')}} +
+ {status.budgets.map((budget) => ( + + ))} +
+ ); +} + +function BudgetUsage({ budget }: { budget: DataBudgetConsumption }) { + const { t } = useTranslation(); + const parts: string[] = []; + if (budget.max_rows != null) { + parts.push( + t('dataBudgets.usage.rows_used', { + used: budget.used_rows.toLocaleString(), + limit: budget.max_rows.toLocaleString(), + }), + ); + } + if (budget.max_bytes != null) { + parts.push( + t('dataBudgets.usage.bytes_used', { + used: formatBytes(budget.used_bytes), + limit: formatBytes(budget.max_bytes) ?? '', + }), + ); + } + const percent = Math.min(100, budget.used_percent); + return ( +
+
+ {budget.name} + + {t('dataBudgets.indicator.per_window', { window: formatWindow(t, budget.window_minutes) })} + {' · '} + {dataBudgetBreachActionLabel(t, budget.breach_action)} + +
+ +
{parts.join(' · ')}
+
+ ); +} diff --git a/frontend/src/components/common/NotificationBell.tsx b/frontend/src/components/common/NotificationBell.tsx index 6cb96c76b..00715a398 100644 --- a/frontend/src/components/common/NotificationBell.tsx +++ b/frontend/src/components/common/NotificationBell.tsx @@ -308,6 +308,19 @@ function renderMessage( pipeline: datasource, environment: payload.environment ?? '—', }); + // #942 — the budget's user rides in `submitter` (the admin copy of an exhaustion names them). + case 'DATA_BUDGET_THRESHOLD_REACHED': + return t('notifications.events.DATA_BUDGET_THRESHOLD_REACHED', { + percent: payload.used_percent ?? '—', + budget: payload.budget ?? '—', + datasource, + }); + case 'DATA_BUDGET_EXHAUSTED': + return t('notifications.events.DATA_BUDGET_EXHAUSTED', { + budget: payload.budget ?? '—', + user: payload.submitter_name ?? payload.submitter ?? '—', + datasource, + }); default: return t('notifications.events.fallback'); } @@ -386,6 +399,14 @@ export function routeForNotification(item: UserNotification): string | null { if (item.event_type === 'SCHEMA_DRIFT_DETECTED') { return '/schema-drift'; } + // #942: a data budget constrains reads, so the recipient lands where reads are written. The + // editor takes its datasource from router state, not the URL, so the route carries no id. + if ( + item.event_type === 'DATA_BUDGET_THRESHOLD_REACHED' || + item.event_type === 'DATA_BUDGET_EXHAUSTED' + ) { + return '/editor'; + } return item.query_request_id ? `/queries/${item.query_request_id}` : null; } diff --git a/frontend/src/components/common/__tests__/NotificationBell.test.tsx b/frontend/src/components/common/__tests__/NotificationBell.test.tsx index 7b2d4ea05..31c441312 100644 --- a/frontend/src/components/common/__tests__/NotificationBell.test.tsx +++ b/frontend/src/components/common/__tests__/NotificationBell.test.tsx @@ -677,6 +677,54 @@ describe('NotificationBell', () => { expect(navigateMock).toHaveBeenCalledWith('/schema-drift'); }); + it.each([ + [ + 'DATA_BUDGET_THRESHOLD_REACHED' as const, + { datasource: 'warehouse', datasource_id: 'ds-1', budget: 'daily-reads', used_percent: 80 }, + 'You have used 80% of your data budget daily-reads on warehouse', + ], + [ + 'DATA_BUDGET_EXHAUSTED' as const, + { + datasource: 'warehouse', + datasource_id: 'ds-1', + budget: 'daily-reads', + used_percent: 100, + submitter: 'ana@example.com', + submitter_name: 'Ana', + }, + 'Data budget daily-reads for Ana on warehouse is used up', + ], + ])('renders %s and opens the query editor (#942)', async (eventType, payload, expected) => { + fetchUnreadCountMock.mockResolvedValue({ count: 1 }); + markNotificationReadMock.mockResolvedValue(undefined); + listNotificationsMock.mockResolvedValue( + page([ + { + id: 'budget1', + event_type: eventType, + query_request_id: null, + api_request_id: null, + deployment_request_id: null, + payload, + read: false, + created_at: new Date().toISOString(), + read_at: null, + }, + ]), + ); + + render(wrap()); + fireEvent.click(screen.getByLabelText('Notifications')); + const text = await screen.findByText(expected); + const row = text.closest('.ant-list-item'); + if (!row) throw new Error('list row not found'); + fireEvent.click(row); + + await waitFor(() => expect(markNotificationReadMock).toHaveBeenCalledWith('budget1')); + expect(navigateMock).toHaveBeenCalledWith('/editor'); + }); + it('routes a plain FAILED outcome to the deployment, not the rollback worklist', async () => { // A rollback review only exists for ROLLED_BACK; a FAILED deploy would never appear there. fetchUnreadCountMock.mockResolvedValue({ count: 1 }); diff --git a/frontend/src/components/datasources/DataBudgetTab.test.tsx b/frontend/src/components/datasources/DataBudgetTab.test.tsx new file mode 100644 index 000000000..170c4acf4 --- /dev/null +++ b/frontend/src/components/datasources/DataBudgetTab.test.tsx @@ -0,0 +1,213 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { App as AntdApp } from 'antd'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@/i18n'; +import type { DataBudget } from '@/types/api'; + +const listDataBudgets = vi.fn(); +const createDataBudget = vi.fn(); +const updateDataBudget = vi.fn(); +const deleteDataBudget = vi.fn(); + +vi.mock('@/api/dataBudgets', async (importOriginal) => { + const original = await importOriginal(); + return { + dataBudgetKeys: original.dataBudgetKeys, + listDataBudgets: (...args: unknown[]) => listDataBudgets(...args), + createDataBudget: (...args: unknown[]) => createDataBudget(...args), + updateDataBudget: (...args: unknown[]) => updateDataBudget(...args), + deleteDataBudget: (...args: unknown[]) => deleteDataBudget(...args), + }; +}); + +vi.mock('@/api/admin', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + listUsers: () => + Promise.resolve({ content: [], page: 0, size: 100, total_elements: 0, total_pages: 0 }), + }; +}); + +vi.mock('@/api/groups', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, listAllGroups: () => Promise.resolve([]) }; +}); + +vi.mock('@/api/roles', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, listRoles: () => Promise.resolve([]) }; +}); + +const { DataBudgetTab } = await import('./DataBudgetTab'); + +const budget: DataBudget = { + id: 'b-1', + datasource_id: 'ds-1', + name: 'Analysts daily', + max_rows: 100000, + max_bytes: 5_000_000_000, + window_minutes: 1440, + breach_action: 'REQUIRE_REVIEW', + warn_threshold_percent: 80, + applies_to_roles: ['ANALYST'], + applies_to_group_ids: [], + applies_to_user_ids: ['u-1', 'u-2'], + enabled: true, + created_at: '2026-09-01T10:00:00Z', + updated_at: '2026-09-01T10:00:00Z', +}; + +function renderTab() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +async function openModal(trigger: HTMLElement) { + fireEvent.click(trigger); + return waitFor(() => { + const el = document.querySelector('.ant-modal') as HTMLElement; + expect(el).toBeTruthy(); + return el; + }); +} + +describe('DataBudgetTab', () => { + beforeEach(() => { + listDataBudgets.mockReset().mockResolvedValue([budget]); + createDataBudget.mockReset().mockResolvedValue(budget); + updateDataBudget.mockReset().mockResolvedValue(budget); + deleteDataBudget.mockReset().mockResolvedValue(undefined); + }); + + it('renders the empty state when there are no budgets', async () => { + listDataBudgets.mockResolvedValue([]); + renderTab(); + + expect(await screen.findByText('No data budgets')).toBeInTheDocument(); + }); + + it('lists budgets with limits, window, action and scope', async () => { + listDataBudgets.mockResolvedValue([ + budget, + { ...budget, id: 'b-2', name: 'Weekly', max_bytes: null, window_minutes: 10080, + breach_action: 'REJECT', applies_to_roles: [], applies_to_user_ids: [], enabled: false }, + ]); + renderTab(); + + expect(await screen.findByText('Analysts daily')).toBeInTheDocument(); + expect(screen.getByText('1 day')).toBeInTheDocument(); + expect(screen.getByText('7 days')).toBeInTheDocument(); + expect(screen.getByText('Send to human review')).toBeInTheDocument(); + expect(screen.getByText('Reject')).toBeInTheDocument(); + expect(screen.getByText('1 role · 2 users')).toBeInTheDocument(); + expect(screen.getByText('Everyone')).toBeInTheDocument(); + }); + + it('creates a row budget with the default window, action and threshold', async () => { + listDataBudgets.mockResolvedValue([]); + renderTab(); + const modal = await openModal(await screen.findByText('Add budget')); + + fireEvent.change(within(modal).getByLabelText('Name'), { target: { value: 'Daily cap' } }); + fireEvent.change(within(modal).getByLabelText('Row limit'), { target: { value: '5000' } }); + fireEvent.click(within(modal).getByText('Save')); + + await waitFor(() => expect(createDataBudget).toHaveBeenCalled()); + expect(createDataBudget.mock.calls[0]?.[0]).toBe('ds-1'); + expect(createDataBudget.mock.calls[0]?.[1]).toEqual({ + name: 'Daily cap', + max_rows: 5000, + max_bytes: null, + window_minutes: 1440, + breach_action: 'REQUIRE_REVIEW', + warn_threshold_percent: 80, + applies_to_roles: [], + applies_to_group_ids: [], + applies_to_user_ids: [], + enabled: true, + }); + }); + + it('requires a name and at least one limit', async () => { + listDataBudgets.mockResolvedValue([]); + renderTab(); + const modal = await openModal(await screen.findByText('Add budget')); + + fireEvent.click(within(modal).getByText('Save')); + + expect(await within(modal).findByText('Enter a name')).toBeInTheDocument(); + expect( + (await within(modal).findAllByText('Set a row limit or a result-size limit')).length, + ).toBeGreaterThan(0); + expect(createDataBudget).not.toHaveBeenCalled(); + }); + + it('rejects a window outside one hour to 31 days', async () => { + listDataBudgets.mockResolvedValue([]); + renderTab(); + const modal = await openModal(await screen.findByText('Add budget')); + + fireEvent.change(within(modal).getByLabelText('Name'), { target: { value: 'Too long' } }); + fireEvent.change(within(modal).getByLabelText('Row limit'), { target: { value: '10' } }); + fireEvent.change(within(modal).getByLabelText('Rolling window'), { target: { value: '40' } }); + fireEvent.click(within(modal).getByText('Save')); + + expect( + await within(modal).findByText('The window must be between 1 hour and 31 days'), + ).toBeInTheDocument(); + expect(createDataBudget).not.toHaveBeenCalled(); + }); + + it('edits an existing budget through update, keeping its window', async () => { + renderTab(); + const modal = await openModal(await screen.findByLabelText('Edit budget')); + await waitFor(() => + expect(within(modal).getByLabelText('Name')).toHaveValue('Analysts daily'), + ); + + fireEvent.change(within(modal).getByLabelText('Name'), { target: { value: 'Renamed' } }); + fireEvent.click(within(modal).getByText('Save')); + + await waitFor(() => expect(updateDataBudget).toHaveBeenCalled()); + expect(updateDataBudget.mock.calls[0]?.[1]).toBe('b-1'); + expect(updateDataBudget.mock.calls[0]?.[2]).toMatchObject({ + name: 'Renamed', + max_rows: 100000, + max_bytes: 5_000_000_000, + window_minutes: 1440, + applies_to_roles: ['ANALYST'], + applies_to_user_ids: ['u-1', 'u-2'], + }); + }); + + it('deletes a budget after confirmation', async () => { + renderTab(); + fireEvent.click(await screen.findByLabelText('Delete budget')); + const [confirm] = await screen.findAllByText('Delete this data budget?'); + const confirmDialog = confirm?.closest('.ant-modal') as HTMLElement; + fireEvent.click(within(confirmDialog).getByRole('button', { name: 'Delete budget' })); + + await waitFor(() => expect(deleteDataBudget).toHaveBeenCalled()); + expect(deleteDataBudget.mock.calls[0]).toEqual(['ds-1', 'b-1']); + }); + + it('surfaces a save failure', async () => { + listDataBudgets.mockResolvedValue([]); + createDataBudget.mockRejectedValue(new Error('Budget rejected')); + renderTab(); + const modal = await openModal(await screen.findByText('Add budget')); + fireEvent.change(within(modal).getByLabelText('Name'), { target: { value: 'X' } }); + fireEvent.change(within(modal).getByLabelText('Row limit'), { target: { value: '1' } }); + fireEvent.click(within(modal).getByText('Save')); + + expect(await screen.findByText('Budget rejected')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/datasources/DataBudgetTab.tsx b/frontend/src/components/datasources/DataBudgetTab.tsx new file mode 100644 index 000000000..910e5e94d --- /dev/null +++ b/frontend/src/components/datasources/DataBudgetTab.tsx @@ -0,0 +1,554 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + App, + Button, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Switch, + Table, + Tooltip, +} from 'antd'; +import { + CheckCircleOutlined, + DeleteOutlined, + EditOutlined, + MinusCircleOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { EmptyState } from '@/components/common/EmptyState'; +import { BytesInput } from '@/components/common/BytesInput'; +import { renderUserOption } from '@/components/common/renderUserOption'; +import { + createDataBudget, + dataBudgetKeys, + deleteDataBudget, + listDataBudgets, + updateDataBudget, +} from '@/api/dataBudgets'; +import { listUsers, userKeys } from '@/api/admin'; +import { groupKeys, listAllGroups } from '@/api/groups'; +import { listRoles, roleKeys } from '@/api/roles'; +import { roleSelectOptions } from '@/utils/roleOptions'; +import { userDisplay } from '@/utils/userDisplay'; +import { apiErrorMessage } from '@/utils/apiErrors'; +import { showApiError } from '@/utils/showApiError'; +import { formatBytes } from '@/utils/queryPlan'; +import { + DATA_BUDGET_BREACH_ACTIONS, + dataBudgetBreachActionLabel, + enumOptions, +} from '@/utils/enumLabels'; +import { + DATA_BUDGET_DEFAULT_WINDOW_MINUTES, + DATA_BUDGET_NAME_MAX, + DATA_BUDGET_THRESHOLD_MAX, + DATA_BUDGET_THRESHOLD_MIN, + DATA_BUDGET_WINDOW_MAX_MINUTES, + DATA_BUDGET_WINDOW_MIN_MINUTES, + DATA_BUDGET_WINDOW_UNITS, + formatWindow, + joinWindow, + splitWindow, + type DataBudgetWindowUnit, +} from '@/utils/dataBudget'; +import type { DataBudget, DataBudgetBreachAction, DataBudgetInput, User } from '@/types/api'; + +export function DataBudgetTab({ dsId }: { dsId: string }) { + const { t } = useTranslation(); + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const budgetsQuery = useQuery({ + queryKey: dataBudgetKeys.list(dsId), + queryFn: () => listDataBudgets(dsId), + }); + const budgets = budgetsQuery.data ?? []; + + const deleteMutation = useMutation({ + mutationFn: (budgetId: string) => deleteDataBudget(dsId, budgetId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: dataBudgetKeys.all }); + message.success(t('dataBudgets.tab.delete_success')); + }, + onError: (err) => { + showApiError(message, err, (e) => + apiErrorMessage(e, () => t('dataBudgets.tab.delete_error')), + ); + }, + }); + + const onAdd = () => { + setEditing(null); + setModalOpen(true); + }; + + const onDelete = (budget: DataBudget) => { + modal.confirm({ + title: t('dataBudgets.tab.delete_confirm_title'), + content: t('dataBudgets.tab.delete_confirm_body'), + okType: 'danger', + okText: t('dataBudgets.tab.delete'), + cancelText: t('common.cancel'), + onOk: () => deleteMutation.mutateAsync(budget.id), + }); + }; + + return ( +
+
+
+
{t('dataBudgets.tab.title')}
+
+ {t('dataBudgets.tab.description')} +
+
+ +
+ + {!budgetsQuery.isLoading && budgets.length === 0 ? ( + + ) : ( + + rowKey="id" + size="middle" + loading={budgetsQuery.isLoading} + dataSource={budgets} + pagination={false} + scroll={{ x: 'max-content' }} + columns={[ + { title: t('dataBudgets.tab.col_name'), dataIndex: 'name' }, + { + title: t('dataBudgets.tab.col_limits'), + render: (_v, b) => , + }, + { + title: t('dataBudgets.tab.col_window'), + width: 110, + render: (_v, b) => formatWindow(t, b.window_minutes), + }, + { + title: t('dataBudgets.tab.col_action'), + width: 180, + render: (_v, b) => dataBudgetBreachActionLabel(t, b.breach_action), + }, + { + title: t('dataBudgets.tab.col_applies_to'), + render: (_v, b) => , + }, + { + title: t('dataBudgets.tab.col_enabled'), + width: 90, + align: 'center', + render: (_v, b) => { + const label = b.enabled + ? t('dataBudgets.tab.label_enabled') + : t('dataBudgets.tab.state_disabled'); + return ( + + {b.enabled ? ( + + ) : ( + + )} + + ); + }, + }, + { + title: t('dataBudgets.tab.col_actions'), + width: 120, + align: 'right', + render: (_v, b) => ( + <> +
+ ); +} + +function LimitsSummary({ budget }: { budget: DataBudget }) { + const { t } = useTranslation(); + const parts: string[] = []; + if (budget.max_rows != null) { + parts.push(t('dataBudgets.tab.rows_value', { value: budget.max_rows.toLocaleString() })); + } + if (budget.max_bytes != null) { + parts.push(formatBytes(budget.max_bytes) ?? ''); + } + return {parts.join(' · ')}; +} + +function AppliesToSummary({ budget }: { budget: DataBudget }) { + const { t } = useTranslation(); + const parts: string[] = []; + if (budget.applies_to_roles.length > 0) { + parts.push(t('dataBudgets.tab.applies_roles', { count: budget.applies_to_roles.length })); + } + if (budget.applies_to_group_ids.length > 0) { + parts.push(t('dataBudgets.tab.applies_groups', { count: budget.applies_to_group_ids.length })); + } + if (budget.applies_to_user_ids.length > 0) { + parts.push(t('dataBudgets.tab.applies_users', { count: budget.applies_to_user_ids.length })); + } + if (parts.length === 0) { + return ( + + {t('dataBudgets.tab.applies_everyone')} + + ); + } + return {parts.join(' · ')}; +} + +interface DataBudgetFormValues { + name: string; + max_rows?: number | null; + max_bytes?: number | null; + window_amount: number; + window_unit: DataBudgetWindowUnit; + breach_action: DataBudgetBreachAction; + warn_threshold_percent?: number | null; + applies_to_roles?: string[]; + applies_to_group_ids?: string[]; + applies_to_user_ids?: string[]; + enabled: boolean; +} + +interface DataBudgetModalProps { + open: boolean; + dsId: string; + budget: DataBudget | null; + onClose: () => void; +} + +const DEFAULT_VALUES: Partial = { + ...(() => { + const { amount, unit } = splitWindow(DATA_BUDGET_DEFAULT_WINDOW_MINUTES); + return { window_amount: amount, window_unit: unit }; + })(), + breach_action: 'REQUIRE_REVIEW', + warn_threshold_percent: 80, + enabled: true, +}; + +function DataBudgetModal({ open, dsId, budget, onClose }: DataBudgetModalProps) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + + const usersQuery = useQuery({ + queryKey: userKeys.list({ size: 100 }), + queryFn: () => listUsers({ size: 100 }), + enabled: open, + }); + const groupsQuery = useQuery({ + queryKey: groupKeys.lists(), + queryFn: () => listAllGroups(), + enabled: open, + }); + const rolesQuery = useQuery({ + queryKey: roleKeys.lists(), + queryFn: listRoles, + enabled: open, + }); + const roleOptions = useMemo( + () => roleSelectOptions(rolesQuery.data ?? [], t, 'name'), + [rolesQuery.data, t], + ); + const userOptions = useMemo( + () => + (usersQuery.data?.content ?? []) + .filter((u: User) => u.active) + .map((u: User) => ({ + value: u.id, + label: userDisplay(u.display_name, u.email), + principal_type: u.principal_type ?? 'HUMAN', + })), + [usersQuery.data], + ); + const groupOptions = useMemo( + () => (groupsQuery.data ?? []).map((g) => ({ value: g.id, label: g.name })), + [groupsQuery.data], + ); + const unitOptions = DATA_BUDGET_WINDOW_UNITS.map((unit) => ({ + value: unit, + label: t(`dataBudgets.tab.unit_${unit}` as const), + })); + + useEffect(() => { + if (!open) return; + if (budget) { + const { amount, unit } = splitWindow(budget.window_minutes); + form.setFieldsValue({ + name: budget.name, + max_rows: budget.max_rows ?? null, + max_bytes: budget.max_bytes ?? null, + window_amount: amount, + window_unit: unit, + breach_action: budget.breach_action, + warn_threshold_percent: budget.warn_threshold_percent ?? null, + applies_to_roles: budget.applies_to_roles, + applies_to_group_ids: budget.applies_to_group_ids, + applies_to_user_ids: budget.applies_to_user_ids, + enabled: budget.enabled, + }); + } else { + form.resetFields(); + } + }, [open, budget, form]); + + const saveMutation = useMutation({ + mutationFn: (input: DataBudgetInput) => + budget ? updateDataBudget(dsId, budget.id, input) : createDataBudget(dsId, input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: dataBudgetKeys.all }); + message.success(t('dataBudgets.tab.save_success')); + onClose(); + }, + onError: (err) => { + showApiError(message, err, (e) => apiErrorMessage(e, () => t('dataBudgets.tab.save_error'))); + }, + }); + + const onFinish = (values: DataBudgetFormValues) => { + saveMutation.mutate({ + name: values.name.trim(), + max_rows: values.max_rows ?? null, + max_bytes: values.max_bytes ?? null, + window_minutes: joinWindow(values.window_amount, values.window_unit), + breach_action: values.breach_action, + warn_threshold_percent: values.warn_threshold_percent ?? null, + applies_to_roles: values.applies_to_roles ?? [], + applies_to_group_ids: values.applies_to_group_ids ?? [], + applies_to_user_ids: values.applies_to_user_ids ?? [], + enabled: values.enabled, + }); + }; + + // Backend: @Min(60) @Max(44640) on window_minutes (#942). + const windowRule = { + validator: (_: unknown, amount: number | null | undefined) => { + const unit: DataBudgetWindowUnit = form.getFieldValue('window_unit') ?? 'hours'; + if (amount === null || amount === undefined) { + return Promise.reject(new Error(t('dataBudgets.tab.window_range'))); + } + const minutes = joinWindow(amount, unit); + return minutes >= DATA_BUDGET_WINDOW_MIN_MINUTES && minutes <= DATA_BUDGET_WINDOW_MAX_MINUTES + ? Promise.resolve() + : Promise.reject(new Error(t('dataBudgets.tab.window_range'))); + }, + }; + // Backend: at least one of max_rows / max_bytes (chk_data_budgets_has_limit, 422). + const limitRequiredRule = ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({ + validator: () => + getFieldValue('max_rows') == null && getFieldValue('max_bytes') == null + ? Promise.reject(new Error(t('dataBudgets.tab.limit_required'))) + : Promise.resolve(), + }); + + return ( + form.submit()} + okText={t('common.save')} + cancelText={t('common.cancel')} + confirmLoading={saveMutation.isPending} + destroyOnHidden + width={600} + > + + form={form} + name="data-budget" + layout="vertical" + initialValues={DEFAULT_VALUES} + onFinish={onFinish} + > + + + + +
+ {t('dataBudgets.tab.limits_hint')} +
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + mode="multiple" + allowClear + options={roleOptions} + loading={rolesQuery.isLoading} + /> + + + + + mode="multiple" + allowClear + showSearch={{ optionFilterProp: 'label' }} + options={groupOptions} + loading={groupsQuery.isLoading} + /> + + + + + mode="multiple" + allowClear + showSearch={{ optionFilterProp: 'label' }} + options={userOptions} + optionRender={renderUserOption} + loading={usersQuery.isLoading} + /> + + + + + + +
+ ); +} diff --git a/frontend/src/components/datasources/SampleDataPreview.test.tsx b/frontend/src/components/datasources/SampleDataPreview.test.tsx index a42e418c8..f9ca74791 100644 --- a/frontend/src/components/datasources/SampleDataPreview.test.tsx +++ b/frontend/src/components/datasources/SampleDataPreview.test.tsx @@ -84,4 +84,19 @@ describe('SampleDataPreview', () => { renderPreview(); expect(screen.getByText(/capped by the result size limit/i)).toBeInTheDocument(); }); + + it('renders the data-budget footer when truncated_reason is DATA_BUDGET', () => { + useTableSampleMock.mockReturnValue({ + data: { + columns: [{ name: 'id', type: 'uuid', restricted: false }], + rows: [['1']], + row_count: 1, + truncated: true, + truncated_reason: 'DATA_BUDGET', + duration_ms: 4, + }, + }); + renderPreview(); + expect(screen.getByText(/capped at your remaining data budget/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/datasources/SampleDataPreview.tsx b/frontend/src/components/datasources/SampleDataPreview.tsx index b779f4806..a1f57409e 100644 --- a/frontend/src/components/datasources/SampleDataPreview.tsx +++ b/frontend/src/components/datasources/SampleDataPreview.tsx @@ -3,6 +3,7 @@ import type { TableColumnsType } from 'antd'; import { LockOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { useTableSample } from '@/hooks/useTableSample'; +import type { TruncatedReason } from '@/types/api'; interface SampleDataPreviewProps { datasourceId: string; @@ -81,12 +82,7 @@ export function SampleDataPreview({ datasourceId, schema, table }: SampleDataPre footer={() => data.truncated ? ( - {t( - data.truncated_reason === 'BYTE_LIMIT' - ? 'datasources.settings.sample_truncated_bytes' - : 'datasources.settings.sample_truncated', - { count: data.row_count }, - )} + {t(sampleTruncatedKey(data.truncated_reason), { count: data.row_count })} ) : ( @@ -104,3 +100,9 @@ function formatCell(value: unknown): string { if (typeof value === 'object') return JSON.stringify(value); return String(value); } + +function sampleTruncatedKey(reason: TruncatedReason | null | undefined) { + if (reason === 'BYTE_LIMIT') return 'datasources.settings.sample_truncated_bytes' as const; + if (reason === 'DATA_BUDGET') return 'datasources.settings.sample_truncated_budget' as const; + return 'datasources.settings.sample_truncated' as const; +} diff --git a/frontend/src/components/editor/DataBudgetIndicator.test.tsx b/frontend/src/components/editor/DataBudgetIndicator.test.tsx new file mode 100644 index 000000000..caea9cd15 --- /dev/null +++ b/frontend/src/components/editor/DataBudgetIndicator.test.tsx @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import type { DataBudgetConsumption, DataBudgetStatus } from '@/types/api'; + +const { getMyDataBudgetStatusMock } = vi.hoisted(() => ({ + getMyDataBudgetStatusMock: vi.fn(), +})); + +vi.mock('@/api/dataBudgets', () => ({ + getMyDataBudgetStatus: getMyDataBudgetStatusMock, + dataBudgetKeys: { mine: (id: string) => ['data-budgets', 'me', id] as const }, +})); + +import { DataBudgetIndicator } from './DataBudgetIndicator'; + +function withClient(ui: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {ui}; +} + +const budget = (overrides: Partial = {}): DataBudgetConsumption => ({ + id: 'b-1', + name: 'Analysts daily', + max_rows: 1000, + max_bytes: 5_000_000_000, + window_minutes: 1440, + breach_action: 'REQUIRE_REVIEW', + warn_threshold_percent: 80, + used_rows: 300, + used_bytes: 1_000_000_000, + remaining_rows: 700, + remaining_bytes: 4_000_000_000, + used_percent: 30, + exhausted: false, + ...overrides, +}); + +const status = (overrides: Partial = {}): DataBudgetStatus => ({ + datasource_id: 'ds-1', + exhausted: false, + budgets: [budget()], + ...overrides, +}); + +describe('DataBudgetIndicator', () => { + beforeEach(() => { + getMyDataBudgetStatusMock.mockReset(); + }); + + it('renders nothing when no budget applies', async () => { + getMyDataBudgetStatusMock.mockResolvedValue(status({ budgets: [] })); + const { container } = render(withClient()); + await waitFor(() => expect(getMyDataBudgetStatusMock).toHaveBeenCalledWith('ds-1')); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the standing cannot be read', async () => { + getMyDataBudgetStatusMock.mockRejectedValue(new Error('404')); + const { container } = render(withClient()); + await waitFor(() => expect(getMyDataBudgetStatusMock).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the remaining rows and bytes for each budget', async () => { + getMyDataBudgetStatusMock.mockResolvedValue(status()); + render(withClient()); + expect(await screen.findByText('Analysts daily')).toBeInTheDocument(); + expect(screen.getByTestId('data-budget-indicator')).toHaveTextContent('700'); + expect(screen.getByTestId('data-budget-indicator')).toHaveTextContent('1,000'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('warns once a budget passes its threshold', async () => { + getMyDataBudgetStatusMock.mockResolvedValue( + status({ budgets: [budget({ used_percent: 85, max_bytes: null, remaining_bytes: null })] }), + ); + render(withClient()); + expect(await screen.findByText('You have used most of your data budget on this datasource.')).toBeInTheDocument(); + }); + + it('explains what happens once a review budget is used up', async () => { + getMyDataBudgetStatusMock.mockResolvedValue( + status({ + exhausted: true, + breach_action: 'REQUIRE_REVIEW', + budgets: [budget({ used_percent: 120, exhausted: true, max_rows: null })], + }), + ); + render(withClient()); + expect(await screen.findByText('Data budget used up')).toBeInTheDocument(); + expect(screen.getByText(/need a reviewer’s approval/)).toBeInTheDocument(); + }); + + it('explains a rejecting budget that is used up', async () => { + getMyDataBudgetStatusMock.mockResolvedValue( + status({ + exhausted: true, + breach_action: 'REJECT', + budgets: [budget({ used_percent: 100, exhausted: true, remaining_rows: null })], + }), + ); + render(withClient()); + expect(await screen.findByText(/are refused until earlier reads/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/editor/DataBudgetIndicator.tsx b/frontend/src/components/editor/DataBudgetIndicator.tsx new file mode 100644 index 000000000..a4bbe5aa9 --- /dev/null +++ b/frontend/src/components/editor/DataBudgetIndicator.tsx @@ -0,0 +1,103 @@ +import { Alert, Progress } from 'antd'; +import { DashboardOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { dataBudgetKeys, getMyDataBudgetStatus } from '@/api/dataBudgets'; +import type { DataBudgetConsumption } from '@/types/api'; +import { budgetNearlyUsed, formatWindow } from '@/utils/dataBudget'; +import { formatBytes } from '@/utils/queryPlan'; + +const CARD_STYLE = { + background: 'var(--bg-elev)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: 14, +} as const; + +/** + * The caller's remaining data-volume allowance on the selected datasource (#942), shown before + * they submit. Renders nothing when no budget applies — or the standing cannot be read — so an + * unbudgeted datasource looks exactly as it did before. + */ +export function DataBudgetIndicator({ dsId }: { dsId: string }) { + const { t } = useTranslation(); + const statusQuery = useQuery({ + queryKey: dataBudgetKeys.mine(dsId), + queryFn: () => getMyDataBudgetStatus(dsId), + retry: false, + }); + const status = statusQuery.data; + if (!status || status.budgets.length === 0) { + return null; + } + + return ( +
+
+ +
{t('dataBudgets.indicator.title')}
+
+ {status.exhausted ? ( + + ) : budgetNearlyUsed(status) ? ( + + ) : null} + {status.budgets.map((budget) => ( + + ))} +
+ ); +} + +function BudgetLine({ budget }: { budget: DataBudgetConsumption }) { + const { t } = useTranslation(); + const window = formatWindow(t, budget.window_minutes); + const parts: string[] = []; + if (budget.max_rows != null) { + parts.push( + t('dataBudgets.indicator.rows_left', { + remaining: (budget.remaining_rows ?? 0).toLocaleString(), + limit: budget.max_rows.toLocaleString(), + }), + ); + } + if (budget.max_bytes != null) { + parts.push( + t('dataBudgets.indicator.bytes_left', { + remaining: formatBytes(budget.remaining_bytes ?? 0), + limit: formatBytes(budget.max_bytes) ?? '', + }), + ); + } + const percent = Math.min(100, budget.used_percent); + return ( +
+
+ {budget.name} + {t('dataBudgets.indicator.per_window', { window })} +
+ +
{parts.join(' · ')}
+
+ ); +} diff --git a/frontend/src/components/policies/decisionTraceDetails.test.ts b/frontend/src/components/policies/decisionTraceDetails.test.ts index 0e4bbcf94..690e63412 100644 --- a/frontend/src/components/policies/decisionTraceDetails.test.ts +++ b/frontend/src/components/policies/decisionTraceDetails.test.ts @@ -205,4 +205,22 @@ describe('formatStepDetails — masking, omission and enum values (#1066 review) 'Rejected: the estimate exceeds the bytes-scanned cap', ); }); + + it('formats the data-budget step with labels, sizes and percentages (#942)', () => { + const rows = formatStepDetails( + { + data_budget_name: 'Analysts daily', + data_budget_action: 'REQUIRE_REVIEW', + data_budget_used_percent: 105, + data_budget_remaining_rows: 0, + data_budget_remaining_bytes: 2_000_000_000, + }, + t, + ); + const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value])); + expect(byKey.data_budget_action).toBe('Send to human review'); + expect(byKey.data_budget_used_percent).toBe('105%'); + expect(byKey.data_budget_remaining_bytes).toBe('2 GB'); + expect(rows.find((r) => r.key === 'data_budget_name')?.label).toBe('Data budget name'); + }); }); diff --git a/frontend/src/components/policies/decisionTraceDetails.ts b/frontend/src/components/policies/decisionTraceDetails.ts index 55cb70050..24f5760b2 100644 --- a/frontend/src/components/policies/decisionTraceDetails.ts +++ b/frontend/src/components/policies/decisionTraceDetails.ts @@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'; import type { BytesScannedCapOutcome, BytesScannedCapSource, + DataBudgetBreachAction, MaskingStrategy, QueryShape, QueryStatus, @@ -15,6 +16,7 @@ import { formatBytes } from '@/utils/queryPlan'; import { BYTES_SCANNED_CAP_OUTCOMES, BYTES_SCANNED_CAP_SOURCES, + DATA_BUDGET_BREACH_ACTIONS, MASKING_STRATEGIES, QUERY_SHAPES, QUERY_TYPES, @@ -22,6 +24,7 @@ import { ROUTING_ACTIONS, bytesScannedCapOutcomeLabel, bytesScannedCapSourceLabel, + dataBudgetBreachActionLabel, maskingStrategyLabel, queryShapeLabel, queryStatusLabel, @@ -68,6 +71,13 @@ export const KNOWN_DETAIL_KEYS = [ 'considered_grant_ids', 'contributing_grants', 'current', + 'data_budget_action', + 'data_budget_id', + 'data_budget_name', + 'data_budget_remaining_bytes', + 'data_budget_remaining_rows', + 'data_budget_suppressed', + 'data_budget_used_percent', 'db_type', 'denied_shapes', 'denied_tables', @@ -172,9 +182,17 @@ function enumValue(key: string, value: unknown, t: TFunction): string | null { && includes(BYTES_SCANNED_CAP_OUTCOMES, value)) { return bytesScannedCapOutcomeLabel(t, value); } - if ((key === 'bytes_scanned_cap' || key === 'estimated_bytes_scanned') && typeof value === 'number') { + if ((key === 'bytes_scanned_cap' || key === 'estimated_bytes_scanned' + || key === 'data_budget_remaining_bytes') && typeof value === 'number') { return formatBytes(value); } + if (key === 'data_budget_action' + && includes(DATA_BUDGET_BREACH_ACTIONS, value)) { + return dataBudgetBreachActionLabel(t, value); + } + if (key === 'data_budget_used_percent' && typeof value === 'number') { + return `${value}%`; + } return null; } diff --git a/frontend/src/components/queries/QueryResultsTable.test.tsx b/frontend/src/components/queries/QueryResultsTable.test.tsx index b5a003900..0b4eb424a 100644 --- a/frontend/src/components/queries/QueryResultsTable.test.tsx +++ b/frontend/src/components/queries/QueryResultsTable.test.tsx @@ -103,6 +103,16 @@ describe('QueryResultsTable', () => { ).toBeInTheDocument(); }); + it('renders the data-budget footer when truncated_reason is DATA_BUDGET', async () => { + getQueryResultsMock.mockResolvedValue( + page({ truncated: true, truncated_reason: 'DATA_BUDGET' }), + ); + renderTable(); + expect( + await screen.findByText(/capped at your remaining data budget/i), + ).toBeInTheDocument(); + }); + it('hides the export button while the decision is unavailable', async () => { getQueryResultsMock.mockResolvedValue(page({})); renderTable(); diff --git a/frontend/src/components/queries/QueryResultsTable.tsx b/frontend/src/components/queries/QueryResultsTable.tsx index c163999f6..a15cc5620 100644 --- a/frontend/src/components/queries/QueryResultsTable.tsx +++ b/frontend/src/components/queries/QueryResultsTable.tsx @@ -234,9 +234,9 @@ export function QueryResultsTable({ queryId, defaultView = 'table' }: Props) { } function truncatedMessageKey(reason: TruncatedReason | null | undefined) { - return reason === 'BYTE_LIMIT' - ? ('queries.detail.results_truncated_bytes' as const) - : ('queries.detail.results_truncated' as const); + if (reason === 'BYTE_LIMIT') return 'queries.detail.results_truncated_bytes' as const; + if (reason === 'DATA_BUDGET') return 'queries.detail.results_truncated_budget' as const; + return 'queries.detail.results_truncated' as const; } function formatCell(value: unknown): string { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index e7278c1ab..a659e6e9b 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -868,7 +868,8 @@ "diff_effective_label": "Effektives SQL" }, "bytes_cap_body": "Geschätzter Scan: {{estimate}} · Limit: {{limit}} ({{source}})", - "bytes_cap_no_estimate": "keine Schätzung" + "bytes_cap_no_estimate": "keine Schätzung", + "results_truncated_budget": "{{count}} Zeilen zurückgegeben (auf Ihr verbleibendes Datenbudget begrenzt)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Limit für gescannte Bytes", "grant_bytes_cap_help": "Ein niedrigeres Limit für diesen Grant. Es gilt das strengste aus Datenquellen-Limit und allen Grants.", "grant_bytes_cap_min": "Das Limit für gescannte Bytes muss mindestens 1 Byte betragen.", - "grant_bytes_cap_placeholder": "Standard der Datenquelle" + "grant_bytes_cap_placeholder": "Standard der Datenquelle", + "tab_data_budgets": "Datenbudgets · {{count}}", + "sample_truncated_budget": "{{count}} Zeilen (auf Ihr verbleibendes Datenbudget begrenzt)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "Min.", "cicd_present_label": "Ist CI/CD-Herkunft", "scan_type_placeholder": "z. B. Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Gescannte Bytes" + "bytes_value_label": "Gescannte Bytes", + "budget_percent_label": "Verbrauchter Anteil des Datenbudgets" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Ihre Schemaänderung {{changeSet}} ist auf {{environment}} fehlgeschlagen", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Ihre Schemaänderung {{changeSet}} wurde auf {{environment}} nur teilweise angewendet", "SCHEMA_DRIFT_DETECTED_one": "{{count}} neuer Schema-Drift-Befund in {{pipeline}} / {{environment}}", - "SCHEMA_DRIFT_DETECTED_other": "{{count}} neue Schema-Drift-Befunde in {{pipeline}} / {{environment}}" + "SCHEMA_DRIFT_DETECTED_other": "{{count}} neue Schema-Drift-Befunde in {{pipeline}} / {{environment}}", + "DATA_BUDGET_THRESHOLD_REACHED": "Sie haben {{percent}} % Ihres Datenbudgets {{budget}} auf {{datasource}} verbraucht", + "DATA_BUDGET_EXHAUSTED": "Datenbudget {{budget}} für {{user}} auf {{datasource}} ist aufgebraucht" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "CI/CD-Herkunft", "estimated_rows": "Geschätzte Zeilen", "scan_type": "Scan-Typ", - "estimated_bytes_scanned": "Geschätzte gescannte Bytes" + "estimated_bytes_scanned": "Geschätzte gescannte Bytes", + "data_budget_used_percent": "Datenbudget verbraucht (%)" }, "query_shape": { "JOIN": "Join", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "Exportrichtlinien verwalten", "ROW_LIMIT_POLICY_MANAGE": "Zeilenlimit-Richtlinien verwalten", "DEPLOYMENT_PIPELINE_MANAGE": "Deployment-Pipelines verwalten", - "DEPLOYMENT_REVIEW": "Deployments prüfen" + "DEPLOYMENT_REVIEW": "Deployments prüfen", + "DATA_BUDGET_MANAGE": "Datenbudgets verwalten" }, "api_variable_kind": { "CONSTANT": "Konstante", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "Zeilensicherheit", "MASKING": "Maskierung", "BREAK_GLASS": "Break-Glass", - "BYTES_SCANNED_CAP": "Limit für gescannte Bytes" + "BYTES_SCANNED_CAP": "Limit für gescannte Bytes", + "DATA_BUDGET": "Datenbudget" }, "api_decision_step": { "CONNECTOR_GATES": "Konnektor-Prüfungen", @@ -3802,6 +3811,10 @@ "EXCEEDED": "Abgelehnt: die Schätzung überschreitet das Limit für gescannte Bytes", "NO_ESTIMATE_REVIEW": "Zur Prüfung zurückgehalten: keine Byte-Schätzung unter dem Limit", "NO_ESTIMATE_REJECTED": "Abgelehnt: keine Byte-Schätzung unter dem Limit" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Zur Prüfung senden", + "REJECT": "Ablehnen" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Limit für gescannte Bytes", "bytes_scanned_cap_outcome": "Ergebnis des Limits", "bytes_scanned_cap_source": "Quelle des Limits", - "estimated_bytes_scanned": "Geschätzte gescannte Bytes" + "estimated_bytes_scanned": "Geschätzte gescannte Bytes", + "data_budget_action": "Aktion bei Budgetüberschreitung", + "data_budget_id": "Datenbudget", + "data_budget_name": "Name des Datenbudgets", + "data_budget_remaining_bytes": "Verbleibende Budget-Bytes", + "data_budget_remaining_rows": "Verbleibende Budget-Zeilen", + "data_budget_suppressed": "Durch das Datenbudget unterdrückt", + "data_budget_used_percent": "Datenbudget verbraucht" }, "use_id": "ID {{id}} verwenden", "user_id_placeholder": "Benutzer-ID einfügen", @@ -5832,5 +5852,93 @@ "key_control_chars": "Der Anwendungsname darf keine Steuerzeichen enthalten.", "rotate_help": "Leer lassen, um den Anwendungsnamen des aktuellen Schlüssels beizubehalten.", "column": "Anwendung" + }, + "dataBudgets": { + "window_hours_one": "{{count}} Stunde", + "window_hours_other": "{{count}} Stunden", + "window_days_one": "{{count}} Tag", + "window_days_other": "{{count}} Tage", + "indicator": { + "title": "Ihr Datenbudget", + "exhausted_title": "Datenbudget aufgebraucht", + "exhausted_reject": "Neue Lesezugriffe auf diese Datenquelle werden abgelehnt, bis frühere Lesezugriffe aus dem Zeitfenster fallen.", + "exhausted_review": "Neue Lesezugriffe auf diese Datenquelle benötigen eine Genehmigung, bis frühere Lesezugriffe aus dem Zeitfenster fallen.", + "nearly_used": "Sie haben den Großteil Ihres Datenbudgets für diese Datenquelle verbraucht.", + "rows_left": "{{remaining}} von {{limit}} Zeilen übrig", + "bytes_left": "{{remaining}} von {{limit}} übrig", + "per_window": "pro {{window}}", + "used_aria": "{{name}}: {{percent}} % verbraucht" + }, + "tab": { + "title": "Datenbudgets", + "description": "Begrenzen Sie, wie viele Zeilen und Ergebnis-Bytes jeder Benutzer in einem gleitenden Zeitfenster aus dieser Datenquelle lesen darf. Eine Abfrage wird auf das verbleibende Kontingent begrenzt; ist ein Budget aufgebraucht, werden neue Lesezugriffe abgelehnt oder zur Prüfung geschickt.", + "add": "Budget hinzufügen", + "empty_title": "Keine Datenbudgets", + "empty_description": "Lesezugriffe auf diese Datenquelle sind nur pro Abfrage begrenzt.", + "col_name": "Name", + "col_limits": "Limits", + "col_window": "Zeitfenster", + "col_action": "Bei Erschöpfung", + "col_applies_to": "Gilt für", + "col_enabled": "Aktiviert", + "col_actions": "Aktionen", + "state_disabled": "Deaktiviert", + "edit": "Budget bearbeiten", + "delete": "Budget löschen", + "delete_confirm_title": "Dieses Datenbudget löschen?", + "delete_confirm_body": "Betroffene Benutzer sind nicht mehr daran gebunden. Erfasste Nutzung bleibt erhalten, bis sie aus dem Zeitfenster fällt.", + "delete_success": "Datenbudget gelöscht", + "delete_error": "Das Datenbudget konnte nicht gelöscht werden", + "save_success": "Datenbudget gespeichert", + "save_error": "Das Datenbudget konnte nicht gespeichert werden", + "create_title": "Neues Datenbudget", + "edit_title": "Datenbudget bearbeiten", + "label_name": "Name", + "name_required": "Geben Sie einen Namen ein", + "name_max": "Höchstens 120 Zeichen", + "label_max_rows": "Zeilenlimit", + "label_max_bytes": "Limit für Ergebnisgröße", + "limits_hint": "Legen Sie ein Zeilenlimit, ein Größenlimit oder beides fest. Jeder Benutzer erhält ein eigenes Kontingent.", + "limit_required": "Legen Sie ein Zeilen- oder Größenlimit fest", + "rows_min": "Das Zeilenlimit muss mindestens 1 betragen", + "bytes_min": "Das Größenlimit muss mindestens 1 Byte betragen", + "label_window": "Gleitendes Zeitfenster", + "window_hint": "Die Nutzung zählt über das zurückliegende Zeitfenster, von jetzt aus gemessen.", + "window_range": "Das Zeitfenster muss zwischen 1 Stunde und 31 Tagen liegen", + "window_unit": "Einheit des Zeitfensters", + "unit_hours": "Stunden", + "unit_days": "Tage", + "label_breach_action": "Wenn das Budget aufgebraucht ist", + "breach_action_hint": "Eine Prüfung lässt legitime Analysten einen intensiven Tag abschließen; Ablehnen stoppt Lesezugriffe sofort. Break-Glass-Zugriff wird nie blockiert, zählt aber mit.", + "label_warn_threshold": "Warnen bei (% verbraucht)", + "warn_threshold_hint": "Der Benutzer wird einmal benachrichtigt, wenn er diesen Anteil überschreitet. Leer lassen für keine Warnung.", + "threshold_range": "Die Schwelle muss zwischen 1 und 99 liegen", + "label_applies_roles": "Gilt für Rollen", + "applies_hint": "Lassen Sie alle drei leer, damit das Budget für jeden Benutzer dieser Datenquelle gilt.", + "label_applies_groups": "Gilt für Gruppen", + "label_applies_users": "Gilt für Benutzer", + "label_enabled": "Aktiviert", + "applies_everyone": "Alle", + "applies_roles_one": "{{count}} Rolle", + "applies_roles_other": "{{count}} Rollen", + "applies_groups_one": "{{count}} Gruppe", + "applies_groups_other": "{{count}} Gruppen", + "applies_users_one": "{{count}} Benutzer", + "applies_users_other": "{{count}} Benutzer", + "rows_value": "{{value}} Zeilen", + "unit_minutes": "Minuten" + }, + "usage": { + "action": "Datennutzung", + "title": "Datennutzung — {{name}}", + "description": "Zeilen und Ergebnis-Bytes, die dieser Benutzer im Zeitfenster jedes für ihn geltenden Datenbudgets gelesen hat.", + "empty": "Für diesen Benutzer gilt kein Datenbudget.", + "load_error": "Die Datennutzung dieses Benutzers konnte nicht geladen werden", + "exhausted": "Aufgebraucht", + "rows_used": "{{used}} von {{limit}} Zeilen", + "bytes_used": "{{used}} von {{limit}}" + }, + "window_minutes_one": "{{count}} Minute", + "window_minutes_other": "{{count}} Minuten" } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 569e80987..5b9439ff3 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -868,7 +868,8 @@ "diff_effective_label": "Effective SQL" }, "bytes_cap_body": "Estimated scan: {{estimate}} · Cap: {{limit}} ({{source}})", - "bytes_cap_no_estimate": "no estimate" + "bytes_cap_no_estimate": "no estimate", + "results_truncated_budget": "{{count}} rows returned (capped at your remaining data budget)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Bytes-scanned cap", "grant_bytes_cap_help": "A lower cap for this grant. The strictest of the datasource cap and every grant applies.", "grant_bytes_cap_min": "Bytes-scanned cap must be at least 1 byte.", - "grant_bytes_cap_placeholder": "Datasource default" + "grant_bytes_cap_placeholder": "Datasource default", + "tab_data_budgets": "Data budgets · {{count}}", + "sample_truncated_budget": "{{count}} rows (capped at your remaining data budget)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "min", "cicd_present_label": "Is CI/CD origin", "scan_type_placeholder": "e.g. Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Bytes scanned" + "bytes_value_label": "Bytes scanned", + "budget_percent_label": "Share of data budget used" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Your schema change {{changeSet}} failed on {{environment}}", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Your schema change {{changeSet}} was only partly applied to {{environment}}", "SCHEMA_DRIFT_DETECTED_one": "{{count}} new schema drift finding on {{pipeline}} / {{environment}}", - "SCHEMA_DRIFT_DETECTED_other": "{{count}} new schema drift findings on {{pipeline}} / {{environment}}" + "SCHEMA_DRIFT_DETECTED_other": "{{count}} new schema drift findings on {{pipeline}} / {{environment}}", + "DATA_BUDGET_THRESHOLD_REACHED": "You have used {{percent}}% of your data budget {{budget}} on {{datasource}}", + "DATA_BUDGET_EXHAUSTED": "Data budget {{budget}} for {{user}} on {{datasource}} is used up" } }, "profile": { @@ -3317,7 +3323,8 @@ "cicd_origin": "CI/CD origin", "estimated_rows": "Estimated rows", "scan_type": "Scan type", - "estimated_bytes_scanned": "Estimated bytes scanned" + "estimated_bytes_scanned": "Estimated bytes scanned", + "data_budget_used_percent": "Data budget used (%)" }, "query_shape": { "JOIN": "Join", @@ -3587,7 +3594,8 @@ "EXPORT_POLICY_MANAGE": "Manage export policies", "ROW_LIMIT_POLICY_MANAGE": "Manage row-limit policies", "DEPLOYMENT_PIPELINE_MANAGE": "Manage deployment pipelines", - "DEPLOYMENT_REVIEW": "Review deployments" + "DEPLOYMENT_REVIEW": "Review deployments", + "DATA_BUDGET_MANAGE": "Manage data budgets" }, "api_variable_kind": { "CONSTANT": "Constant", @@ -3758,7 +3766,8 @@ "ROW_SECURITY": "Row security", "MASKING": "Masking", "BREAK_GLASS": "Break-glass", - "BYTES_SCANNED_CAP": "Bytes-scanned cap" + "BYTES_SCANNED_CAP": "Bytes-scanned cap", + "DATA_BUDGET": "Data budget" }, "api_decision_step": { "CONNECTOR_GATES": "Connector gates", @@ -3827,6 +3836,10 @@ "EXCEEDED": "Rejected: the estimate exceeds the bytes-scanned cap", "NO_ESTIMATE_REVIEW": "Held for review: no bytes estimate under the cap", "NO_ESTIMATE_REJECTED": "Rejected: no bytes estimate under the cap" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Send to human review", + "REJECT": "Reject" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Bytes-scanned cap", "bytes_scanned_cap_outcome": "Cap outcome", "bytes_scanned_cap_source": "Cap source", - "estimated_bytes_scanned": "Estimated bytes scanned" + "estimated_bytes_scanned": "Estimated bytes scanned", + "data_budget_action": "Budget breach action", + "data_budget_id": "Data budget", + "data_budget_name": "Data budget name", + "data_budget_remaining_bytes": "Budget bytes left", + "data_budget_remaining_rows": "Budget rows left", + "data_budget_suppressed": "Suppressed by the data budget", + "data_budget_used_percent": "Data budget used" }, "use_id": "Use ID {{id}}", "user_id_placeholder": "Paste a user ID", @@ -5832,5 +5852,93 @@ "key_control_chars": "Application name must not contain control characters.", "rotate_help": "Leave empty to keep the current key's application name.", "column": "Application" + }, + "dataBudgets": { + "window_hours_one": "{{count}} hour", + "window_hours_other": "{{count}} hours", + "window_days_one": "{{count}} day", + "window_days_other": "{{count}} days", + "indicator": { + "title": "Your data budget", + "exhausted_title": "Data budget used up", + "exhausted_reject": "New reads on this datasource are refused until earlier reads age out of the window.", + "exhausted_review": "New reads on this datasource need a reviewer’s approval until earlier reads age out of the window.", + "nearly_used": "You have used most of your data budget on this datasource.", + "rows_left": "{{remaining}} of {{limit}} rows left", + "bytes_left": "{{remaining}} of {{limit}} left", + "per_window": "per {{window}}", + "used_aria": "{{name}}: {{percent}}% used" + }, + "tab": { + "title": "Data budgets", + "description": "Cap how many rows and result bytes each user may read from this datasource over a rolling window. A query is capped to the allowance left; once a budget is used up, new reads are rejected or sent to human review.", + "add": "Add budget", + "empty_title": "No data budgets", + "empty_description": "Reads on this datasource are bounded per query only.", + "col_name": "Name", + "col_limits": "Limits", + "col_window": "Window", + "col_action": "When used up", + "col_applies_to": "Applies to", + "col_enabled": "Enabled", + "col_actions": "Actions", + "state_disabled": "Disabled", + "edit": "Edit budget", + "delete": "Delete budget", + "delete_confirm_title": "Delete this data budget?", + "delete_confirm_body": "Users it applied to are no longer bounded by it. Recorded usage is kept until it ages out.", + "delete_success": "Data budget deleted", + "delete_error": "Could not delete the data budget", + "save_success": "Data budget saved", + "save_error": "Could not save the data budget", + "create_title": "New data budget", + "edit_title": "Edit data budget", + "label_name": "Name", + "name_required": "Enter a name", + "name_max": "Use at most 120 characters", + "label_max_rows": "Row limit", + "label_max_bytes": "Result-size limit", + "limits_hint": "Set a row limit, a result-size limit, or both. Each user gets their own allowance.", + "limit_required": "Set a row limit or a result-size limit", + "rows_min": "The row limit must be at least 1", + "bytes_min": "The result-size limit must be at least 1 byte", + "label_window": "Rolling window", + "window_hint": "Usage counts over the trailing window, measured back from now.", + "window_range": "The window must be between 1 hour and 31 days", + "window_unit": "Window unit", + "unit_hours": "hours", + "unit_days": "days", + "label_breach_action": "When the budget is used up", + "breach_action_hint": "Human review lets a legitimate analyst finish a heavy day; reject stops reads outright. Break-glass access is never blocked but still counts.", + "label_warn_threshold": "Warn at (% used)", + "warn_threshold_hint": "The user is notified once when they cross this share. Leave empty for no warning.", + "threshold_range": "The threshold must be between 1 and 99", + "label_applies_roles": "Applies to roles", + "applies_hint": "Leave all three empty to apply the budget to every user of this datasource.", + "label_applies_groups": "Applies to groups", + "label_applies_users": "Applies to users", + "label_enabled": "Enabled", + "applies_everyone": "Everyone", + "applies_roles_one": "{{count}} role", + "applies_roles_other": "{{count}} roles", + "applies_groups_one": "{{count}} group", + "applies_groups_other": "{{count}} groups", + "applies_users_one": "{{count}} user", + "applies_users_other": "{{count}} users", + "rows_value": "{{value}} rows", + "unit_minutes": "minutes" + }, + "usage": { + "action": "Data usage", + "title": "Data usage — {{name}}", + "description": "Rows and result bytes this user read against each data budget that applies to them, over each budget’s window.", + "empty": "No data budget applies to this user.", + "load_error": "Could not load this user’s data usage", + "exhausted": "Used up", + "rows_used": "{{used}} of {{limit}} rows", + "bytes_used": "{{used}} of {{limit}}" + }, + "window_minutes_one": "{{count}} minute", + "window_minutes_other": "{{count}} minutes" } } diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index db432ca89..9f413f074 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -868,7 +868,8 @@ "diff_effective_label": "SQL efectivo" }, "bytes_cap_body": "Escaneo estimado: {{estimate}} · Límite: {{limit}} ({{source}})", - "bytes_cap_no_estimate": "sin estimación" + "bytes_cap_no_estimate": "sin estimación", + "results_truncated_budget": "{{count}} filas devueltas (limitadas a tu presupuesto de datos restante)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Límite de bytes escaneados", "grant_bytes_cap_help": "Un límite menor para este permiso. Se aplica el más estricto entre el límite de la fuente de datos y todos los permisos.", "grant_bytes_cap_min": "El límite de bytes escaneados debe ser al menos 1 byte.", - "grant_bytes_cap_placeholder": "Predeterminado de la fuente de datos" + "grant_bytes_cap_placeholder": "Predeterminado de la fuente de datos", + "tab_data_budgets": "Presupuestos de datos · {{count}}", + "sample_truncated_budget": "{{count}} filas (limitadas a tu presupuesto de datos restante)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "min", "cicd_present_label": "Es origen CI/CD", "scan_type_placeholder": "p. ej. Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Bytes escaneados" + "bytes_value_label": "Bytes escaneados", + "budget_percent_label": "Porcentaje del presupuesto de datos usado" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Su cambio de esquema {{changeSet}} falló en {{environment}}", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Su cambio de esquema {{changeSet}} solo se aplicó parcialmente en {{environment}}", "SCHEMA_DRIFT_DETECTED_one": "{{count}} hallazgo nuevo de deriva de esquema en {{pipeline}} / {{environment}}", - "SCHEMA_DRIFT_DETECTED_other": "{{count}} hallazgos nuevos de deriva de esquema en {{pipeline}} / {{environment}}" + "SCHEMA_DRIFT_DETECTED_other": "{{count}} hallazgos nuevos de deriva de esquema en {{pipeline}} / {{environment}}", + "DATA_BUDGET_THRESHOLD_REACHED": "Ha usado el {{percent}} % de su presupuesto de datos {{budget}} en {{datasource}}", + "DATA_BUDGET_EXHAUSTED": "El presupuesto de datos {{budget}} de {{user}} en {{datasource}} está agotado" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "Origen CI/CD", "estimated_rows": "Filas estimadas", "scan_type": "Tipo de escaneo", - "estimated_bytes_scanned": "Bytes escaneados estimados" + "estimated_bytes_scanned": "Bytes escaneados estimados", + "data_budget_used_percent": "Presupuesto de datos usado (%)" }, "query_shape": { "JOIN": "Join", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "Gestionar políticas de exportación", "ROW_LIMIT_POLICY_MANAGE": "Gestionar políticas de límite de filas", "DEPLOYMENT_PIPELINE_MANAGE": "Gestionar pipelines de despliegue", - "DEPLOYMENT_REVIEW": "Revisar despliegues" + "DEPLOYMENT_REVIEW": "Revisar despliegues", + "DATA_BUDGET_MANAGE": "Gestionar presupuestos de datos" }, "api_variable_kind": { "CONSTANT": "Constante", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "Seguridad por filas", "MASKING": "Enmascaramiento", "BREAK_GLASS": "Acceso de emergencia", - "BYTES_SCANNED_CAP": "Límite de bytes escaneados" + "BYTES_SCANNED_CAP": "Límite de bytes escaneados", + "DATA_BUDGET": "Presupuesto de datos" }, "api_decision_step": { "CONNECTOR_GATES": "Controles del conector", @@ -3802,6 +3811,10 @@ "EXCEEDED": "Rechazada: la estimación supera el límite de bytes escaneados", "NO_ESTIMATE_REVIEW": "Retenida para revisión: sin estimación de bytes bajo el límite", "NO_ESTIMATE_REJECTED": "Rechazada: sin estimación de bytes bajo el límite" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Enviar a revisión humana", + "REJECT": "Rechazar" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Límite de bytes escaneados", "bytes_scanned_cap_outcome": "Resultado del límite", "bytes_scanned_cap_source": "Origen del límite", - "estimated_bytes_scanned": "Bytes escaneados estimados" + "estimated_bytes_scanned": "Bytes escaneados estimados", + "data_budget_action": "Acción al agotar el presupuesto", + "data_budget_id": "Presupuesto de datos", + "data_budget_name": "Nombre del presupuesto de datos", + "data_budget_remaining_bytes": "Bytes restantes del presupuesto", + "data_budget_remaining_rows": "Filas restantes del presupuesto", + "data_budget_suppressed": "Suprimido por el presupuesto de datos", + "data_budget_used_percent": "Presupuesto de datos usado" }, "use_id": "Usar el ID {{id}}", "user_id_placeholder": "Pegue un ID de usuario", @@ -5832,5 +5852,93 @@ "key_control_chars": "El nombre de la aplicación no debe contener caracteres de control.", "rotate_help": "Déjalo vacío para conservar el nombre de aplicación de la clave actual.", "column": "Aplicación" + }, + "dataBudgets": { + "window_hours_one": "{{count}} hora", + "window_hours_other": "{{count}} horas", + "window_days_one": "{{count}} día", + "window_days_other": "{{count}} días", + "indicator": { + "title": "Tu presupuesto de datos", + "exhausted_title": "Presupuesto de datos agotado", + "exhausted_reject": "Las nuevas lecturas en esta fuente de datos se rechazan hasta que las lecturas anteriores salgan de la ventana.", + "exhausted_review": "Las nuevas lecturas en esta fuente de datos necesitan la aprobación de un revisor hasta que las lecturas anteriores salgan de la ventana.", + "nearly_used": "Has usado la mayor parte de tu presupuesto de datos en esta fuente de datos.", + "rows_left": "Quedan {{remaining}} de {{limit}} filas", + "bytes_left": "Quedan {{remaining}} de {{limit}}", + "per_window": "por {{window}}", + "used_aria": "{{name}}: {{percent}} % usado" + }, + "tab": { + "title": "Presupuestos de datos", + "description": "Limita cuántas filas y bytes de resultado puede leer cada usuario de esta fuente de datos en una ventana móvil. Una consulta se limita a la cuota restante; cuando un presupuesto se agota, las nuevas lecturas se rechazan o pasan a revisión humana.", + "add": "Añadir presupuesto", + "empty_title": "Sin presupuestos de datos", + "empty_description": "Las lecturas en esta fuente de datos solo se limitan por consulta.", + "col_name": "Nombre", + "col_limits": "Límites", + "col_window": "Ventana", + "col_action": "Al agotarse", + "col_applies_to": "Se aplica a", + "col_enabled": "Activado", + "col_actions": "Acciones", + "state_disabled": "Desactivado", + "edit": "Editar presupuesto", + "delete": "Eliminar presupuesto", + "delete_confirm_title": "¿Eliminar este presupuesto de datos?", + "delete_confirm_body": "Los usuarios afectados dejan de estar limitados por él. El uso registrado se conserva hasta que caduca.", + "delete_success": "Presupuesto de datos eliminado", + "delete_error": "No se pudo eliminar el presupuesto de datos", + "save_success": "Presupuesto de datos guardado", + "save_error": "No se pudo guardar el presupuesto de datos", + "create_title": "Nuevo presupuesto de datos", + "edit_title": "Editar presupuesto de datos", + "label_name": "Nombre", + "name_required": "Introduce un nombre", + "name_max": "Usa como máximo 120 caracteres", + "label_max_rows": "Límite de filas", + "label_max_bytes": "Límite de tamaño de resultado", + "limits_hint": "Define un límite de filas, de tamaño o ambos. Cada usuario tiene su propia cuota.", + "limit_required": "Define un límite de filas o de tamaño", + "rows_min": "El límite de filas debe ser al menos 1", + "bytes_min": "El límite de tamaño debe ser al menos 1 byte", + "label_window": "Ventana móvil", + "window_hint": "El uso se cuenta en la ventana anterior, medida desde ahora.", + "window_range": "La ventana debe estar entre 1 hora y 31 días", + "window_unit": "Unidad de la ventana", + "unit_hours": "horas", + "unit_days": "días", + "label_breach_action": "Cuando se agota el presupuesto", + "breach_action_hint": "La revisión humana permite a un analista legítimo terminar un día intenso; rechazar detiene las lecturas. El acceso de emergencia nunca se bloquea, pero cuenta.", + "label_warn_threshold": "Avisar al (% usado)", + "warn_threshold_hint": "El usuario recibe un aviso al superar este porcentaje. Déjalo vacío para no avisar.", + "threshold_range": "El umbral debe estar entre 1 y 99", + "label_applies_roles": "Se aplica a roles", + "applies_hint": "Deja los tres vacíos para aplicar el presupuesto a todos los usuarios de esta fuente de datos.", + "label_applies_groups": "Se aplica a grupos", + "label_applies_users": "Se aplica a usuarios", + "label_enabled": "Activado", + "applies_everyone": "Todos", + "applies_roles_one": "{{count}} rol", + "applies_roles_other": "{{count}} roles", + "applies_groups_one": "{{count}} grupo", + "applies_groups_other": "{{count}} grupos", + "applies_users_one": "{{count}} usuario", + "applies_users_other": "{{count}} usuarios", + "rows_value": "{{value}} filas", + "unit_minutes": "minutos" + }, + "usage": { + "action": "Uso de datos", + "title": "Uso de datos — {{name}}", + "description": "Filas y bytes de resultado que este usuario leyó frente a cada presupuesto que le aplica, en la ventana de cada presupuesto.", + "empty": "No se aplica ningún presupuesto de datos a este usuario.", + "load_error": "No se pudo cargar el uso de datos de este usuario", + "exhausted": "Agotado", + "rows_used": "{{used}} de {{limit}} filas", + "bytes_used": "{{used}} de {{limit}}" + }, + "window_minutes_one": "{{count}} minuto", + "window_minutes_other": "{{count}} minutos" } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c36d0004c..5c967d1c0 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -868,7 +868,8 @@ "diff_effective_label": "SQL effectif" }, "bytes_cap_body": "Analyse estimée : {{estimate}} · Limite : {{limit}} ({{source}})", - "bytes_cap_no_estimate": "aucune estimation" + "bytes_cap_no_estimate": "aucune estimation", + "results_truncated_budget": "{{count}} lignes renvoyées (plafonnées à votre budget de données restant)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Limite d’octets analysés", "grant_bytes_cap_help": "Une limite plus basse pour cette autorisation. La plus stricte entre la limite de la source de données et toutes les autorisations s’applique.", "grant_bytes_cap_min": "La limite d’octets analysés doit être d’au moins 1 octet.", - "grant_bytes_cap_placeholder": "Valeur par défaut de la source de données" + "grant_bytes_cap_placeholder": "Valeur par défaut de la source de données", + "tab_data_budgets": "Budgets de données · {{count}}", + "sample_truncated_budget": "{{count}} lignes (plafonnées à votre budget de données restant)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "min", "cicd_present_label": "Provenance CI/CD", "scan_type_placeholder": "p. ex. Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Octets analysés" + "bytes_value_label": "Octets analysés", + "budget_percent_label": "Part du budget de données consommée" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Votre modification de schéma {{changeSet}} a échoué sur {{environment}}", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Votre modification de schéma {{changeSet}} n'a été que partiellement appliquée sur {{environment}}", "SCHEMA_DRIFT_DETECTED_one": "{{count}} nouvelle constatation de dérive de schéma sur {{pipeline}} / {{environment}}", - "SCHEMA_DRIFT_DETECTED_other": "{{count}} nouvelles constatations de dérive de schéma sur {{pipeline}} / {{environment}}" + "SCHEMA_DRIFT_DETECTED_other": "{{count}} nouvelles constatations de dérive de schéma sur {{pipeline}} / {{environment}}", + "DATA_BUDGET_THRESHOLD_REACHED": "Vous avez utilisé {{percent}} % de votre budget de données {{budget}} sur {{datasource}}", + "DATA_BUDGET_EXHAUSTED": "Le budget de données {{budget}} de {{user}} sur {{datasource}} est épuisé" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "Provenance CI/CD", "estimated_rows": "Lignes estimées", "scan_type": "Type de scan", - "estimated_bytes_scanned": "Octets analysés estimés" + "estimated_bytes_scanned": "Octets analysés estimés", + "data_budget_used_percent": "Budget de données consommé (%)" }, "query_shape": { "JOIN": "Jointure", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "Gérer les politiques d'export", "ROW_LIMIT_POLICY_MANAGE": "Gérer les politiques de limite de lignes", "DEPLOYMENT_PIPELINE_MANAGE": "Gérer les pipelines de déploiement", - "DEPLOYMENT_REVIEW": "Réviser les déploiements" + "DEPLOYMENT_REVIEW": "Réviser les déploiements", + "DATA_BUDGET_MANAGE": "Gérer les budgets de données" }, "api_variable_kind": { "CONSTANT": "Constante", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "Sécurité par ligne", "MASKING": "Masquage", "BREAK_GLASS": "Accès d'urgence", - "BYTES_SCANNED_CAP": "Limite d’octets analysés" + "BYTES_SCANNED_CAP": "Limite d’octets analysés", + "DATA_BUDGET": "Budget de données" }, "api_decision_step": { "CONNECTOR_GATES": "Contrôles du connecteur", @@ -3802,6 +3811,10 @@ "EXCEEDED": "Rejetée : l’estimation dépasse la limite d’octets analysés", "NO_ESTIMATE_REVIEW": "Mise en revue : aucune estimation en octets sous la limite", "NO_ESTIMATE_REJECTED": "Rejetée : aucune estimation en octets sous la limite" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Envoyer en revue humaine", + "REJECT": "Refuser" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Limite d’octets analysés", "bytes_scanned_cap_outcome": "Résultat de la limite", "bytes_scanned_cap_source": "Origine de la limite", - "estimated_bytes_scanned": "Octets analysés estimés" + "estimated_bytes_scanned": "Octets analysés estimés", + "data_budget_action": "Action à l’épuisement du budget", + "data_budget_id": "Budget de données", + "data_budget_name": "Nom du budget de données", + "data_budget_remaining_bytes": "Octets restants du budget", + "data_budget_remaining_rows": "Lignes restantes du budget", + "data_budget_suppressed": "Suspendu par le budget de données", + "data_budget_used_percent": "Budget de données consommé" }, "use_id": "Utiliser l'ID {{id}}", "user_id_placeholder": "Collez un ID d'utilisateur", @@ -5832,5 +5852,93 @@ "key_control_chars": "Le nom de l'application ne doit pas contenir de caractères de contrôle.", "rotate_help": "Laissez vide pour conserver le nom d'application de la clé actuelle.", "column": "Application" + }, + "dataBudgets": { + "window_hours_one": "{{count}} heure", + "window_hours_other": "{{count}} heures", + "window_days_one": "{{count}} jour", + "window_days_other": "{{count}} jours", + "indicator": { + "title": "Votre budget de données", + "exhausted_title": "Budget de données épuisé", + "exhausted_reject": "Les nouvelles lectures sur cette source de données sont refusées jusqu’à ce que les lectures précédentes sortent de la fenêtre.", + "exhausted_review": "Les nouvelles lectures sur cette source de données nécessitent l’approbation d’un réviseur jusqu’à ce que les lectures précédentes sortent de la fenêtre.", + "nearly_used": "Vous avez consommé la majeure partie de votre budget de données sur cette source.", + "rows_left": "{{remaining}} lignes restantes sur {{limit}}", + "bytes_left": "{{remaining}} restants sur {{limit}}", + "per_window": "par {{window}}", + "used_aria": "{{name}} : {{percent}} % consommé" + }, + "tab": { + "title": "Budgets de données", + "description": "Limitez le nombre de lignes et d’octets de résultat que chaque utilisateur peut lire depuis cette source sur une fenêtre glissante. Une requête est plafonnée au quota restant ; une fois un budget épuisé, les nouvelles lectures sont refusées ou envoyées en revue humaine.", + "add": "Ajouter un budget", + "empty_title": "Aucun budget de données", + "empty_description": "Les lectures sur cette source ne sont limitées que par requête.", + "col_name": "Nom", + "col_limits": "Limites", + "col_window": "Fenêtre", + "col_action": "Une fois épuisé", + "col_applies_to": "S’applique à", + "col_enabled": "Activé", + "col_actions": "Actions", + "state_disabled": "Désactivé", + "edit": "Modifier le budget", + "delete": "Supprimer le budget", + "delete_confirm_title": "Supprimer ce budget de données ?", + "delete_confirm_body": "Les utilisateurs concernés n’y sont plus soumis. L’usage enregistré est conservé jusqu’à son expiration.", + "delete_success": "Budget de données supprimé", + "delete_error": "Impossible de supprimer le budget de données", + "save_success": "Budget de données enregistré", + "save_error": "Impossible d’enregistrer le budget de données", + "create_title": "Nouveau budget de données", + "edit_title": "Modifier le budget de données", + "label_name": "Nom", + "name_required": "Saisissez un nom", + "name_max": "120 caractères maximum", + "label_max_rows": "Limite de lignes", + "label_max_bytes": "Limite de taille de résultat", + "limits_hint": "Définissez une limite de lignes, de taille, ou les deux. Chaque utilisateur dispose de son propre quota.", + "limit_required": "Définissez une limite de lignes ou de taille", + "rows_min": "La limite de lignes doit être d’au moins 1", + "bytes_min": "La limite de taille doit être d’au moins 1 octet", + "label_window": "Fenêtre glissante", + "window_hint": "L’usage est compté sur la fenêtre écoulée, mesurée à partir de maintenant.", + "window_range": "La fenêtre doit être comprise entre 1 heure et 31 jours", + "window_unit": "Unité de la fenêtre", + "unit_hours": "heures", + "unit_days": "jours", + "label_breach_action": "Quand le budget est épuisé", + "breach_action_hint": "La revue humaine permet à un analyste légitime de finir une journée chargée ; le refus arrête les lectures. L’accès d’urgence n’est jamais bloqué mais reste comptabilisé.", + "label_warn_threshold": "Avertir à (% consommé)", + "warn_threshold_hint": "L’utilisateur est prévenu une fois ce seuil franchi. Laissez vide pour ne pas avertir.", + "threshold_range": "Le seuil doit être compris entre 1 et 99", + "label_applies_roles": "S’applique aux rôles", + "applies_hint": "Laissez les trois vides pour appliquer le budget à tous les utilisateurs de cette source.", + "label_applies_groups": "S’applique aux groupes", + "label_applies_users": "S’applique aux utilisateurs", + "label_enabled": "Activé", + "applies_everyone": "Tout le monde", + "applies_roles_one": "{{count}} rôle", + "applies_roles_other": "{{count}} rôles", + "applies_groups_one": "{{count}} groupe", + "applies_groups_other": "{{count}} groupes", + "applies_users_one": "{{count}} utilisateur", + "applies_users_other": "{{count}} utilisateurs", + "rows_value": "{{value}} lignes", + "unit_minutes": "minutes" + }, + "usage": { + "action": "Usage des données", + "title": "Usage des données — {{name}}", + "description": "Lignes et octets de résultat lus par cet utilisateur pour chaque budget qui le concerne, sur la fenêtre de chaque budget.", + "empty": "Aucun budget de données ne s’applique à cet utilisateur.", + "load_error": "Impossible de charger l’usage des données de cet utilisateur", + "exhausted": "Épuisé", + "rows_used": "{{used}} lignes sur {{limit}}", + "bytes_used": "{{used}} sur {{limit}}" + }, + "window_minutes_one": "{{count}} minute", + "window_minutes_other": "{{count}} minutes" } } diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index c20dfd2ef..7b617f4dd 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -868,7 +868,8 @@ "diff_effective_label": "Փաստացի SQL" }, "bytes_cap_body": "Գնահատված սկանավորում՝ {{estimate}} · Սահմանաչափ՝ {{limit}} ({{source}})", - "bytes_cap_no_estimate": "գնահատական չկա" + "bytes_cap_no_estimate": "գնահատական չկա", + "results_truncated_budget": "Վերադարձվել է {{count}} տող (սահմանափակված ձեր մնացած տվյալների բյուջեով)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Սկանավորված բայթերի սահմանաչափ", "grant_bytes_cap_help": "Ավելի ցածր սահմանաչափ այս թույլտվության համար։ Կիրառվում է տվյալների աղբյուրի և բոլոր թույլտվությունների սահմանաչափերից ամենախիստը։", "grant_bytes_cap_min": "Սկանավորված բայթերի սահմանաչափը պետք է լինի առնվազն 1 բայթ։", - "grant_bytes_cap_placeholder": "Տվյալների աղբյուրի լռելյայնը" + "grant_bytes_cap_placeholder": "Տվյալների աղբյուրի լռելյայնը", + "tab_data_budgets": "Տվյալների բյուջեներ · {{count}}", + "sample_truncated_budget": "{{count}} տող (սահմանափակված ձեր մնացած տվյալների բյուջեով)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "ր", "cicd_present_label": "CI/CD ծագում", "scan_type_placeholder": "օր.՝ Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Սկանավորված բայթեր" + "bytes_value_label": "Սկանավորված բայթեր", + "budget_percent_label": "Տվյալների բյուջեի օգտագործված մասը" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Ձեր սխեմայի փոփոխությունը {{changeSet}} ձախողվեց {{environment}}-ում", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Ձեր սխեմայի փոփոխությունը {{changeSet}} միայն մասնակիորեն կիրառվեց {{environment}}-ում", "SCHEMA_DRIFT_DETECTED_one": "{{count}} նոր սխեմայի շեղում {{pipeline}} / {{environment}}-ում", - "SCHEMA_DRIFT_DETECTED_other": "{{count}} նոր սխեմայի շեղում {{pipeline}} / {{environment}}-ում" + "SCHEMA_DRIFT_DETECTED_other": "{{count}} նոր սխեմայի շեղում {{pipeline}} / {{environment}}-ում", + "DATA_BUDGET_THRESHOLD_REACHED": "Դուք օգտագործել եք {{datasource}}-ում ձեր {{budget}} տվյալների բյուջեի {{percent}}%-ը", + "DATA_BUDGET_EXHAUSTED": "{{user}}-ի {{budget}} տվյալների բյուջեն {{datasource}}-ում սպառված է" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "CI/CD ծագում", "estimated_rows": "Գնահատված տողեր", "scan_type": "Սկանավորման տեսակ", - "estimated_bytes_scanned": "Սկանավորվող բայթերի գնահատական" + "estimated_bytes_scanned": "Սկանավորվող բայթերի գնահատական", + "data_budget_used_percent": "Տվյալների բյուջեի օգտագործում (%)" }, "query_shape": { "JOIN": "Միացում (JOIN)", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "Կառավարել արտահանման քաղաքականությունները", "ROW_LIMIT_POLICY_MANAGE": "Կառավարել տողերի սահմանաչափի քաղաքականությունները", "DEPLOYMENT_PIPELINE_MANAGE": "Կառավարել տեղակայման փիփլայնները", - "DEPLOYMENT_REVIEW": "Վերանայել տեղակայումները" + "DEPLOYMENT_REVIEW": "Վերանայել տեղակայումները", + "DATA_BUDGET_MANAGE": "Կառավարել տվյալների բյուջեները" }, "api_variable_kind": { "CONSTANT": "Հաստատուն", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "Տողային անվտանգություն", "MASKING": "Քողարկում", "BREAK_GLASS": "Արտակարգ մուտք", - "BYTES_SCANNED_CAP": "Սկանավորված բայթերի սահմանաչափ" + "BYTES_SCANNED_CAP": "Սկանավորված բայթերի սահմանաչափ", + "DATA_BUDGET": "Տվյալների բյուջե" }, "api_decision_step": { "CONNECTOR_GATES": "Միակցիչի ստուգումներ", @@ -3802,6 +3811,10 @@ "EXCEEDED": "Մերժված է. գնահատականը գերազանցում է սկանավորված բայթերի սահմանաչափը", "NO_ESTIMATE_REVIEW": "Պահված է ստուգման համար. սահմանաչափի ներքո բայթերի գնահատական չկա", "NO_ESTIMATE_REJECTED": "Մերժված է. սահմանաչափի ներքո բայթերի գնահատական չկա" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Ուղարկել մարդկային վերանայման", + "REJECT": "Մերժել" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Սկանավորված բայթերի սահմանաչափ", "bytes_scanned_cap_outcome": "Սահմանաչափի արդյունք", "bytes_scanned_cap_source": "Սահմանաչափի աղբյուր", - "estimated_bytes_scanned": "Սկանավորվող բայթերի գնահատական" + "estimated_bytes_scanned": "Սկանավորվող բայթերի գնահատական", + "data_budget_action": "Բյուջեի սպառման գործողություն", + "data_budget_id": "Տվյալների բյուջե", + "data_budget_name": "Տվյալների բյուջեի անուն", + "data_budget_remaining_bytes": "Բյուջեի մնացած բայթեր", + "data_budget_remaining_rows": "Բյուջեի մնացած տողեր", + "data_budget_suppressed": "Կասեցված է տվյալների բյուջեով", + "data_budget_used_percent": "Տվյալների բյուջեի օգտագործում" }, "use_id": "Օգտագործել ID {{id}}", "user_id_placeholder": "Տեղադրեք օգտատիրոջ ID", @@ -5832,5 +5852,93 @@ "key_control_chars": "Հավելվածի անունը չպետք է պարունակի կառավարման նիշեր։", "rotate_help": "Թողեք դատարկ՝ ընթացիկ բանալու հավելվածի անունը պահպանելու համար։", "column": "Հավելված" + }, + "dataBudgets": { + "window_hours_one": "{{count}} ժամ", + "window_hours_other": "{{count}} ժամ", + "window_days_one": "{{count}} օր", + "window_days_other": "{{count}} օր", + "indicator": { + "title": "Ձեր տվյալների բյուջեն", + "exhausted_title": "Տվյալների բյուջեն սպառված է", + "exhausted_reject": "Այս տվյալների աղբյուրի նոր ընթերցումները մերժվում են, մինչև նախորդ ընթերցումները դուրս գան պատուհանից։", + "exhausted_review": "Այս տվյալների աղբյուրի նոր ընթերցումները պահանջում են վերանայողի հաստատում, մինչև նախորդ ընթերցումները դուրս գան պատուհանից։", + "nearly_used": "Դուք օգտագործել եք այս տվյալների աղբյուրի ձեր բյուջեի մեծ մասը։", + "rows_left": "Մնացել է {{remaining}} տող {{limit}}-ից", + "bytes_left": "Մնացել է {{remaining}} {{limit}}-ից", + "per_window": "յուրաքանչյուր {{window}}", + "used_aria": "{{name}}՝ օգտագործված է {{percent}}%" + }, + "tab": { + "title": "Տվյալների բյուջեներ", + "description": "Սահմանափակեք, թե քանի տող և արդյունքի բայթ կարող է յուրաքանչյուր օգտատեր կարդալ այս աղբյուրից սահող պատուհանում։ Հարցումը սահմանափակվում է մնացած ծավալով. բյուջեի սպառումից հետո նոր ընթերցումները մերժվում են կամ ուղարկվում մարդկային վերանայման։", + "add": "Ավելացնել բյուջե", + "empty_title": "Տվյալների բյուջեներ չկան", + "empty_description": "Այս աղբյուրի ընթերցումները սահմանափակված են միայն ըստ հարցման։", + "col_name": "Անուն", + "col_limits": "Սահմանաչափեր", + "col_window": "Պատուհան", + "col_action": "Սպառվելիս", + "col_applies_to": "Կիրառվում է", + "col_enabled": "Միացված", + "col_actions": "Գործողություններ", + "state_disabled": "Անջատված", + "edit": "Խմբագրել բյուջեն", + "delete": "Ջնջել բյուջեն", + "delete_confirm_title": "Ջնջե՞լ այս տվյալների բյուջեն", + "delete_confirm_body": "Այն օգտատերերը, որոնց վրա կիրառվում էր, այլևս չեն սահմանափակվի։ Գրանցված օգտագործումը պահպանվում է մինչև ժամկետի ավարտը։", + "delete_success": "Տվյալների բյուջեն ջնջվեց", + "delete_error": "Չհաջողվեց ջնջել տվյալների բյուջեն", + "save_success": "Տվյալների բյուջեն պահպանվեց", + "save_error": "Չհաջողվեց պահպանել տվյալների բյուջեն", + "create_title": "Նոր տվյալների բյուջե", + "edit_title": "Խմբագրել տվյալների բյուջեն", + "label_name": "Անուն", + "name_required": "Մուտքագրեք անուն", + "name_max": "Առավելագույնը 120 նիշ", + "label_max_rows": "Տողերի սահմանաչափ", + "label_max_bytes": "Արդյունքի չափի սահմանաչափ", + "limits_hint": "Սահմանեք տողերի, չափի սահմանաչափ կամ երկուսը։ Յուրաքանչյուր օգտատեր ունի իր սեփական ծավալը։", + "limit_required": "Սահմանեք տողերի կամ չափի սահմանաչափ", + "rows_min": "Տողերի սահմանաչափը պետք է լինի առնվազն 1", + "bytes_min": "Չափի սահմանաչափը պետք է լինի առնվազն 1 բայթ", + "label_window": "Սահող պատուհան", + "window_hint": "Օգտագործումը հաշվվում է անցած պատուհանում՝ հիմա պահից։", + "window_range": "Պատուհանը պետք է լինի 1 ժամից մինչև 31 օր", + "window_unit": "Պատուհանի միավոր", + "unit_hours": "ժամ", + "unit_days": "օր", + "label_breach_action": "Երբ բյուջեն սպառվում է", + "breach_action_hint": "Մարդկային վերանայումը թույլ է տալիս օրինական վերլուծաբանին ավարտել ծանր օրը, մերժումը անմիջապես կանգնեցնում է ընթերցումները։ Արտակարգ մուտքը երբեք չի արգելափակվում, բայց հաշվվում է։", + "label_warn_threshold": "Զգուշացնել (% օգտագործված)", + "warn_threshold_hint": "Օգտատերը ծանուցվում է մեկ անգամ, երբ անցնում է այս մասը։ Թողեք դատարկ՝ առանց զգուշացման։", + "threshold_range": "Շեմը պետք է լինի 1-ից 99", + "label_applies_roles": "Կիրառվում է դերերի վրա", + "applies_hint": "Թողեք երեքն էլ դատարկ՝ բյուջեն այս աղբյուրի բոլոր օգտատերերի վրա կիրառելու համար։", + "label_applies_groups": "Կիրառվում է խմբերի վրա", + "label_applies_users": "Կիրառվում է օգտատերերի վրա", + "label_enabled": "Միացված", + "applies_everyone": "Բոլորը", + "applies_roles_one": "{{count}} դեր", + "applies_roles_other": "{{count}} դեր", + "applies_groups_one": "{{count}} խումբ", + "applies_groups_other": "{{count}} խումբ", + "applies_users_one": "{{count}} օգտատեր", + "applies_users_other": "{{count}} օգտատեր", + "rows_value": "{{value}} տող", + "unit_minutes": "րոպե" + }, + "usage": { + "action": "Տվյալների օգտագործում", + "title": "Տվյալների օգտագործում — {{name}}", + "description": "Տողեր և արդյունքի բայթեր, որոնք այս օգտատերը կարդացել է իր վրա կիրառվող յուրաքանչյուր բյուջեի պատուհանում։", + "empty": "Այս օգտատիրոջ վրա տվյալների բյուջե չի կիրառվում։", + "load_error": "Չհաջողվեց բեռնել այս օգտատիրոջ տվյալների օգտագործումը", + "exhausted": "Սպառված", + "rows_used": "{{used}} տող {{limit}}-ից", + "bytes_used": "{{used}} {{limit}}-ից" + }, + "window_minutes_one": "{{count}} րոպե", + "window_minutes_other": "{{count}} րոպե" } } diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 5367102af..5adf6aaf9 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -868,7 +868,8 @@ "diff_effective_label": "Фактический SQL" }, "bytes_cap_body": "Оценка сканирования: {{estimate}} · Лимит: {{limit}} ({{source}})", - "bytes_cap_no_estimate": "нет оценки" + "bytes_cap_no_estimate": "нет оценки", + "results_truncated_budget": "Возвращено строк: {{count}} (ограничено оставшимся бюджетом данных)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "Лимит сканируемых байтов", "grant_bytes_cap_help": "Более низкий лимит для этой выдачи доступа. Применяется самый строгий из лимита источника данных и всех выдач.", "grant_bytes_cap_min": "Лимит сканируемых байтов должен быть не менее 1 байта.", - "grant_bytes_cap_placeholder": "По умолчанию источника данных" + "grant_bytes_cap_placeholder": "По умолчанию источника данных", + "tab_data_budgets": "Бюджеты данных · {{count}}", + "sample_truncated_budget": "Строк: {{count}} (ограничено оставшимся бюджетом данных)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "мин", "cicd_present_label": "Источник CI/CD", "scan_type_placeholder": "напр. Seq Scan, COLLSCAN, Index*", - "bytes_value_label": "Сканируемые байты" + "bytes_value_label": "Сканируемые байты", + "budget_percent_label": "Доля использованного бюджета данных" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "Ваше изменение схемы {{changeSet}} не выполнено в {{environment}}", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "Ваше изменение схемы {{changeSet}} применено в {{environment}} лишь частично", "SCHEMA_DRIFT_DETECTED_one": "{{count}} новая находка дрейфа схемы в {{pipeline}} / {{environment}}", - "SCHEMA_DRIFT_DETECTED_other": "Новых находок дрейфа схемы в {{pipeline}} / {{environment}}: {{count}}" + "SCHEMA_DRIFT_DETECTED_other": "Новых находок дрейфа схемы в {{pipeline}} / {{environment}}: {{count}}", + "DATA_BUDGET_THRESHOLD_REACHED": "Вы использовали {{percent}}% бюджета данных {{budget}} в {{datasource}}", + "DATA_BUDGET_EXHAUSTED": "Бюджет данных {{budget}} пользователя {{user}} в {{datasource}} исчерпан" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "Источник CI/CD", "estimated_rows": "Оценка строк", "scan_type": "Тип сканирования", - "estimated_bytes_scanned": "Оценка сканируемых байтов" + "estimated_bytes_scanned": "Оценка сканируемых байтов", + "data_budget_used_percent": "Использовано бюджета данных (%)" }, "query_shape": { "JOIN": "Соединение (JOIN)", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "Управлять политиками экспорта", "ROW_LIMIT_POLICY_MANAGE": "Управление политиками лимита строк", "DEPLOYMENT_PIPELINE_MANAGE": "Управлять пайплайнами развертываний", - "DEPLOYMENT_REVIEW": "Проверять развертывания" + "DEPLOYMENT_REVIEW": "Проверять развертывания", + "DATA_BUDGET_MANAGE": "Управление бюджетами данных" }, "api_variable_kind": { "CONSTANT": "Константа", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "Построчная безопасность", "MASKING": "Маскирование", "BREAK_GLASS": "Экстренный доступ", - "BYTES_SCANNED_CAP": "Лимит сканируемых байтов" + "BYTES_SCANNED_CAP": "Лимит сканируемых байтов", + "DATA_BUDGET": "Бюджет данных" }, "api_decision_step": { "CONNECTOR_GATES": "Проверки коннектора", @@ -3802,6 +3811,10 @@ "EXCEEDED": "Отклонено: оценка превышает лимит сканируемых байтов", "NO_ESTIMATE_REVIEW": "Отправлено на проверку: нет оценки в байтах при лимите", "NO_ESTIMATE_REJECTED": "Отклонено: нет оценки в байтах при лимите" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "Отправить на проверку", + "REJECT": "Отклонить" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "Лимит сканируемых байтов", "bytes_scanned_cap_outcome": "Результат лимита", "bytes_scanned_cap_source": "Источник лимита", - "estimated_bytes_scanned": "Оценка сканируемых байтов" + "estimated_bytes_scanned": "Оценка сканируемых байтов", + "data_budget_action": "Действие при исчерпании бюджета", + "data_budget_id": "Бюджет данных", + "data_budget_name": "Название бюджета данных", + "data_budget_remaining_bytes": "Осталось байтов бюджета", + "data_budget_remaining_rows": "Осталось строк бюджета", + "data_budget_suppressed": "Подавлено бюджетом данных", + "data_budget_used_percent": "Использовано бюджета данных" }, "use_id": "Использовать ID {{id}}", "user_id_placeholder": "Вставьте ID пользователя", @@ -5832,5 +5852,93 @@ "key_control_chars": "Имя приложения не должно содержать управляющих символов.", "rotate_help": "Оставьте пустым, чтобы сохранить имя приложения текущего ключа.", "column": "Приложение" + }, + "dataBudgets": { + "window_hours_one": "{{count}} час", + "window_hours_other": "{{count}} ч", + "window_days_one": "{{count}} день", + "window_days_other": "{{count}} дн.", + "indicator": { + "title": "Ваш бюджет данных", + "exhausted_title": "Бюджет данных исчерпан", + "exhausted_reject": "Новые чтения из этого источника данных отклоняются, пока предыдущие чтения не выйдут за пределы окна.", + "exhausted_review": "Новые чтения из этого источника данных требуют одобрения проверяющего, пока предыдущие чтения не выйдут за пределы окна.", + "nearly_used": "Вы израсходовали большую часть бюджета данных для этого источника.", + "rows_left": "Осталось {{remaining}} из {{limit}} строк", + "bytes_left": "Осталось {{remaining}} из {{limit}}", + "per_window": "за {{window}}", + "used_aria": "{{name}}: использовано {{percent}}%" + }, + "tab": { + "title": "Бюджеты данных", + "description": "Ограничьте, сколько строк и байтов результата каждый пользователь может прочитать из этого источника в скользящем окне. Запрос ограничивается оставшимся объёмом; когда бюджет исчерпан, новые чтения отклоняются или отправляются на проверку.", + "add": "Добавить бюджет", + "empty_title": "Нет бюджетов данных", + "empty_description": "Чтения из этого источника ограничены только на уровне запроса.", + "col_name": "Название", + "col_limits": "Лимиты", + "col_window": "Окно", + "col_action": "При исчерпании", + "col_applies_to": "Применяется к", + "col_enabled": "Включён", + "col_actions": "Действия", + "state_disabled": "Выключен", + "edit": "Изменить бюджет", + "delete": "Удалить бюджет", + "delete_confirm_title": "Удалить этот бюджет данных?", + "delete_confirm_body": "Пользователи больше не будут им ограничены. Учтённое использование хранится, пока не устареет.", + "delete_success": "Бюджет данных удалён", + "delete_error": "Не удалось удалить бюджет данных", + "save_success": "Бюджет данных сохранён", + "save_error": "Не удалось сохранить бюджет данных", + "create_title": "Новый бюджет данных", + "edit_title": "Изменить бюджет данных", + "label_name": "Название", + "name_required": "Введите название", + "name_max": "Не более 120 символов", + "label_max_rows": "Лимит строк", + "label_max_bytes": "Лимит объёма результата", + "limits_hint": "Задайте лимит строк, объёма или оба. У каждого пользователя свой объём.", + "limit_required": "Задайте лимит строк или объёма", + "rows_min": "Лимит строк должен быть не меньше 1", + "bytes_min": "Лимит объёма должен быть не меньше 1 байта", + "label_window": "Скользящее окно", + "window_hint": "Использование считается за прошедшее окно, отсчитанное от текущего момента.", + "window_range": "Окно должно быть от 1 часа до 31 дня", + "window_unit": "Единица окна", + "unit_hours": "часов", + "unit_days": "дней", + "label_breach_action": "Когда бюджет исчерпан", + "breach_action_hint": "Проверка человеком позволяет добросовестному аналитику завершить загруженный день; отклонение сразу останавливает чтения. Экстренный доступ не блокируется, но учитывается.", + "label_warn_threshold": "Предупреждать при (% использовано)", + "warn_threshold_hint": "Пользователь получит уведомление один раз при превышении этой доли. Оставьте пустым, чтобы не предупреждать.", + "threshold_range": "Порог должен быть от 1 до 99", + "label_applies_roles": "Применяется к ролям", + "applies_hint": "Оставьте все три поля пустыми, чтобы бюджет применялся ко всем пользователям источника.", + "label_applies_groups": "Применяется к группам", + "label_applies_users": "Применяется к пользователям", + "label_enabled": "Включён", + "applies_everyone": "Все", + "applies_roles_one": "{{count}} роль", + "applies_roles_other": "{{count}} ролей", + "applies_groups_one": "{{count}} группа", + "applies_groups_other": "{{count}} групп", + "applies_users_one": "{{count}} пользователь", + "applies_users_other": "{{count}} пользователей", + "rows_value": "{{value}} строк", + "unit_minutes": "минут" + }, + "usage": { + "action": "Использование данных", + "title": "Использование данных — {{name}}", + "description": "Строки и байты результата, прочитанные пользователем в окне каждого применимого к нему бюджета.", + "empty": "К этому пользователю не применяется ни один бюджет данных.", + "load_error": "Не удалось загрузить использование данных пользователя", + "exhausted": "Исчерпан", + "rows_used": "{{used}} из {{limit}} строк", + "bytes_used": "{{used}} из {{limit}}" + }, + "window_minutes_one": "{{count}} минута", + "window_minutes_other": "{{count}} мин" } } diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index ea8a8cb5f..a7aac24fa 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -868,7 +868,8 @@ "diff_effective_label": "实际执行的 SQL" }, "bytes_cap_body": "预计扫描:{{estimate}} · 上限:{{limit}}({{source}})", - "bytes_cap_no_estimate": "无估算" + "bytes_cap_no_estimate": "无估算", + "results_truncated_budget": "返回 {{count}} 行(受剩余数据预算限制)" } }, "reviews": { @@ -1590,7 +1591,9 @@ "grant_bytes_cap_label": "扫描字节上限", "grant_bytes_cap_help": "为此授权设置更低的上限。取数据源上限与所有授权中最严格的一个。", "grant_bytes_cap_min": "扫描字节上限至少为 1 字节。", - "grant_bytes_cap_placeholder": "数据源默认值" + "grant_bytes_cap_placeholder": "数据源默认值", + "tab_data_budgets": "数据预算 · {{count}}", + "sample_truncated_budget": "{{count}} 行(受剩余数据预算限制)" } }, "admin": { @@ -2471,7 +2474,8 @@ "minutes_suffix": "分钟", "cicd_present_label": "来自 CI/CD", "scan_type_placeholder": "例如 Seq Scan、COLLSCAN、Index*", - "bytes_value_label": "扫描字节数" + "bytes_value_label": "扫描字节数", + "budget_percent_label": "数据预算已用比例" }, "langfuse": { "title": "Langfuse", @@ -2912,7 +2916,9 @@ "SCHEMA_CHANGE_PROMOTION_FAILED": "您的架构变更 {{changeSet}} 在 {{environment}} 上失败", "SCHEMA_CHANGE_PROMOTION_FAILED_partial": "您的架构变更 {{changeSet}} 仅部分应用到 {{environment}}", "SCHEMA_DRIFT_DETECTED_one": "{{pipeline}} / {{environment}} 上有 {{count}} 项新的架构漂移发现", - "SCHEMA_DRIFT_DETECTED_other": "{{pipeline}} / {{environment}} 上有 {{count}} 项新的架构漂移发现" + "SCHEMA_DRIFT_DETECTED_other": "{{pipeline}} / {{environment}} 上有 {{count}} 项新的架构漂移发现", + "DATA_BUDGET_THRESHOLD_REACHED": "您已使用 {{datasource}} 上数据预算 {{budget}} 的 {{percent}}%", + "DATA_BUDGET_EXHAUSTED": "{{user}} 在 {{datasource}} 上的数据预算 {{budget}} 已用尽" } }, "profile": { @@ -3287,7 +3293,8 @@ "cicd_origin": "CI/CD 来源", "estimated_rows": "预估行数", "scan_type": "扫描类型", - "estimated_bytes_scanned": "预计扫描字节数" + "estimated_bytes_scanned": "预计扫描字节数", + "data_budget_used_percent": "数据预算已用(%)" }, "query_shape": { "JOIN": "连接(JOIN)", @@ -3562,7 +3569,8 @@ "EXPORT_POLICY_MANAGE": "管理导出策略", "ROW_LIMIT_POLICY_MANAGE": "管理行数限制策略", "DEPLOYMENT_PIPELINE_MANAGE": "管理部署流水线", - "DEPLOYMENT_REVIEW": "审核部署" + "DEPLOYMENT_REVIEW": "审核部署", + "DATA_BUDGET_MANAGE": "管理数据预算" }, "api_variable_kind": { "CONSTANT": "常量", @@ -3733,7 +3741,8 @@ "ROW_SECURITY": "行级安全", "MASKING": "脱敏", "BREAK_GLASS": "紧急访问", - "BYTES_SCANNED_CAP": "扫描字节上限" + "BYTES_SCANNED_CAP": "扫描字节上限", + "DATA_BUDGET": "数据预算" }, "api_decision_step": { "CONNECTOR_GATES": "连接器检查", @@ -3802,6 +3811,10 @@ "EXCEEDED": "已拒绝:估算超过扫描字节上限", "NO_ESTIMATE_REVIEW": "已转人工审查:上限下没有字节估算", "NO_ESTIMATE_REJECTED": "已拒绝:上限下没有字节估算" + }, + "data_budget_breach_action": { + "REQUIRE_REVIEW": "转交人工审核", + "REJECT": "拒绝" } }, "access": { @@ -5812,7 +5825,14 @@ "bytes_scanned_cap": "扫描字节上限", "bytes_scanned_cap_outcome": "上限结果", "bytes_scanned_cap_source": "上限来源", - "estimated_bytes_scanned": "预计扫描字节数" + "estimated_bytes_scanned": "预计扫描字节数", + "data_budget_action": "预算用尽时的操作", + "data_budget_id": "数据预算", + "data_budget_name": "数据预算名称", + "data_budget_remaining_bytes": "预算剩余字节", + "data_budget_remaining_rows": "预算剩余行数", + "data_budget_suppressed": "被数据预算抑制", + "data_budget_used_percent": "数据预算已用" }, "use_id": "使用 ID {{id}}", "user_id_placeholder": "粘贴用户 ID", @@ -5832,5 +5852,93 @@ "key_control_chars": "应用名称不能包含控制字符。", "rotate_help": "留空则沿用当前密钥的应用名称。", "column": "应用" + }, + "dataBudgets": { + "window_hours_one": "{{count}} 小时", + "window_hours_other": "{{count}} 小时", + "window_days_one": "{{count}} 天", + "window_days_other": "{{count}} 天", + "indicator": { + "title": "你的数据预算", + "exhausted_title": "数据预算已用尽", + "exhausted_reject": "在之前的读取移出窗口之前,此数据源上的新读取将被拒绝。", + "exhausted_review": "在之前的读取移出窗口之前,此数据源上的新读取需要审核人批准。", + "nearly_used": "你已用掉此数据源上的大部分数据预算。", + "rows_left": "剩余 {{remaining}} / {{limit}} 行", + "bytes_left": "剩余 {{remaining}} / {{limit}}", + "per_window": "每 {{window}}", + "used_aria": "{{name}}:已使用 {{percent}}%" + }, + "tab": { + "title": "数据预算", + "description": "限制每个用户在滚动窗口内可从此数据源读取的行数和结果字节数。查询会被限制在剩余额度内;预算用尽后,新的读取将被拒绝或转交人工审核。", + "add": "添加预算", + "empty_title": "没有数据预算", + "empty_description": "此数据源上的读取仅按单个查询限制。", + "col_name": "名称", + "col_limits": "上限", + "col_window": "窗口", + "col_action": "用尽时", + "col_applies_to": "适用于", + "col_enabled": "已启用", + "col_actions": "操作", + "state_disabled": "已停用", + "edit": "编辑预算", + "delete": "删除预算", + "delete_confirm_title": "删除此数据预算?", + "delete_confirm_body": "受其约束的用户将不再受限。已记录的用量会保留至过期。", + "delete_success": "数据预算已删除", + "delete_error": "无法删除数据预算", + "save_success": "数据预算已保存", + "save_error": "无法保存数据预算", + "create_title": "新建数据预算", + "edit_title": "编辑数据预算", + "label_name": "名称", + "name_required": "请输入名称", + "name_max": "最多 120 个字符", + "label_max_rows": "行数上限", + "label_max_bytes": "结果大小上限", + "limits_hint": "设置行数上限、结果大小上限或两者。每个用户拥有各自的额度。", + "limit_required": "请设置行数或结果大小上限", + "rows_min": "行数上限至少为 1", + "bytes_min": "结果大小上限至少为 1 字节", + "label_window": "滚动窗口", + "window_hint": "用量按从现在往回推算的窗口统计。", + "window_range": "窗口必须介于 1 小时到 31 天之间", + "window_unit": "窗口单位", + "unit_hours": "小时", + "unit_days": "天", + "label_breach_action": "预算用尽时", + "breach_action_hint": "人工审核可让正常工作的分析师完成繁忙的一天;拒绝则直接停止读取。紧急访问永不被阻止,但仍计入用量。", + "label_warn_threshold": "预警阈值(已用 %)", + "warn_threshold_hint": "用户超过此比例时会收到一次通知。留空表示不预警。", + "threshold_range": "阈值必须介于 1 到 99 之间", + "label_applies_roles": "适用角色", + "applies_hint": "三项都留空则预算适用于此数据源的所有用户。", + "label_applies_groups": "适用组", + "label_applies_users": "适用用户", + "label_enabled": "已启用", + "applies_everyone": "所有人", + "applies_roles_one": "{{count}} 个角色", + "applies_roles_other": "{{count}} 个角色", + "applies_groups_one": "{{count}} 个组", + "applies_groups_other": "{{count}} 个组", + "applies_users_one": "{{count}} 个用户", + "applies_users_other": "{{count}} 个用户", + "rows_value": "{{value}} 行", + "unit_minutes": "分钟" + }, + "usage": { + "action": "数据用量", + "title": "数据用量 — {{name}}", + "description": "此用户在每个适用预算窗口内读取的行数和结果字节数。", + "empty": "没有适用于此用户的数据预算。", + "load_error": "无法加载此用户的数据用量", + "exhausted": "已用尽", + "rows_used": "{{used}} / {{limit}} 行", + "bytes_used": "{{used}} / {{limit}}" + }, + "window_minutes_one": "{{count}} 分钟", + "window_minutes_other": "{{count}} 分钟" } } diff --git a/frontend/src/pages/admin/AuditLogPage.tsx b/frontend/src/pages/admin/AuditLogPage.tsx index e29a6aac6..801492012 100644 --- a/frontend/src/pages/admin/AuditLogPage.tsx +++ b/frontend/src/pages/admin/AuditLogPage.tsx @@ -86,6 +86,10 @@ const ACTIONS = [ 'ROW_LIMIT_POLICY_CREATED', 'ROW_LIMIT_POLICY_UPDATED', 'ROW_LIMIT_POLICY_DELETED', + 'DATA_BUDGET_CREATED', + 'DATA_BUDGET_UPDATED', + 'DATA_BUDGET_DELETED', + 'QUERY_DATA_BUDGET_ENFORCED', 'AUDIT_SINK_CREATED', 'AUDIT_SINK_UPDATED', 'AUDIT_SINK_DELETED', diff --git a/frontend/src/pages/admin/RoutingPoliciesPage.tsx b/frontend/src/pages/admin/RoutingPoliciesPage.tsx index 6bdbc4b9c..edd99cd13 100644 --- a/frontend/src/pages/admin/RoutingPoliciesPage.tsx +++ b/frontend/src/pages/admin/RoutingPoliciesPage.tsx @@ -958,6 +958,27 @@ function ConditionValueEditor({ name, operand, groups, roleOptions }: ConditionV ); + case 'data_budget_used_percent': + return ( +
+ +