feat: reveal, break-glass and the secured-data UI API extension (phase c) #8

Merged
KofTwentyTwo merged 1 commits from feature/reveal-and-break-glass into develop 2026-10-14 12:15:52 +00:00
30 changed files with 2488 additions and 45 deletions

No files matched your search

+7
View File
@@ -6,6 +6,13 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Added
- Phase (c) HTTP-independent reveal options, reason validation, UTC daily limits, grants, audit links, cap alerts and assistant refusal.
- Five-second per-user grant cache with immediate invalidation and read-time expiration.
- Start/End break-glass table and record actions, streamed Review/Result summaries, owner/admin authorization, scheduled expiration, alerts and optional admin bell notification.
- Caller status and the versioned `secured-data` UI API extension behind the temporary `uiapi` Maven profile, with committed OpenAPI and contract/HTTP tests.
- Contingent classification field picker support for snake_case and camelCase table selections; unknown-table rejection.
- Memory and real-core coverage for reveal, break-glass, record locks and expiry. Reason columns support 500 characters; existing RDBMS installations must widen the three `reason` columns to VARCHAR(500).
- Phase (b) secured-data classification, policy, reveal-grant, break-glass and insert-only access-log tables, metadata, permissions, pickers and app navigation.
- Versioned classification and policy resolution with channel caps, table features, break-glass expiry and access fingerprints.
- Bounded asynchronous value-free logging, HMAC search fingerprints, rate limits, entropy rules and enumeration alerts.
+31 -4
View File
@@ -1,6 +1,6 @@
# QBit Secured Data
Phase (b) of [SECURED-DATA](https://github.com/QRun-IO/qqq/blob/develop/docs/SECURED-DATA.md): classification overrides, policy resolution, enumeration guardrails, and value-free access logging. QQQ core owns masking and query enforcement. Reveal/Start/End operations, home widgets, retention and audit-value protection are later phases.
Phases (b) and (c) of [SECURED-DATA](https://github.com/QRun-IO/qqq/blob/develop/docs/SECURED-DATA.md): classification overrides, policy resolution, enumeration guardrails, and value-free access logging. QQQ core owns masking and query enforcement. Reveal and break-glass services and actions are implemented. Home widgets, retention and audit-value protection remain later phases.
## Compatibility
@@ -107,6 +107,32 @@ One daemon worker drains a bounded queue as a system session. Enqueueing does no
Flagged rows raise the optional alerts producer signal `secured_data.enumeration`, deduplicated by `secured_data.enumeration:<user>:<table>.<field>:<UTC date>`. RC.5 already provides `QAlerts`; without an installed sink it logs the signal. Its catalog validator rejects underscores in type names, so this mandated type is sent through the producer without catalog registration until that core restriction is reconciled. No values or fingerprints are sent to the alert sink.
## Reveal and break-glass
`new RevealService().options(tableName, fieldName)` describes the reason choices, free-text rules, duration and remaining allowance. `reveal(tableName, primaryKey, fieldName, reason, reasonText)` performs an explicitly permission-enforced Get as the caller before a system read. It returns `value`, core-formatted `displayValue`, `expiresAt`, and `revealId`. Grants and their linked audit rows are system-written; no raw value is copied into the access log.
The effective tier must be REVEALABLE. The chosen granting policy is the row with the highest daily cap (null means unlimited), then the longest duration, then lowest lexical id. Channel-only cap rows never grant reveal settings. A feature/default-based REVEALABLE tier with no granting row uses five minutes and an allowed free-text reason. Limits count grants across all tables for the user in the UTC day. Reason and reason text each have a 500-character limit. A required explanation is accepted alongside a listed reason even if arbitrary free-text reasons are disabled. Capped requests record `REVEAL_DENIED/CAPPED` and raise `secured_data.reveal_capped`; assistant-channel requests record `REVEAL_DENIED/DENIED` and return 403.
Grant lookups cache identifiers and expiration times for at most five seconds per user, check expiration on each use, and invalidate immediately after a new grant. The service serializes cap-check/write and duplicate-session checks within one runtime. Multi-replica deployments must coordinate these operations externally for a globally atomic cap and single active session; the in-process lock is not a distributed lock.
`secured_data_break_glass_start` is a table action, labeled **Start break-glass**, requiring `secured_data_break_glass_start.hasAccess`. Its reason must contain 20-500 characters; duration defaults to 60 minutes and accepts 1-480. Table scope is optional and classification ceiling defaults to PHI. `secured_data_break_glass_end` is a record action with the standard streamed ETL Review/Result screens; ownership/admin authorization is checked at review and again at execution. Neither action is a navigation entry. The current admin-only activity table permissions remain in place; an owner can invoke End using the session id returned by status without obtaining admin access to activity tables.
Start raises `secured_data.break_glass` at core's `HIGH` mapping (`AlertSeverity.ERROR`). End and expiry resolve the same per-session alert. Bell delivery targets holders of `secured_data.admin` through core's optional notification SPI #1125. RC.5 and the installed secure-data snapshot lack that SPI: a clear warning is logged. TODO: switch to a direct notification call when #1125 ships and verify against a notification-capable core.
Configure `.withSchedulerName("yourScheduler")` to register the hidden `secured_data_break_glass_expire` process every minute. Without a scheduler, the host must invoke that process itself. Tier resolution checks expiration independently, so delayed maintenance cannot prolong access. `new StatusService().status()` returns the caller's active session or null, grants used today, and the allowance for each readable REVEALABLE field.
## UI API extension
The `secured-data` extension is version **1.0.0**:
- `POST /qqq/v1/qbit/secured-data/reveal`
- `GET /qqq/v1/qbit/secured-data/reveal/options?tableName=&fieldName=`
- `GET /qqq/v1/qbit/secured-data/status`
Build with `-Duiapi=true` and a compatible core to compile `src/main/java-uiapi` and `src/test/java-uiapi`. The producer registers the extension only when both its classes and core's extension metadata exist. The committed contract is [secured-data.yaml](src/main/resources/openapi/qqq-v1/secured-data.yaml); regenerate intentionally with `-Dqqq.uiApi.regenerate=true`. The supplied core exposes `UiApiContract.check`, so a local test-only `UiApiContractAssertions` adapter supplies the documented assertion facade. All UI API roots and adapters are `STAND-IN(core=QRun-IO/qqq#1128, removeBy=RC.6)` and fold into the normal roots at RC.6. The core OpenAPI model cannot mark object properties required; the request schema documents required properties and the handler validates them.
The classification field picker uses CUSTOM possible values, reading `table_name` or `tableName` from the form's other values. It returns only the selected readable table's labeled fields, returns none without a selection, and rejects an unknown table. TODO: add `dependsOn("table_name")` when the core metadata API supports it.
## Configuration
| Fluent option | Default / purpose |
@@ -124,6 +150,7 @@ Flagged rows raise the optional alerts producer signal `secured_data.enumeration
| `withMissAlertConsecutive(int)` | 5; zero disables |
| `withDistinctValueAlert(int)` | 15; zero disables |
| `withMinEntropyEnabled(boolean)` | true |
| `withSchedulerName(String)` | Host scheduler for the 60-second break-glass expiry job; null means host-managed invocation |
| `withClock(Clock)` | UTC system clock; injectable for deterministic expiry tests |
Configure before producing the QBit; runtime infrastructure and HMAC keys are captured at startup. Classification/policy/break-glass lookups always run as system, with a thread-local recursion guard. No reveal values are cached.
@@ -131,15 +158,15 @@ Configure before producing the QBit; runtime infrastructure and HMAC keys are ca
## Build and tests
```powershell
./mvnw.cmd -q -o verify
./mvnw.cmd -q -o -Dqqq.snapshot.version=4.1.0-SECUREDATA-SNAPSHOT verify
./mvnw.cmd -q -o clean verify
./mvnw.cmd -q -o clean verify -Dqqq.snapshot.version=4.1.0-SECUREDATA-SNAPSHOT -Duiapi=true
```
The direct suite uses memory and H2. `CoreFieldAccessBridgeIT` is included by Surefire and uses JUnit assumptions to skip unless the actual core resolver metadata is present. With the installed core snapshot it exercises Query/Get, policy tiers, masking, exact matches and outcomes, sorting denial, rate/entropy refusal and the assistant wall. Checkstyle and JaCoCo remain 70% instructions / 90% classes; stand-ins are not excluded.
## Remaining phases
RC.6 migration removes temporary types and reflection. Phase (c) adds Reveal and Start/End break-glass operations and expiry scheduling; this phase already reads active grants/sessions. Phase (e) adds the app home and broader anomaly rules. Retention, wipe and audit-value protection remain phases (f)/(g).
RC.6 migration removes temporary types and reflection. Phase (e) adds the app home and broader anomaly rules. Retention, wipe and audit-value protection remain phases (f)/(g).
## License
+3 -3
View File
@@ -47,7 +47,7 @@ CREATE TABLE secured_data_reveal_grant (
table_name VARCHAR(255) NOT NULL,
record_id VARCHAR(255) NOT NULL,
field_name VARCHAR(255) NOT NULL,
reason VARCHAR(255),
reason VARCHAR(500),
reason_text TEXT,
granted_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
@@ -58,7 +58,7 @@ CREATE TABLE secured_data_break_glass (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
user_name VARCHAR(255),
reason VARCHAR(255),
reason VARCHAR(500),
reason_text TEXT,
classification_ceiling VARCHAR(255) DEFAULT 'PHI',
table_name VARCHAR(255),
@@ -79,7 +79,7 @@ CREATE TABLE secured_data_access_log (
field_name TEXT,
record_count INTEGER,
action_name VARCHAR(255),
reason VARCHAR(255),
reason VARCHAR(500),
reason_text TEXT,
client_channel VARCHAR(255),
outcome VARCHAR(255),
+37 -20
View File
@@ -22,6 +22,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<revision>1.0.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -203,28 +204,44 @@
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<profiles>
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
<!-- STAND-IN(core=QRun-IO/qqq#1128, removeBy=RC.6): fold these roots into main/test at RC.6. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- Pinned to the latest released QQQ RC (secured-data#4). Raise to RC.6, which ships the fieldaccess hooks; local builds against unreleased core pass -Dqqq.snapshot.version=... -->
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<id>uiapi</id>
<activation><property><name>uiapi</name><value>true</value></property></activation>
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<configuration>
<sourceDirectories>
<sourceDirectory>${project.basedir}/src/main/java</sourceDirectory>
<sourceDirectory>${project.basedir}/src/main/java-uiapi</sourceDirectory>
</sourceDirectories>
</configuration>
</plugin><plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution><id>uiapi-source</id><phase>generate-sources</phase><goals><goal>add-source</goal></goals>
<configuration><sources><source>src/main/java-uiapi</source></sources></configuration>
</execution>
<execution><id>uiapi-tests</id><phase>generate-test-sources</phase><goals><goal>add-test-source</goal></goals>
<configuration><sources><source>src/test/java-uiapi</source></sources></configuration>
</execution>
</executions>
</plugin></plugins></build>
</profile>
</profiles>
</project>
@@ -0,0 +1,56 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.uiapi;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1128, removeBy=RC.6): move to main source root.
*******************************************************************************/
public final class SecuredDataExtension
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SecuredDataExtension()
{
}
/*******************************************************************************
** The secured-data UI API, version 1.0.0.
*******************************************************************************/
public static MiddlewareSpecExtension defineExtension()
{
return (new MiddlewareSpecExtension().withName("secured-data").withSemanticVersion("1.0.0")
.withDescription("Reveal grants and break-glass status")
.withSpec(SecuredDataRevealSpecV1.class).withSpec(SecuredDataRevealOptionsSpecV1.class).withSpec(SecuredDataStatusSpecV1.class));
}
/*******************************************************************************
** Called reflectively by the producer.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(defineExtension());
}
}
@@ -0,0 +1,84 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.uiapi;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.reveal.RevealService;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** GET secured-data/reveal/options.
*******************************************************************************/
public class SecuredDataRevealOptionsSpecV1 extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** Operation metadata.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath("/qbit/secured-data/reveal/options").withHttpMethod(HttpMethod.GET)
.withTag(() -> "Secured data").withShortSummary("RevealOptions"));
}
/*******************************************************************************
** Request query parameters.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(new Parameter().withName("tableName").withIn(com.kingsrook.qqq.openapi.model.In.QUERY).withRequired(true).withSchema(new Schema().withType(com.kingsrook.qqq.openapi.model.Type.STRING)),
new Parameter().withName("fieldName").withIn(com.kingsrook.qqq.openapi.model.In.QUERY).withRequired(true).withSchema(new Schema().withType(com.kingsrook.qqq.openapi.model.Type.STRING))));
}
/*******************************************************************************
** Published component schemas.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
Map<String, Schema> schemas = SecuredDataSchemas.all();
return (Map.of("SecuredDataRevealOptionsResponseV1", schemas.get("SecuredDataRevealOptionsResponseV1")));
}
/*******************************************************************************
** Response component.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("SecuredDataRevealOptionsResponseV1");
}
/*******************************************************************************
** Execute with the caller session supplied by the core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (new RevealService().options(input.getQueryParam("tableName"), input.getQueryParam("fieldName")));
}
}
@@ -0,0 +1,143 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.uiapi;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.reveal.RevealCappedException;
import com.kingsrook.qbits.secureddata.reveal.RevealService;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** POST secured-data/reveal.
*******************************************************************************/
public class SecuredDataRevealSpecV1 extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** Operation metadata.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath("/qbit/secured-data/reveal").withHttpMethod(HttpMethod.POST)
.withTag(() -> "Secured data").withShortSummary("Reveal"));
}
/*******************************************************************************
** Request query parameters.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of());
}
/*******************************************************************************
** Published component schemas.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
Map<String, Schema> schemas = SecuredDataSchemas.all();
return (Map.of("SecuredDataRevealRequestV1", schemas.get("SecuredDataRevealRequestV1"), "SecuredDataRevealResponseV1", schemas.get("SecuredDataRevealResponseV1"), "SecuredDataErrorV1", schemas.get("SecuredDataErrorV1")));
}
/*******************************************************************************
** Response component.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("SecuredDataRevealResponseV1");
}
/*******************************************************************************
** Standard error responses, including the daily-limit refusal.
*******************************************************************************/
@Override
public List<com.kingsrook.qqq.middleware.javalin.specs.BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.BAD_REQUEST, "Invalid reveal request", "SecuredDataErrorV1"),
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.FORBIDDEN, "Reveal denied", "SecuredDataErrorV1"),
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.NOT_FOUND, "Record not found or locked", "SecuredDataErrorV1"),
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.TOO_MANY_REQUESTS, "Daily reveal cap reached", "SecuredDataErrorV1")));
}
/*******************************************************************************
** Required JSON request.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("SecuredDataRevealRequestV1"));
}
/*******************************************************************************
** Reject wrong JSON types with the standard 400 response.
*******************************************************************************/
private String string(Map<String, Object> body, String key) throws QBadRequestException
{
Object value = body.get(key);
if(value != null && !(value instanceof String))
{
throw (new QBadRequestException(key + " must be a string."));
}
return ((String) value);
}
/*******************************************************************************
** Execute with the caller session supplied by the core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
Map<String, Object> body = input.getBody();
Object pk = body.get("primaryKey");
if(!(pk instanceof String || pk instanceof Number))
{
throw (new QBadRequestException("primaryKey must be a string or number."));
}
return (new RevealService().reveal(string(body, "tableName"), (Serializable) pk, string(body, "fieldName"),
string(body, "reason"), string(body, "reasonText")));
}
/*******************************************************************************
** Preserve core error bodies and use 429 for exhausted daily allowances.
*******************************************************************************/
@Override
protected void handleException(io.javalin.http.Context context, Exception e)
{
if(e instanceof RevealCappedException)
{
com.kingsrook.qqq.middleware.javalin.QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.Code.TOO_MANY_REQUESTS, e.getMessage());
}
else
{
super.handleException(context, e);
}
}
}
@@ -0,0 +1,85 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.uiapi;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Published shapes of service responses; nullable allowances mean unlimited.
*******************************************************************************/
public final class SecuredDataSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SecuredDataSchemas()
{
}
/*******************************************************************************
** Every request and response component carries the extension prefix and version.
*******************************************************************************/
public static Map<String, Schema> all()
{
Map<String, Schema> schemas = new LinkedHashMap<>();
schemas.put("SecuredDataRevealRequestV1", object("tableName", type(Type.STRING), "fieldName", type(Type.STRING),
"primaryKey", new Schema().withAnyOf(List.of(type(Type.STRING), type(Type.NUMBER))).withDescription("Record primary key (string or number)."),
"reason", type(Type.STRING).withNullable(true).withMaxLength(500), "reasonText", type(Type.STRING).withNullable(true).withMaxLength(500))
.withDescription("tableName, primaryKey and fieldName are required. Supply a policy reason or allowed free text; maximum 500 characters."));
schemas.put("SecuredDataRevealResponseV1", object("value", new Schema().withNullable(true).withDescription("Authorized raw field value."),
"displayValue", type(Type.STRING).withNullable(true), "expiresAt", type(Type.STRING).withFormat("date-time"), "revealId", type(Type.INTEGER)));
schemas.put("SecuredDataRevealOptionsResponseV1", object("reasonOptions", type(Type.ARRAY).withItems(type(Type.STRING)),
"allowFreeTextReason", type(Type.BOOLEAN), "requireFreeText", type(Type.BOOLEAN), "revealMinutes", type(Type.INTEGER),
"remainingDailyAllowance", type(Type.INTEGER).withNullable(true)));
schemas.put("SecuredDataBreakGlassV1", object("id", type(Type.INTEGER), "reason", type(Type.STRING), "expiresAt", type(Type.STRING).withFormat("date-time"),
"scope", type(Type.STRING).withNullable(true), "ceiling", type(Type.STRING)));
schemas.put("SecuredDataRevealAllowanceV1", object("tableName", type(Type.STRING), "fieldName", type(Type.STRING), "remainingDailyAllowance", type(Type.INTEGER).withNullable(true)));
schemas.put("SecuredDataStatusResponseV1", object("breakGlass", new Schema().withRefToSchema("SecuredDataBreakGlassV1").withNullable(true),
"revealsUsedToday", type(Type.INTEGER), "revealAllowances", type(Type.ARRAY).withItems(new Schema().withRefToSchema("SecuredDataRevealAllowanceV1"))));
schemas.put("SecuredDataErrorV1", object("error", type(Type.STRING)));
return (schemas);
}
/*******************************************************************************
** Object properties in stable order.
*******************************************************************************/
private static Schema object(Object... pairs)
{
Map<String, Schema> properties = new LinkedHashMap<>();
for(int i = 0; i < pairs.length; i += 2)
{
properties.put((String) pairs[i], (Schema) pairs[i + 1]);
}
return (type(Type.OBJECT).withProperties(properties));
}
/*******************************************************************************
** Typed schema.
*******************************************************************************/
private static Schema type(Type type)
{
return (new Schema().withType(type));
}
}
@@ -0,0 +1,83 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.uiapi;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.StatusService;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** GET secured-data/status.
*******************************************************************************/
public class SecuredDataStatusSpecV1 extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** Operation metadata.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath("/qbit/secured-data/status").withHttpMethod(HttpMethod.GET)
.withTag(() -> "Secured data").withShortSummary("Status"));
}
/*******************************************************************************
** Request query parameters.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of());
}
/*******************************************************************************
** Published component schemas.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
Map<String, Schema> schemas = SecuredDataSchemas.all();
return (Map.of("SecuredDataStatusResponseV1", schemas.get("SecuredDataStatusResponseV1"), "SecuredDataBreakGlassV1", schemas.get("SecuredDataBreakGlassV1"), "SecuredDataRevealAllowanceV1", schemas.get("SecuredDataRevealAllowanceV1")));
}
/*******************************************************************************
** Response component.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("SecuredDataStatusResponseV1");
}
/*******************************************************************************
** Execute with the caller session supplied by the core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (new StatusService().status());
}
}
@@ -43,6 +43,7 @@ public class SecuredDataQBitConfig implements QBitConfig
{
private static final byte[] JVM_KEY = new SecureRandom().generateSeed(32);
private Map<String, String> defaultTierByClassification = new HashMap<>(Map.of("CONFIDENTIAL", "FULL", "PII", "MASKED", "PHI", "MASKED"));
private String schedulerName;
private String backendName = "memory";
private Clock clock = Clock.systemUTC();
private int accessLogQueueCapacity = 4096;
@@ -58,6 +59,23 @@ public class SecuredDataQBitConfig implements QBitConfig
/*******************************************************************************
** Scheduler to run the once-per-minute break-glass expiry job.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Configure the host scheduler; null leaves manual scheduling to the host.
*******************************************************************************/
public SecuredDataQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Get backendName.
*******************************************************************************/
@@ -156,10 +156,12 @@ public class SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredData
}
SecuredDataTables.MAIN.forEach(name -> app.withChild(tables.get(name)));
output.add(app);
com.kingsrook.qbits.secureddata.processes.BreakGlassProcesses.processes(qBitConfig).forEach(output::add);
output.add(SecuredDataTables.revealsJoin());
output.add(SecuredDataTables.revealsWidget());
output.addSelfToInstance(qInstance);
CoreMetadataBridge.register(qInstance);
com.kingsrook.qbits.secureddata.support.UiApiBridge.register(qInstance);
if(qBitConfig.getNameHeuristics())
{
seedHeuristics(qInstance);
@@ -66,6 +66,15 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
private final SecuredDataRuntime runtime;
private final byte[] fingerprintKey;
private final ThreadLocal<Boolean> resolving = ThreadLocal.withInitial(() -> false);
private final Map<String, RevealCache> reveals = new HashMap<>();
/*******************************************************************************
** Bounded per-user grant cache, with exact read-time expiry.
*******************************************************************************/
private record RevealCache(Instant until, List<QRecord> rows)
{
}
private long cachedVersion;
private List<QRecord> policies = List.of();
private final Map<String, UserState> users = new HashMap<>();
@@ -267,6 +276,17 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
/*******************************************************************************
** Granting rows only: channel rows cap tiers and never grant reveal settings.
*******************************************************************************/
public List<QRecord> revealPolicies(QTableMetaData table, String fieldName)
{
return (matching(table, SecuredDataClassifications.effective(table, fieldName)).stream()
.filter(row -> blank(row.getValueString("client_channel")) && "REVEALABLE".equals(row.getValueString("tier"))).toList());
}
/*******************************************************************************
** Refresh policy rows once per version and matched state per relevant permissions.
*******************************************************************************/
@@ -312,7 +332,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
/*******************************************************************************
** Exact expiry is checked at use time, independent of the future expiry job.
*******************************************************************************/
private List<QRecord> activeBreakGlass()
public List<QRecord> activeBreakGlass()
{
Instant now = runtime.config().getClock().instant();
return (state().breakGlass().stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
@@ -382,12 +402,39 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
@Override
public boolean isRevealActive(String tableName, String fieldName, Serializable primaryKey)
{
return (SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter()
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, userId()))
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, tableName))
.withCriteria(new QFilterCriteria("field_name", QCriteriaOperator.EQUALS, fieldName))
.withCriteria(new QFilterCriteria("record_id", QCriteriaOperator.EQUALS, String.valueOf(primaryKey)))
.withCriteria(new QFilterCriteria("expires_at", QCriteriaOperator.GREATER_THAN, runtime.config().getClock().instant()))).size() > 0);
synchronized(reveals)
{
String user = userId();
Instant now = runtime.config().getClock().instant();
RevealCache cached = reveals.get(user);
if(cached == null || !cached.until().isAfter(now))
{
if(reveals.size() >= 1024)
{
reveals.clear();
}
cached = new RevealCache(now.plusSeconds(5), SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter()
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, user))
.withCriteria(new QFilterCriteria("expires_at", QCriteriaOperator.GREATER_THAN, now))));
reveals.put(user, cached);
}
return (cached.rows().stream().anyMatch(row -> tableName.equals(row.getValueString("table_name"))
&& fieldName.equals(row.getValueString("field_name")) && String.valueOf(primaryKey).equals(row.getValueString("record_id"))
&& row.getValueInstant("expires_at").isAfter(now)));
}
}
/*******************************************************************************
** Publish a newly created grant to all sessions on this instance immediately.
*******************************************************************************/
public void invalidateReveals()
{
synchronized(reveals)
{
reveals.clear();
}
}
@@ -0,0 +1,90 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.secureddata.reveal.RevealOptions;
import com.kingsrook.qbits.secureddata.reveal.RevealService;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Caller-only banner and per-field reveal allowance summary.
*******************************************************************************/
public class StatusService
{
/*******************************************************************************
** Active session projection.
*******************************************************************************/
public record BreakGlassStatus(Serializable id, String reason, Instant expiresAt, String scope, String ceiling)
{
}
/*******************************************************************************
** Allowances differ by granting policy; null remaining means unlimited.
*******************************************************************************/
public record Allowance(String tableName, String fieldName, Integer remainingDailyAllowance)
{
}
/*******************************************************************************
** Status response.
*******************************************************************************/
public record Status(BreakGlassStatus breakGlass, int revealsUsedToday, List<Allowance> revealAllowances)
{
}
/*******************************************************************************
** Only readable revealable fields are included, without any record values.
*******************************************************************************/
public Status status() throws QException
{
List<QRecord> active = SecuredDataRuntime.current().resolver().activeBreakGlass();
QRecord row = active.isEmpty() ? null : active.getFirst();
BreakGlassStatus session = row == null ? null : new BreakGlassStatus(row.getValue("id"), row.getValueString("reason"), row.getValueInstant("expires_at"),
row.getValueString("table_name"), row.getValueString("classification_ceiling"));
RevealService service = new RevealService();
List<Allowance> allowances = new ArrayList<>();
for(QTableMetaData table : QContext.getQInstance().getTables().values())
{
for(QFieldMetaData field : table.getFields().values())
{
try
{
RevealOptions options = service.options(table.getName(), field.getName());
allowances.add(new Allowance(table.getName(), field.getName(), options.remainingDailyAllowance()));
}
catch(QPermissionDeniedException expected)
{
// This field offers no reveal operation to the caller.
}
}
}
return (new Status(session, service.usedToday(), List.copyOf(allowances)));
}
}
@@ -0,0 +1,243 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.breakglass;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.secureddata.SecuredDataResolver;
import com.kingsrook.qbits.secureddata.SecuredDataRuntime;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
import com.kingsrook.qbits.secureddata.standin.DataClassification;
import com.kingsrook.qbits.secureddata.support.ActivityLog;
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Explicit, audited emergency access. No implicit admin access to user data.
*******************************************************************************/
public class BreakGlassService
{
public static final String START = "secured_data_break_glass_start";
public static final String END = "secured_data_break_glass_end";
public static final String EXPIRE = "secured_data_break_glass_expire";
private static final QLogger LOG = QLogger.getLogger(BreakGlassService.class);
/*******************************************************************************
** Validate first, then create a system-owned session and publish its policy version.
*******************************************************************************/
public QRecord start(String reason, Integer minutes, String table, DataClassification ceiling) throws QException
{
checkChannel();
if(!QContext.getQSession().hasPermission(START + ".hasAccess"))
{
throw (new QPermissionDeniedException("Permission denied."));
}
int duration = minutes == null ? 60 : minutes;
if(reason == null || reason.trim().length() < 20 || reason.length() > 500 || duration < 1 || duration > 480)
{
throw (new QBadRequestException("Reason must contain 20-500 characters; duration must be 1-480 minutes."));
}
if(table != null && table.isBlank())
{
table = null;
}
if(table != null && QContext.getQInstance().getTable(table) == null)
{
throw (new QBadRequestException("Unknown table scope."));
}
SecuredDataRuntime runtime = SecuredDataRuntime.current();
synchronized(runtime)
{
if(!runtime.resolver().activeBreakGlass().isEmpty())
{
throw (new QBadRequestException("An active break-glass session already exists."));
}
Instant now = runtime.config().getClock().instant();
QRecord row = SystemWrites.insertOne(SecuredDataTables.BREAK_GLASS, new QRecord()
.withValue("user_id", SecuredDataResolver.userId()).withValue("user_name", QContext.getQSession().getUser().getFullName())
.withValue("reason", reason).withValue("table_name", table).withValue("classification_ceiling", Objects.requireNonNullElse(ceiling, DataClassification.PHI).name())
.withValue("started_at", now).withValue("expires_at", now.plusSeconds(duration * 60L)).withValue("status", "ACTIVE"));
runtime.invalidatePolicies();
log(row, "BREAK_GLASS_START");
QAlerts.raise(new AlertSignal("secured_data.break_glass").withSeverity(AlertSeverity.fromLabel("HIGH", AlertSeverity.ERROR)).withDedupKey(dedup(row))
.withTitle("Break-glass access started").withSummary("Review the secured-data break-glass session.").withOccurredAt(now));
notifyAdmins(row);
return (row);
}
}
/*******************************************************************************
** Owner or admin may end an active session, never an assistant channel.
*******************************************************************************/
public QRecord end(Serializable id) throws QException
{
checkChannel();
synchronized(SecuredDataRuntime.current())
{
QRecord row = find(id);
checkOwner(row);
if(!"ACTIVE".equals(row.getValueString("status")))
{
throw (new QBadRequestException("Session is already finished."));
}
return (finish(row, "ENDED", "BREAK_GLASS_END"));
}
}
/*******************************************************************************
** Review uses the same ownership check; load rechecks against persisted state.
*******************************************************************************/
public void checkOwner(QRecord row) throws QPermissionDeniedException
{
checkChannel();
if(!Objects.equals(SecuredDataResolver.userId(), row.getValueString("user_id"))
&& !QContext.getQSession().hasPermission(SecuredDataPermissions.ADMIN))
{
throw (new QPermissionDeniedException("Only the owner or a secured-data administrator can end this session."));
}
}
/*******************************************************************************
** All rows are looked up as system; authorization remains the caller responsibility.
*******************************************************************************/
public QRecord find(Serializable id) throws QNotFoundException
{
List<QRecord> rows = SystemWrites.query(SecuredDataTables.BREAK_GLASS, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)));
if(rows.isEmpty())
{
throw (new QNotFoundException("No record was found"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Scheduled maintenance always performs its lookups and changes as system.
*******************************************************************************/
public int expire() throws QException
{
synchronized(SecuredDataRuntime.current())
{
return (SystemWrites.call(() ->
{
List<QRecord> rows = SystemWrites.query(SecuredDataTables.BREAK_GLASS, new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"))
.withCriteria(new QFilterCriteria("expires_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, SecuredDataRuntime.current().config().getClock().instant())));
for(QRecord row : rows)
{
finish(row, "EXPIRED", "BREAK_GLASS_EXPIRED");
}
return (rows.size());
}));
}
}
/*******************************************************************************
** Update, audit and resolve using one stable per-session alert key.
*******************************************************************************/
private QRecord finish(QRecord row, String status, String event) throws QException
{
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", status)
.withValue("ended_at", SecuredDataRuntime.current().config().getClock().instant()).withValue("ended_by", SecuredDataResolver.userId());
QRecord result = SystemWrites.call(() -> new UpdateAction().execute(new UpdateInput(SecuredDataTables.BREAK_GLASS).withRecord(update)).getRecords().getFirst());
if(result.getErrors() != null && !result.getErrors().isEmpty())
{
throw (new QException("Unable to finish break-glass session."));
}
SecuredDataRuntime.current().invalidatePolicies();
log(row, event);
QAlerts.resolve("secured_data.break_glass", dedup(row), status);
return (result);
}
/*******************************************************************************
** Audit the actor and affected session without copying protected field values.
*******************************************************************************/
private void log(QRecord row, String event) throws QException
{
SystemWrites.insertOne(SecuredDataTables.ACCESS_LOG, ActivityLog.row(event, SecuredDataTables.BREAK_GLASS, "ALLOWED")
.withValue("break_glass_id", row.getValue("id")).withValue("record_id", row.getValueString("id")).withValue("reason", row.getValue("reason")));
}
/*******************************************************************************
** Stable alert identity.
*******************************************************************************/
private String dedup(QRecord row)
{
return ("secured_data.break_glass:" + row.getValueString("id"));
}
/*******************************************************************************
** RC.5 lacks #1125; use the notify SPI when installed without a qbit-alerts dependency.
*******************************************************************************/
private void notifyAdmins(QRecord row)
{
try
{
Class<?> signalClass = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationSignal");
Class<?> audienceClass = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationAudience");
Object signal = signalClass.getConstructor(String.class).newInstance("Break-glass access started");
signalClass.getMethod("withAudience", audienceClass).invoke(signal, audienceClass.getMethod("permission", String.class).invoke(null, SecuredDataPermissions.ADMIN));
signalClass.getMethod("withSourceName", String.class).invoke(signal, "secured_data");
signalClass.getMethod("withSourceKey", String.class).invoke(signal, dedup(row));
QAlerts.class.getMethod("notify", signalClass).invoke(null, signal);
}
catch(ClassNotFoundException | NoSuchMethodException expected)
{
// TODO(core #1125): remove reflection at RC.6 and use the notification SPI directly.
LOG.warn("Break-glass admin bell notification unavailable: core notification SPI #1125 is not installed.");
}
catch(ReflectiveOperationException e)
{
LOG.warn("Unable to deliver break-glass admin bell notification.");
}
}
/*******************************************************************************
** Deny unauthenticated and assistant invocations in direct service calls too.
*******************************************************************************/
private void checkChannel() throws QPermissionDeniedException
{
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null
|| "anonymous".equals(SecuredDataResolver.userId()) || SecuredDataResolver.userId().isBlank()
|| "assistant".equals(CoreMetadataBridge.channel(QContext.getQSession())))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
}
@@ -58,9 +58,21 @@ public class FieldsOfTablePossibleValueSource implements QCustomPossibleValuePro
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
{
Object tableName = input.getOtherValues() == null ? null : input.getOtherValues().get("table_name");
if(tableName == null && input.getOtherValues() != null)
{
tableName = input.getOtherValues().get("tableName");
}
List<QPossibleValue<String>> values = new ArrayList<>();
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName.toString());
if(table != null && PermissionsHelper.hasTablePermission(new GetInput(table.getName()), table.getName(), TablePermissionSubType.READ))
if(tableName == null || tableName.toString().isBlank())
{
return (values);
}
QTableMetaData table = QContext.getQInstance().getTable(tableName.toString());
if(table == null)
{
throw (new QException("Unknown table."));
}
if(PermissionsHelper.hasTablePermission(new GetInput(table.getName()), table.getName(), TablePermissionSubType.READ))
{
table.getFields().values().forEach(field -> values.add(new QPossibleValue<>(field.getName(), field.getLabel())));
}
@@ -120,12 +120,7 @@ public final class SecuredDataTables
private static QFieldMetaData field(String name, QFieldType type)
{
QFieldMetaData field = new QFieldMetaData(name, type).withBackendName(name).withLabel(name.replace('_', ' '));
if("field_name".equals(name))
{
field.withPossibleValueSourceFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()
.withCriteria(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("table_name",
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "${input.table_name}")));
}
// TODO(contingent): dependsOn("table_name") once the core RC has it
return (field);
}
@@ -199,7 +194,7 @@ public final class SecuredDataTables
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("tables"))
.withField(field("record_id", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
.withField(field("field_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_fields"))
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
.withField(field("reason", QFieldType.STRING).withMaxLength(500))
.withField(field("reason_text", QFieldType.TEXT))
.withField(field("granted_at", QFieldType.DATE_TIME).withIsRequired(true))
.withField(field("expires_at", QFieldType.DATE_TIME).withIsRequired(true))
@@ -220,7 +215,7 @@ public final class SecuredDataTables
QTableMetaData table = base(config, BREAK_GLASS, "Break-glass session")
.withField(field("user_id", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
.withField(field("user_name", QFieldType.STRING).withMaxLength(255))
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
.withField(field("reason", QFieldType.STRING).withMaxLength(500))
.withField(field("reason_text", QFieldType.TEXT))
.withField(field("classification_ceiling", QFieldType.STRING).withMaxLength(255).withDefaultValue("PHI").withPossibleValueSourceName("secured_data_classification"))
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("tables"))
@@ -251,7 +246,7 @@ public final class SecuredDataTables
.withField(field("field_name", QFieldType.TEXT))
.withField(field("record_count", QFieldType.INTEGER))
.withField(field("action_name", QFieldType.STRING).withMaxLength(255))
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
.withField(field("reason", QFieldType.STRING).withMaxLength(500))
.withField(field("reason_text", QFieldType.TEXT))
.withField(field("client_channel", QFieldType.STRING).withMaxLength(255))
.withField(field("outcome", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("secured_data_outcome"))
@@ -0,0 +1,233 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.SecuredDataQBitConfig;
import com.kingsrook.qbits.secureddata.breakglass.BreakGlassService;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.standin.DataClassification;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Table/record actions and the expiry schedule. Never added to navigation.
*******************************************************************************/
public final class BreakGlassProcesses
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BreakGlassProcesses()
{
}
/*******************************************************************************
** A start form, a streamed Review/Result end action, and a hidden scheduled job.
*******************************************************************************/
public static List<QProcessMetaData> processes(SecuredDataQBitConfig config)
{
QProcessMetaData start = new QProcessMetaData().withName(BreakGlassService.START).withLabel("Start break-glass")
.withTableName(SecuredDataTables.BREAK_GLASS).withMinInputRecords(0).withMaxInputRecords(0)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
.withStep(new QFrontendStepMetaData().withName("input").withLabel("Start break-glass")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("reason", QFieldType.STRING).withLabel("Reason").withIsRequired(true).withMaxLength(500))
.withFormField(new QFieldMetaData("duration_minutes", QFieldType.INTEGER).withLabel("Duration minutes").withDefaultValue(60))
.withFormField(new QFieldMetaData("table_name", QFieldType.STRING).withLabel("Table scope").withPossibleValueSourceName("tables"))
.withFormField(new QFieldMetaData("classification_ceiling", QFieldType.STRING).withLabel("Classification ceiling")
.withDefaultValue("PHI").withPossibleValueSourceName("secured_data_classification")))
.withStep(new QBackendStepMetaData().withName("start").withCode(new QCodeReference(Start.class)))
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
.withViewField(new QFieldMetaData("message", QFieldType.STRING)));
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, SecuredDataTables.BREAK_GLASS);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, SecuredDataTables.BREAK_GLASS);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "The selected break-glass sessions will be ended.");
QProcessMetaData end = StreamedETLWithFrontendProcess.defineProcessMetaData(EndExtract.class, EndReview.class, EndLoad.class, values)
.withName(BreakGlassService.END).withLabel("End break-glass").withTableName(SecuredDataTables.BREAK_GLASS).withMinInputRecords(1)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED));
QProcessMetaData expire = new QProcessMetaData().withName(BreakGlassService.EXPIRE).withLabel("Expire break-glass sessions").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
.withStep(new QBackendStepMetaData().withName("expire").withCode(new QCodeReference(Expire.class)));
if(config.getSchedulerName() != null)
{
expire.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(30));
}
return (List.of(start, end, expire));
}
/*******************************************************************************
** Start after the form is submitted.
*******************************************************************************/
public static class Start implements BackendStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String ceiling = input.getValueString("classification_ceiling");
DataClassification classification;
try
{
classification = ceiling == null ? null : DataClassification.valueOf(ceiling);
}
catch(IllegalArgumentException e)
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Unknown classification ceiling."));
}
new BreakGlassService().start(input.getValueString("reason"), input.getValueInteger("duration_minutes"), input.getValueString("table_name"), classification);
output.addValue("message", "Break-glass access is active.");
}
}
/*******************************************************************************
** Extract with system visibility; ownership is checked before any row enters the pipe.
*******************************************************************************/
public static class EndExtract extends ExtractViaQueryStep
{
@Override
public Integer doCount(RunBackendStepInput input)
{
return (null);
}
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : SystemWrites.query(SecuredDataTables.BREAK_GLASS, getQueryFilter(input)))
{
new BreakGlassService().checkOwner(row);
getRecordPipe().addRecord(row);
}
}
}
/*******************************************************************************
** Review validates ownership and reports the change in future and past tense.
*******************************************************************************/
public static class EndReview extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine line = summary();
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
new BreakGlassService().checkOwner(row);
if("ACTIVE".equals(row.getValueString("status")))
{
line.incrementCountAndAddPrimaryKey(row.getValue("id"));
output.getRecords().add(row);
}
}
}
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
line.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Apply the reviewed operation, rechecking persisted ownership at execution time.
*******************************************************************************/
public static class EndLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine line = summary();
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
output.getRecords().add(new BreakGlassService().end(row.getValue("id")));
line.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
}
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
line.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Standard ETL summary wording.
*******************************************************************************/
private static ProcessSummaryLine summary()
{
ProcessSummaryLine line = new ProcessSummaryLine(Status.OK, 0, null);
line.setSingularFutureMessage("session will be ended");
line.setPluralFutureMessage("sessions will be ended");
line.setSingularPastMessage("session was ended");
line.setPluralPastMessage("sessions were ended");
return (line);
}
/*******************************************************************************
** Expiry job; system context and writes are established by the service.
*******************************************************************************/
public static class Expire implements BackendStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("expired", new BreakGlassService().expire());
}
}
}
@@ -0,0 +1,37 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.reveal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Transport-independent refusal, mapped to 429 by the UI API.
*******************************************************************************/
public class RevealCappedException extends QException
{
/*******************************************************************************
** Daily allowance exhausted.
*******************************************************************************/
public RevealCappedException()
{
super("Daily reveal allowance exhausted.");
}
}
@@ -0,0 +1,31 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.reveal;
import java.util.List;
/*******************************************************************************
** Policy-driven reveal dialog. Null remainingDailyAllowance means unlimited.
*******************************************************************************/
public record RevealOptions(List<String> reasonOptions, boolean allowFreeTextReason, boolean requireFreeText,
int revealMinutes, Integer remainingDailyAllowance)
{
}
@@ -0,0 +1,31 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.reveal;
import java.io.Serializable;
import java.time.Instant;
/*******************************************************************************
** The authorized value and its grant; never persist this response in an audit row.
*******************************************************************************/
public record RevealResult(Serializable value, String displayValue, Instant expiresAt, Serializable revealId)
{
}
@@ -0,0 +1,215 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.reveal;
import java.io.Serializable;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.secureddata.SecuredDataResolver;
import com.kingsrook.qbits.secureddata.SecuredDataRuntime;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
import com.kingsrook.qbits.secureddata.support.ActivityLog;
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Reveal authorization and grant creation independent of any HTTP transport.
*******************************************************************************/
public class RevealService
{
/*******************************************************************************
** Use the current instance runtime, including its injectable clock.
*******************************************************************************/
public RevealOptions options(String tableName, String fieldName) throws QException
{
requireActor();
QRecord policy = policy(tableName, fieldName);
String reasons = policy.getValueString("reason_options");
Integer cap = policy.getValueInteger("daily_reveal_cap");
return (new RevealOptions(reasons == null ? List.of() : Arrays.stream(reasons.split("\\R")).map(String::trim).filter(s -> !s.isEmpty()).toList(),
!Boolean.FALSE.equals(policy.getValueBoolean("allow_free_text_reason")), Boolean.TRUE.equals(policy.getValueBoolean("require_free_text")),
Objects.requireNonNullElse(policy.getValueInteger("reveal_minutes"), 5), cap == null ? null : Math.max(0, cap - usedToday())));
}
/*******************************************************************************
** A single coherent policy: unlimited/highest cap, then longest duration, then id.
*******************************************************************************/
private QRecord policy(String tableName, String fieldName) throws QException
{
requireTarget(tableName, fieldName);
if("assistant".equals(CoreMetadataBridge.channel(QContext.getQSession())))
{
throw (new QPermissionDeniedException("Reveal is unavailable on the assistant channel."));
}
QTableMetaData table = QContext.getQInstance().getTable(tableName);
if(table == null || !table.getFields().containsKey(fieldName))
{
throw (new QNotFoundException("Table or field not found."));
}
GetInput input = new GetInput(tableName);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
CorePermissionEnforcer.checkTablePermissions(input, TablePermissionSubType.READ);
if(SecuredDataRuntime.current().resolver().resolve(table, table.getField(fieldName)).tier() != FieldAccessTier.REVEALABLE)
{
throw (new QPermissionDeniedException("Field is not revealable."));
}
return (SecuredDataRuntime.current().resolver().revealPolicies(table, fieldName).stream()
.max(Comparator.comparingInt((QRecord row) -> Objects.requireNonNullElse(row.getValueInteger("daily_reveal_cap"), Integer.MAX_VALUE))
.thenComparingInt(row -> Objects.requireNonNullElse(row.getValueInteger("reveal_minutes"), 5))
.thenComparing(row -> row.getValueString("id"), Comparator.reverseOrder()))
.orElse(new QRecord().withValue("reveal_minutes", 5).withValue("allow_free_text_reason", true)));
}
/*******************************************************************************
** UTC daily count across all tables, including expired grants.
*******************************************************************************/
public int usedToday()
{
Instant start = SecuredDataRuntime.current().config().getClock().instant().atOffset(ZoneOffset.UTC).toLocalDate().atStartOfDay().toInstant(ZoneOffset.UTC);
return (SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter()
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, SecuredDataResolver.userId()))
.withCriteria(new QFilterCriteria("granted_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, start))).size());
}
/*******************************************************************************
** Serialize the cap check and write within this runtime; first fetch always as caller.
*******************************************************************************/
public RevealResult reveal(String tableName, Serializable primaryKey, String fieldName, String reason, String reasonText) throws QException
{
requireActor();
SecuredDataRuntime runtime = SecuredDataRuntime.current();
synchronized(runtime)
{
if("assistant".equals(CoreMetadataBridge.channel(QContext.getQSession())))
{
denied(tableName, primaryKey, fieldName, "DENIED");
throw (new QPermissionDeniedException("Reveal is unavailable on the assistant channel."));
}
if(primaryKey == null)
{
throw (new QBadRequestException("primaryKey is required."));
}
requireTarget(tableName, fieldName);
GetInput input = new GetInput(tableName).withPrimaryKey(primaryKey).withShouldFetchHeavyFields(true);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
if(new GetAction().execute(input).getRecord() == null)
{
throw (new QNotFoundException("No record was found"));
}
RevealOptions options = options(tableName, fieldName);
validateReason(options, reason, reasonText);
if(options.remainingDailyAllowance() != null && options.remainingDailyAllowance() == 0)
{
denied(tableName, primaryKey, fieldName, "CAPPED");
QAlerts.raise(new AlertSignal("secured_data.reveal_capped").withDedupKey("secured_data.reveal_capped:" + SecuredDataResolver.userId())
.withTitle("Daily reveal allowance exhausted").withSummary("Review the secured-data access log."));
throw (new RevealCappedException());
}
QRecord raw = SystemWrites.call(() -> new GetAction().execute(new GetInput(tableName).withPrimaryKey(primaryKey)
.withShouldFetchHeavyFields(true).withShouldTranslatePossibleValues(true)).getRecord());
if(raw == null)
{
throw (new QNotFoundException("No record was found"));
}
com.kingsrook.qqq.backend.core.actions.values.QValueFormatter.setDisplayValuesInRecords(QContext.getQInstance().getTable(tableName), List.of(raw));
Instant now = runtime.config().getClock().instant();
Instant expiry = now.plusSeconds(options.revealMinutes() * 60L);
QRecord audit = SystemWrites.insertOne(SecuredDataTables.ACCESS_LOG, ActivityLog.row("REVEAL", tableName, "ALLOWED")
.withValue("record_id", String.valueOf(primaryKey)).withValue("field_name", fieldName).withValue("reason", reason).withValue("reason_text", reasonText));
QRecord grant = SystemWrites.insertOne(SecuredDataTables.REVEAL_GRANT, new QRecord()
.withValue("user_id", SecuredDataResolver.userId()).withValue("user_name", QContext.getQSession().getUser().getFullName())
.withValue("table_name", tableName).withValue("field_name", fieldName).withValue("record_id", String.valueOf(primaryKey))
.withValue("reason", reason).withValue("reason_text", reasonText).withValue("granted_at", now).withValue("expires_at", expiry)
.withValue("access_log_id", audit.getValue("id")));
runtime.resolver().invalidateReveals();
return (new RevealResult(raw.getValue(fieldName), raw.getDisplayValue(fieldName), expiry, grant.getValue("id")));
}
}
/*******************************************************************************
** Grants require a stable authenticated user identity across sessions.
*******************************************************************************/
private void requireActor() throws QPermissionDeniedException
{
if("anonymous".equals(SecuredDataResolver.userId()) || SecuredDataResolver.userId().isBlank())
{
throw (new QPermissionDeniedException("Reveal requires an authenticated user."));
}
}
/*******************************************************************************
** Missing target identifiers are malformed input, not an internal lookup error.
*******************************************************************************/
private void requireTarget(String table, String field) throws QBadRequestException
{
if(table == null || table.isBlank() || field == null || field.isBlank())
{
throw (new QBadRequestException("tableName and fieldName are required."));
}
}
/*******************************************************************************
** Persist refusals without user supplied text or protected values.
*******************************************************************************/
private void denied(String table, Serializable pk, String field, String outcome) throws QException
{
SystemWrites.insertOne(SecuredDataTables.ACCESS_LOG, ActivityLog.row("REVEAL_DENIED", table, outcome)
.withValue("record_id", String.valueOf(pk)).withValue("field_name", field));
}
/*******************************************************************************
** Reasons are a listed option or allowed free text; both inputs are bounded.
*******************************************************************************/
private void validateReason(RevealOptions options, String reason, String text) throws QBadRequestException
{
boolean hasText = text != null && !text.isBlank();
boolean listed = reason != null && options.reasonOptions().contains(reason);
if((reason != null && reason.length() > 500) || (text != null && text.length() > 500)
|| (options.requireFreeText() && !hasText)
|| (!options.allowFreeTextReason() && hasText && !options.requireFreeText())
|| (!listed && !(options.allowFreeTextReason() && (hasText || (reason != null && !reason.isBlank())))))
{
throw (new QBadRequestException("Supply an allowed reason and required explanation, each at most 500 characters."));
}
}
}
@@ -0,0 +1,52 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.support;
import com.kingsrook.qbits.secureddata.SecuredDataResolver;
import com.kingsrook.qbits.secureddata.SecuredDataRuntime;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Synchronous activity audit rows for state-changing operations.
*******************************************************************************/
public final class ActivityLog
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ActivityLog()
{
}
/*******************************************************************************
** Capture the actor before entering the system session. No field values.
*******************************************************************************/
public static QRecord row(String event, String table, String outcome)
{
return (new QRecord().withValue("event_type", event).withValue("table_name", table)
.withValue("user_id", SecuredDataResolver.userId())
.withValue("user_name", QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getFullName())
.withValue("client_channel", CoreMetadataBridge.channel(QContext.getQSession()))
.withValue("created_at", SecuredDataRuntime.current().config().getClock().instant())
.withValue("outcome", outcome).withValue("anomaly_flag", false));
}
}
@@ -0,0 +1,56 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata.support;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Optional UI API registration, with no linkage to unreleased middleware on RC.5.
*******************************************************************************/
public final class UiApiBridge
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private UiApiBridge()
{
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1128, removeBy=RC.6): register directly at RC.6.
*******************************************************************************/
public static void register(QInstance instance)
{
try
{
Class.forName("com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData");
Class.forName("com.kingsrook.qbits.secureddata.uiapi.SecuredDataExtension").getMethod("register", QInstance.class).invoke(null, instance);
}
catch(ClassNotFoundException expected)
{
// RC.5 or a build without the uiapi source profile.
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("Unable to register secured-data UI API extension", e));
}
}
}
@@ -0,0 +1,195 @@
components:
schemas:
SecuredDataBreakGlassV1:
properties:
id:
type: "integer"
reason:
type: "string"
expiresAt:
format: "date-time"
type: "string"
scope:
nullable: true
type: "string"
ceiling:
type: "string"
type: "object"
SecuredDataErrorV1:
properties:
error:
type: "string"
type: "object"
SecuredDataRevealAllowanceV1:
properties:
tableName:
type: "string"
fieldName:
type: "string"
remainingDailyAllowance:
nullable: true
type: "integer"
type: "object"
SecuredDataRevealOptionsResponseV1:
properties:
reasonOptions:
items:
type: "string"
type: "array"
allowFreeTextReason:
type: "boolean"
requireFreeText:
type: "boolean"
revealMinutes:
type: "integer"
remainingDailyAllowance:
nullable: true
type: "integer"
type: "object"
SecuredDataRevealRequestV1:
description: "tableName, primaryKey and fieldName are required. Supply a policy\
\ reason or allowed free text; maximum 500 characters."
properties:
tableName:
type: "string"
fieldName:
type: "string"
primaryKey:
anyOf:
- type: "string"
- type: "number"
description: "Record primary key (string or number)."
reason:
maxLength: 500
nullable: true
type: "string"
reasonText:
maxLength: 500
nullable: true
type: "string"
type: "object"
SecuredDataRevealResponseV1:
properties:
value:
description: "Authorized raw field value."
nullable: true
displayValue:
nullable: true
type: "string"
expiresAt:
format: "date-time"
type: "string"
revealId:
type: "integer"
type: "object"
SecuredDataStatusResponseV1:
properties:
breakGlass:
$ref: "#/components/schemas/SecuredDataBreakGlassV1"
nullable: true
revealsUsedToday:
type: "integer"
revealAllowances:
items:
$ref: "#/components/schemas/SecuredDataRevealAllowanceV1"
type: "array"
type: "object"
securitySchemes:
sessionUuidCookie:
in: "cookie"
name: "sessionUUID"
type: "apiKey"
info:
contact:
email: "contact@kingsrook.com"
description: "Reveal grants and break-glass status"
title: "QQQ Middleware API extension: secured-data"
version: "1.0.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/secured-data/reveal:
post:
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataRevealRequestV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataErrorV1"
description: "Invalid reveal request"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataErrorV1"
description: "Reveal denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataErrorV1"
description: "Record not found or locked"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataRevealResponseV1"
description: "Successful Response"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataErrorV1"
description: "Daily reveal cap reached"
security:
- sessionUuidCookie:
- "N/A"
summary: "Reveal"
tags:
- "Secured data"
/qqq/v1/qbit/secured-data/reveal/options:
get:
parameters:
- in: "query"
name: "tableName"
required: true
schema:
type: "string"
- in: "query"
name: "fieldName"
required: true
schema:
type: "string"
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataRevealOptionsResponseV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "RevealOptions"
tags:
- "Secured data"
/qqq/v1/qbit/secured-data/status:
get:
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/SecuredDataStatusResponseV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Status"
tags:
- "Secured data"
@@ -0,0 +1,147 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.breakglass.BreakGlassService;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.*;
import static org.assertj.core.api.Assertions.*;
class SecuredDataHttpTest
{
QApplicationJavalinServer server;
int port;
@AfterEach
void cleanup()
{
if(server != null) server.stop();
QJavalinImplementation.setSessionCookieHttpOnly(false);
close();
}
public static class Sessions implements QAuthenticationModuleCustomizerInterface
{
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
session.setPermissions(java.util.Set.of("person.read", "cs_admin"));
}
}
void start() throws Exception
{
QInstance instance = setup();
instance.getAuthentication().setType(QAuthenticationType.MOCK);
instance.getAuthentication().setCustomizer(new QCodeReference(Sessions.class));
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("daily_reveal_cap", 1));
insert("person", new QRecord().withValue("id", 1).withValue("email", "HTTP-CANARY@example.invalid"));
server = new QApplicationJavalinServer(new AbstractQQQApplication()
{
public QInstance defineQInstance() { return instance; }
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false).withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true);
server.start();
var serviceField = QApplicationJavalinServer.class.getDeclaredField("service");
serviceField.setAccessible(true);
port = ((io.javalin.Javalin) serviceField.get(server)).port();
}
HttpResponse<String> request(String path, String body, boolean assistant) throws Exception
{
var builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/qqq/v1" + path)).header("Cookie", "sessionUUID=phase-c-user");
if(assistant) builder.header("X-QQQ-Client", "assistant");
if(body != null) builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body));
return HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
@Test
void optionsRevealStatusAndCapThroughRealMiddleware() throws Exception
{
start();
var options = request("/qbit/secured-data/reveal/options?tableName=person&fieldName=email", null, false);
assertThat(options.statusCode()).as(options.body()).isEqualTo(200);
assertThat(new JSONObject(options.body()).getInt("remainingDailyAllowance")).isEqualTo(1);
String body = "{\"tableName\":\"person\",\"primaryKey\":1,\"fieldName\":\"email\",\"reason\":\"Investigation\"}";
var reveal = request("/qbit/secured-data/reveal", body, false);
assertThat(reveal.statusCode()).as(reveal.body()).isEqualTo(200);
assertThat(new JSONObject(reveal.body()).getString("value")).isEqualTo("HTTP-CANARY@example.invalid");
var status = request("/qbit/secured-data/status", null, false);
assertThat(status.statusCode()).as(status.body()).isEqualTo(200);
assertThat(new JSONObject(status.body()).getInt("revealsUsedToday")).isEqualTo(1);
assertThat(request("/qbit/secured-data/reveal", body, false).statusCode()).isEqualTo(429);
assertThat(request("/qbit/secured-data/reveal", body, true).statusCode()).isEqualTo(403);
assertThat(request("/qbit/secured-data/reveal", "{}", false).statusCode()).isEqualTo(400);
assertThat(request("/qbit/secured-data/reveal", "{\"primaryKey\":1}", false).statusCode()).isEqualTo(400);
assertThat(request("/qbit/secured-data/reveal/options?tableName=person&fieldName=missing", null, false).statusCode()).isEqualTo(404);
}
@Test
void notificationTargetsOnlySecuredDataAdmins() throws Exception
{
Class<?> signalType;
try { signalType = Class.forName("com.kingsrook.qqq.backend.core.alerts.NotificationSignal"); }
catch(ClassNotFoundException expected) { org.junit.jupiter.api.Assumptions.assumeTrue(false, "Installed core lacks notification SPI #1125"); return; }
var instance = setup();
Bell.signal = null;
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(Bell.class));
var metadata = AlertsInstanceMetaData.ofOrWithNew(instance);
var setter = metadata.getClass().getDeclaredMethod("setLoadedSink", AlertSinkInterface.class);
setter.setAccessible(true);
setter.invoke(metadata, java.lang.reflect.Proxy.newProxyInstance(AlertSinkInterface.class.getClassLoader(), new Class<?>[] {AlertSinkInterface.class},
(proxy, method, args) -> { if(method.getName().equals("notify")) Bell.signal = args[0]; return null; }));
QContext.setQSession(user("u1", BreakGlassService.START + ".hasAccess"));
new BreakGlassService().start("Urgent incident investigation", 60, null, null);
assertThat(Bell.signal).isNotNull();
Object audience = signalType.getMethod("getAudience").invoke(Bell.signal);
assertThat((List<?>) audience.getClass().getMethod("getValues").invoke(audience)).isEqualTo(List.of("secured_data.admin"));
}
public static class Bell implements AlertSinkInterface
{
static Object signal;
public void raise(AlertSignal ignored) { }
public void resolve(AlertResolution ignored) { }
public void heartbeat(String key, Instant at) { }
}
}
@@ -0,0 +1,34 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.nio.file.Path;
import com.kingsrook.qbits.secureddata.uiapi.SecuredDataExtension;
import org.junit.jupiter.api.Test;
class SecuredDataUiApiContractTest
{
@Test
void committedContractMatches() throws Exception
{
UiApiContractAssertions.assertExtensionMatchesCommitted(SecuredDataExtension.defineExtension(), Path.of("src/main/resources/openapi/qqq-v1/secured-data.yaml"));
}
}
@@ -0,0 +1,43 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.nio.file.Files;
import java.nio.file.Path;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import static org.assertj.core.api.Assertions.assertThat;
/** STAND-IN(core=QRun-IO/qqq#1128, removeBy=RC.6): supplied worktree exposes UiApiContract, not the documented assertion facade. */
final class UiApiContractAssertions
{
static void assertExtensionMatchesCommitted(MiddlewareSpecExtension extension, Path path) throws Exception
{
String generated = UiApiContract.generateExtensionYaml(new MiddlewareVersionV1(), extension);
if(Boolean.getBoolean("qqq.uiApi.regenerate"))
{
Files.createDirectories(path.getParent());
Files.writeString(path, generated);
}
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1, "Regenerate secured-data.yaml with -Dqqq.uiApi.regenerate=true")).isEmpty();
}
}
@@ -166,6 +166,38 @@ class CoreFieldAccessBridgeIT
@Test
void revealUnmasksOnlyGrantedRecordAndExpiresInCoreGet() throws Exception
{
setup();
policy("cs_admin", "PII", "REVEALABLE");
QRecord row = insert("person", new QRecord().withValue("email", EMAIL));
QRecord other = insert("person", new QRecord().withValue("email", "OTHER-CANARY"));
GetInput get = new GetInput("person").withPrimaryKey(row.getValue("id"));
assertThat(new GetAction().execute(get).getRecord().getValueString("email")).isNotEqualTo(EMAIL);
var reveal = new com.kingsrook.qbits.secureddata.reveal.RevealService().reveal("person", row.getValue("id"), "email", "Investigating", null);
assertThat(reveal.value()).isEqualTo(EMAIL);
assertThat(new GetAction().execute(get).getRecord().getValueString("email")).isEqualTo(EMAIL);
assertThat(new GetAction().execute(new GetInput("person").withPrimaryKey(other.getValue("id"))).getRecord().getValueString("email")).isNotEqualTo("OTHER-CANARY");
SecuredDataTestSupport.CLOCK.now = SecuredDataTestSupport.NOW.plusSeconds(300);
assertThat(new GetAction().execute(get).getRecord().getValueString("email")).isNotEqualTo(EMAIL);
}
@Test
void revealPreservesCoreRecordLockDenial() throws Exception
{
QInstance instance = setup();
policy("cs_admin", "PII", "REVEALABLE");
QRecord row = insert("person", new QRecord().withValue("email", EMAIL).withValue("notes", "tenant-a"));
instance.addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("tenant"));
instance.getTable("person").withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock().withSecurityKeyType("tenant").withFieldName("notes"));
QContext.getQSession().withSecurityKeyValue("tenant", "tenant-b");
assertThat(new GetAction().execute(new GetInput("person").withPrimaryKey(row.getValue("id"))).getRecord()).isNull();
assertThatThrownBy(() -> new com.kingsrook.qbits.secureddata.reveal.RevealService().reveal("person", row.getValue("id"), "email", "Investigating", null))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QNotFoundException.class);
assertThat(com.kingsrook.qbits.secureddata.support.SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter())).isEmpty();
}
/*******************************************************************************
** Search.
*******************************************************************************/
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.io.Serializable;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Runs a streamed-ETL table or record action the way the UI does: the
** review screen, then the result.
*******************************************************************************/
public final class ProcessRunner
{
/*******************************************************************************
** Review and result lines, as shown.
*******************************************************************************/
public record Lines(List<String> review, List<String> result)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ProcessRunner()
{
}
/*******************************************************************************
** Run on some record ids.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static Lines run(String process, Serializable... ids) throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(process);
input.addValue("recordsParam", "recordIds");
input.addValue("recordIds", Arrays.stream(ids).map(String::valueOf).collect(Collectors.joining(",")));
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
RunProcessOutput review = new RunProcessAction().execute(input);
if(review.getException().isPresent())
{
throw (review.getException().get());
}
List<ProcessSummaryLineInterface> reviewLines = (List<ProcessSummaryLineInterface>) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
RunProcessInput go = new RunProcessInput();
go.setProcessName(process);
go.setProcessUUID(review.getProcessUUID());
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
RunProcessOutput result = new RunProcessAction().execute(go);
if(result.getException().isPresent())
{
throw (result.getException().get());
}
List<ProcessSummaryLineInterface> resultLines = (List<ProcessSummaryLineInterface>) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
return (new Lines(text(reviewLines, false), text(resultLines, true)));
}
/*******************************************************************************
** Lines as text.
*******************************************************************************/
private static List<String> text(List<ProcessSummaryLineInterface> lines, boolean past)
{
return (lines == null ? List.of() : lines.stream().map(line ->
{
if(line instanceof ProcessSummaryLine summary)
{
summary.pickMessage(past);
return ((summary.getCount() == null ? "" : summary.getCount() + " ") + summary.getMessage());
}
return (String.valueOf(line));
}).toList());
}
}
@@ -0,0 +1,319 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.secureddata.breakglass.BreakGlassService;
import com.kingsrook.qbits.secureddata.metadata.FieldsOfTablePossibleValueSource;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.processes.BreakGlassProcesses;
import com.kingsrook.qbits.secureddata.reveal.RevealCappedException;
import com.kingsrook.qbits.secureddata.reveal.RevealService;
import com.kingsrook.qbits.secureddata.standin.DataClassification;
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.*;
import static org.assertj.core.api.Assertions.*;
class RevealAndBreakGlassTest
{
RevealService reveals = new RevealService();
BreakGlassService breaks = new BreakGlassService();
Serializable pk;
QRecord policy;
@BeforeEach
void init() throws Exception
{
var instance = setup();
policy = insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE")
.withValue("reason_options", "Support\nInvestigation").withValue("daily_reveal_cap", 2).withValue("allow_free_text_reason", false));
pk = insert("person", new QRecord().withValue("email", "secret@example.invalid").withValue("ssn", "123456789")).getValue("id");
Sink.signals.clear(); Sink.resolutions.clear();
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(Sink.class));
}
@AfterEach
void cleanup() { close(); }
@Test
void revealValueAuditLinkCacheAndExactExpiry() throws Exception
{
var resolver = SecuredDataRuntime.current().resolver();
assertThat(resolver.isRevealActive("person", "email", pk)).isFalse();
var result = reveals.reveal("person", pk, "email", "Support", null);
assertThat(result.value()).isEqualTo("secret@example.invalid");
assertThat(result.displayValue()).isEqualTo("secret@example.invalid");
assertThat(result.expiresAt()).isEqualTo(NOW.plusSeconds(300));
assertThat(resolver.isRevealActive("person", "email", pk)).isTrue();
assertThat(resolver.isRevealActive("person", "ssn", pk)).isFalse();
assertThat(resolver.isRevealActive("person", "email", 999)).isFalse();
QRecord grant = SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter()).getFirst();
QRecord log = logs().stream().filter(r -> "REVEAL".equals(r.getValueString("event_type"))).findFirst().orElseThrow();
assertThat(grant.getValue("access_log_id")).isEqualTo(log.getValue("id"));
assertThat(log.getValueString("reason")).isEqualTo("Support");
QContext.setQSession(user("u2", "cs_admin"));
assertThat(resolver.isRevealActive("person", "email", pk)).isFalse();
QContext.setQSession(user("u1", "cs_admin"));
assertThat(resolver.isRevealActive("person", "email", pk)).isTrue();
CLOCK.now = NOW.plusSeconds(300);
assertThat(resolver.isRevealActive("person", "email", pk)).isFalse();
}
@Test
void optionsSelectOneMostPermissiveGrantingPolicy() throws Exception
{
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("daily_reveal_cap", 10)
.withValue("reveal_minutes", 15).withValue("reason_options", "Other policy").withValue("require_free_text", true));
var options = reveals.options("person", "email");
assertThat(options.remainingDailyAllowance()).isEqualTo(10);
assertThat(options.revealMinutes()).isEqualTo(15);
assertThat(options.reasonOptions()).containsExactly("Other policy");
assertThat(options.requireFreeText()).isTrue();
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("client_channel", "web").withValue("reveal_minutes", 60));
QContext.setQSession(channelUser("web", "cs_admin"));
assertThat(reveals.options("person", "email").revealMinutes()).isEqualTo(15);
}
@Test
void wrongTierAndReadPermissionAreRefused() throws Exception
{
assertThatThrownBy(() -> reveals.reveal("person", pk, "ssn", "Support", null)).isInstanceOf(QPermissionDeniedException.class);
QContext.setQSession(new QSession().withUser(user("u1").getUser()).withPermissions(List.of("cs_admin")));
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Support", null)).isInstanceOf(QPermissionDeniedException.class);
assertThat(SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter())).isEmpty();
}
@Test
void missingRecordAndFieldAreNotFound() throws Exception
{
assertThatThrownBy(() -> reveals.reveal("person", 999, "email", "Support", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> reveals.options("person", "bogus")).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> reveals.reveal("person", null, "email", "Support", null)).isInstanceOf(QBadRequestException.class);
}
@Test
void invalidReasonsAndLengthAreRejected() throws Exception
{
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Bogus", null)).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Support", "x".repeat(501))).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "x".repeat(501), null)).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", null, null)).isInstanceOf(QBadRequestException.class);
}
@Test
void freeTextRequiredAndFiveHundredCharactersAccepted() throws Exception
{
update(SecuredDataTables.POLICY, new QRecord().withValue("id", policy.getValue("id")).withValue("allow_free_text_reason", true).withValue("require_free_text", true));
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Support", null)).isInstanceOf(QBadRequestException.class);
assertThat(reveals.reveal("person", pk, "email", null, "x".repeat(500)).value()).isEqualTo("secret@example.invalid");
}
@Test
void dailyCapAcrossTablesAlertsAndResetsAtUtcMidnight() throws Exception
{
reveals.reveal("person", pk, "email", "Support", null);
QContext.getQInstance().addTable(person("memory").withName("customer"));
QContext.setQSession(user("u1", "cs_admin", "customer.read"));
Serializable other = insert("customer", new QRecord().withValue("email", "second@example.invalid")).getValue("id");
reveals.reveal("customer", other, "email", "Support", null);
assertThat(reveals.options("person", "email").remainingDailyAllowance()).isZero();
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Support", null)).isInstanceOf(RevealCappedException.class);
assertThat(logs()).anyMatch(r -> "CAPPED".equals(r.getValueString("outcome")) && "REVEAL_DENIED".equals(r.getValueString("event_type")));
assertThat(Sink.signals).anyMatch(s -> "secured_data.reveal_capped".equals(s.getAlertTypeName()));
CLOCK.now = NOW.plusSeconds(86400);
assertThat(reveals.options("person", "email").remainingDailyAllowance()).isEqualTo(2);
}
@Test
void assistantRefusedAndLogged() throws Exception
{
QContext.setQSession(channelUser("assistant/thread", "cs_admin"));
assertThatThrownBy(() -> reveals.reveal("person", pk, "email", "Support", null)).isInstanceOf(QPermissionDeniedException.class);
assertThat(logs()).anyMatch(r -> "REVEAL_DENIED".equals(r.getValueString("event_type")) && "assistant".equals(r.getValueString("client_channel")));
}
void enableStart() { QContext.setQSession(user("u1", "cs_admin", BreakGlassService.START + ".hasAccess")); }
@Test
void breakGlassStartsImmediatelyAlertsAndReportsStatus() throws Exception
{
enableStart();
String fingerprint = SecuredDataRuntime.current().resolver().getAccessFingerprint();
QRecord row = breaks.start("Investigating urgent incident", null, null, null);
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.FULL);
assertThat(SecuredDataRuntime.current().resolver().getAccessFingerprint()).isNotEqualTo(fingerprint);
assertThat(Sink.signals).singleElement().satisfies(s -> { assertThat(s.getSeverity()).isEqualTo(AlertSeverity.ERROR); assertThat(s.getAlertTypeName()).isEqualTo("secured_data.break_glass"); });
var status = new StatusService().status();
assertThat(status.breakGlass().id()).isEqualTo(row.getValue("id"));
assertThat(status.breakGlass().expiresAt()).isEqualTo(NOW.plusSeconds(3600));
assertThat(status.breakGlass().ceiling()).isEqualTo("PHI");
assertThatThrownBy(() -> breaks.start("Another urgent investigation", 60, null, null)).isInstanceOf(QBadRequestException.class);
}
FieldAccessTier tier(String field) { var table = QContext.getQInstance().getTable("person"); return SecuredDataRuntime.current().resolver().resolve(table, table.getField(field)).tier(); }
@Test
void ceilingScopeAndReadTimeExpiry() throws Exception
{
enableStart();
breaks.start("Investigating urgent incident", 1, "person", DataClassification.PII);
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.MASKED);
CLOCK.now = NOW.plusSeconds(60);
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
assertThat(new StatusService().status().breakGlass()).isNull();
breaks.start("Investigating another incident", 1, SecuredDataTables.POLICY, null);
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
}
@Test
void ownerAndAdminEndButOthersCannot() throws Exception
{
enableStart(); QRecord row = breaks.start("Investigating urgent incident", 60, null, null);
Serializable firstId = row.getValue("id");
QContext.setQSession(user("u2"));
assertThatThrownBy(() -> breaks.end(firstId)).isInstanceOf(QPermissionDeniedException.class);
QContext.setQSession(user("u2", "secured_data.admin"));
breaks.end(row.getValue("id"));
assertThat(breaks.find(row.getValue("id")).getValueString("ended_by")).isEqualTo("u2");
assertThat(Sink.resolutions).hasSize(1);
enableStart(); row = breaks.start("Investigating urgent incident", 60, null, null);
breaks.end(row.getValue("id"));
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
assertThat(logs()).anyMatch(r -> "BREAK_GLASS_END".equals(r.getValueString("event_type")));
}
@Test
void expiryJobIsIdempotentAndResolvesAlert() throws Exception
{
enableStart(); QRecord row = breaks.start("Investigating urgent incident", 1, null, null);
CLOCK.now = NOW.plusSeconds(60);
var output = new RunBackendStepOutput();
new BreakGlassProcesses.Expire().run(new RunBackendStepInput(), output);
assertThat(breaks.find(row.getValue("id")).getValueString("status")).isEqualTo("EXPIRED");
assertThat(breaks.expire()).isZero();
assertThat(Sink.resolutions).hasSize(1);
assertThat(logs()).anyMatch(r -> "BREAK_GLASS_EXPIRED".equals(r.getValueString("event_type")));
}
@Test
void breakGlassValidationAndPermission() throws Exception
{
assertThatThrownBy(() -> breaks.start("Investigating urgent incident", 60, null, null)).isInstanceOf(QPermissionDeniedException.class);
enableStart();
assertThatThrownBy(() -> breaks.start("short", 60, null, null)).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> breaks.start("Investigating urgent incident", 481, null, null)).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> breaks.start("Investigating urgent incident", 0, null, null)).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> breaks.start("Investigating urgent incident", 60, "missing", null)).isInstanceOf(QBadRequestException.class);
QContext.setQSession(channelUser("assistant", BreakGlassService.START + ".hasAccess"));
assertThatThrownBy(() -> breaks.start("Investigating urgent incident", 60, null, null)).isInstanceOf(QPermissionDeniedException.class);
}
@Test
void statusAllowanceAndPicker() throws Exception
{
var status = new StatusService().status();
assertThat(status.breakGlass()).isNull();
assertThat(status.revealAllowances()).singleElement().satisfies(a -> { assertThat(a.tableName()).isEqualTo("person"); assertThat(a.fieldName()).isEqualTo("email"); assertThat(a.remainingDailyAllowance()).isEqualTo(2); });
var provider = new FieldsOfTablePossibleValueSource();
assertThat(provider.search(new SearchPossibleValueSourceInput())).isEmpty();
assertThat(provider.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("table_name", "")))).isEmpty();
var fields = provider.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("table_name", "person")));
assertThat(fields).extracting(v -> v.getId()).containsExactlyInAnyOrder("id", "email", "ssn", "notes");
assertThat(fields).allMatch(v -> v.getLabel() != null);
assertThat(provider.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("tableName", "person")))).hasSize(4);
assertThatThrownBy(() -> provider.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("tableName", "unknown")))).hasMessageContaining("Unknown table");
}
@Test
void endReviewAndResultRecheckOwnership() throws Exception
{
enableStart(); QRecord row = breaks.start("Investigating urgent incident", 60, null, null);
var input = new RunBackendStepInput(); input.setRecords(List.of(row));
var output = new RunBackendStepOutput();
var review = new BreakGlassProcesses.EndReview(); review.runOnePage(input, output);
assertThat(review.getProcessSummary(output, false)).hasSize(1);
var load = new BreakGlassProcesses.EndLoad(); load.runOnePage(input, new RunBackendStepOutput());
assertThat(load.getProcessSummary(output, true)).hasSize(1);
assertThat(breaks.find(row.getValue("id")).getValueString("status")).isEqualTo("ENDED");
}
@Test
void startAndEndThroughProcessEngine() throws Exception
{
enableStart();
var input = new RunBackendStepInput();
input.addValue("reason", "Investigating urgent incident");
input.addValue("duration_minutes", 60);
new BreakGlassProcesses.Start().run(input, new RunBackendStepOutput());
QRecord row = SecuredDataRuntime.current().resolver().activeBreakGlass().getFirst();
var lines = ProcessRunner.run(BreakGlassService.END, row.getValue("id"));
assertThat(lines.review()).anyMatch(line -> line.contains("will be ended"));
assertThat(lines.result()).anyMatch(line -> line.contains("was ended"));
assertThat(breaks.find(row.getValue("id")).getValueString("status")).isEqualTo("ENDED");
}
@Test
void expiryScheduleUsesHostSchedulerAndSixtySeconds()
{
var config = new SecuredDataQBitConfig().withSchedulerName("host");
var process = BreakGlassProcesses.processes(config).getLast();
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(60);
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("host");
assertThat(process.getIsHidden()).isTrue();
}
public static class Sink implements AlertSinkInterface
{
static final List<AlertSignal> signals = new ArrayList<>();
static final List<AlertResolution> resolutions = new ArrayList<>();
public void raise(AlertSignal signal) { signals.add(signal); }
public void resolve(AlertResolution resolution) { resolutions.add(resolution); }
public void heartbeat(String key, Instant at) { }
}
}