Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f5199e06
|
||
|
|
214bd75298
|
||
|
|
6bb55f332a
|
||
|
|
781bae3efe |
No files matched your search
@@ -4,6 +4,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed: UI API 1.6.0
|
||||
- UI API extension secured-data 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained SecuredDataErrorV1 schema deprecated since 1.1.0.
|
||||
|
||||
### Changed — direct core field access and audit protection
|
||||
- Remove all #1172/#1182 stand-ins and reflective bridges; use core field-access, classified-value token, audit protection, wipe flag and shredded-event APIs directly.
|
||||
- Register the resolver, key source and audit protector through core supplemental metadata.
|
||||
|
||||
@@ -4,7 +4,7 @@ Phases (b), (c), (e) and (f) of [SECURED-DATA](https://github.com/QRun-IO/qqq/bl
|
||||
|
||||
## Compatibility
|
||||
|
||||
Java 21, Maven wrapper 3.9.11, and **QQQ 4.1.0-RC.7** or later (it ships field access #1172 and audit value protection #1182).
|
||||
Java 21, Maven wrapper 3.9.11, and **QQQ 4.1.0-RC.15** or later (UI API 1.6.0, field access #1172 and audit value protection #1182).
|
||||
|
||||
Core classifications, AI metadata, client channels, table-feature permissions and UI API extension points are used directly. The UI API and its contract test are part of the main build; the contract test calls `UiApiContract` directly.
|
||||
|
||||
@@ -121,7 +121,7 @@ Configure `.withSchedulerName("yourScheduler")` to register the hidden `secured_
|
||||
|
||||
## UI API extension
|
||||
|
||||
The `secured-data` extension is version **1.0.0**:
|
||||
The `secured-data` extension is version **1.1.0**:
|
||||
|
||||
- `POST /qqq/v1/qbit/secured-data/reveal`
|
||||
- `GET /qqq/v1/qbit/secured-data/reveal/options?tableName=&fieldName=`
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<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 4.1.0-RC.7 ships field access (#1172) and audit value protection (#1182) -->
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<!-- QQQ 4.1.0-RC.15 ships UI API 1.6.0, field access and audit value protection -->
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
|
||||
@@ -37,11 +37,11 @@ public final class SecuredDataExtension
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The secured-data UI API, version 1.0.0.
|
||||
** The secured-data UI API, version 1.1.0.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension defineExtension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("secured-data").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("secured-data").withSemanticVersion("1.1.0")
|
||||
.withDescription("Reveal grants and break-glass status")
|
||||
.withSpec(SecuredDataRevealSpecV1.class).withSpec(SecuredDataRevealOptionsSpecV1.class).withSpec(SecuredDataStatusSpecV1.class));
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ public class SecuredDataRevealOptionsSpecV1 extends AbstractJsonEndpointSpec
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/secured-data/reveal/options").withHttpMethod(HttpMethod.GET)
|
||||
.withTag(() -> "Secured data").withShortSummary("RevealOptions"));
|
||||
.withTag(() -> "secured-data").withShortSummary("RevealOptions"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -28,6 +28,7 @@ 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.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
@@ -44,7 +45,7 @@ public class SecuredDataRevealSpecV1 extends AbstractJsonEndpointSpec
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/secured-data/reveal").withHttpMethod(HttpMethod.POST)
|
||||
.withTag(() -> "Secured data").withShortSummary("Reveal"));
|
||||
.withTag(() -> "secured-data").withShortSummary("Reveal"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -81,10 +82,10 @@ public class SecuredDataRevealSpecV1 extends AbstractJsonEndpointSpec
|
||||
@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")));
|
||||
return (List.of(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.BAD_REQUEST, "Invalid reveal request", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.FORBIDDEN, "Reveal denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.NOT_FOUND, "Record not found or locked", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.TOO_MANY_REQUESTS, "Daily reveal cap reached", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -58,7 +58,8 @@ public final class SecuredDataSchemas
|
||||
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)));
|
||||
schemas.put("SecuredDataErrorV1", object("error", type(Type.STRING))
|
||||
.withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1."));
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ public class SecuredDataStatusSpecV1 extends AbstractJsonEndpointSpec
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/secured-data/status").withHttpMethod(HttpMethod.GET)
|
||||
.withTag(() -> "Secured data").withShortSummary("Status"));
|
||||
.withTag(() -> "secured-data").withShortSummary("Status"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
components:
|
||||
schemas:
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
SecuredDataBreakGlassV1:
|
||||
properties:
|
||||
id:
|
||||
@@ -16,6 +55,7 @@ components:
|
||||
type: "string"
|
||||
type: "object"
|
||||
SecuredDataErrorV1:
|
||||
description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
|
||||
properties:
|
||||
error:
|
||||
type: "string"
|
||||
@@ -96,19 +136,53 @@ components:
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Reveal grants and break-glass status"
|
||||
title: "QQQ Middleware API extension: secured-data"
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/secured-data/reveal:
|
||||
post:
|
||||
operationId: "postQbitSecuredDataReveal"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -120,19 +194,19 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid reveal request"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Reveal denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Record not found or locked"
|
||||
200:
|
||||
content:
|
||||
@@ -144,16 +218,37 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Daily reveal cap reached"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not authenticated, or the session or token is invalid or expired\
|
||||
\ (code UNAUTHENTICATED). Sign in again, or refresh the token."
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Reveal"
|
||||
tags:
|
||||
- "Secured data"
|
||||
- "secured-data"
|
||||
/qqq/v1/qbit/secured-data/reveal/options:
|
||||
get:
|
||||
operationId: "getQbitSecuredDataRevealOptions"
|
||||
parameters:
|
||||
- in: "query"
|
||||
name: "tableName"
|
||||
@@ -172,14 +267,61 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataRevealOptionsResponseV1"
|
||||
description: "Successful Response"
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Bad request: a parameter or the body is invalid (code BAD_REQUEST);\
|
||||
\ fieldErrors may name the fields."
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not authenticated, or the session or token is invalid or expired\
|
||||
\ (code UNAUTHENTICATED). Sign in again, or refresh the token."
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "The user (or the credential's scope) may not do this (code\
|
||||
\ PERMISSION_DENIED)."
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "RevealOptions"
|
||||
tags:
|
||||
- "Secured data"
|
||||
- "secured-data"
|
||||
/qqq/v1/qbit/secured-data/status:
|
||||
get:
|
||||
operationId: "getQbitSecuredDataStatus"
|
||||
responses:
|
||||
200:
|
||||
content:
|
||||
@@ -187,9 +329,61 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecuredDataStatusResponseV1"
|
||||
description: "Successful Response"
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Bad request: a parameter or the body is invalid (code BAD_REQUEST);\
|
||||
\ fieldErrors may name the fields."
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not authenticated, or the session or token is invalid or expired\
|
||||
\ (code UNAUTHENTICATED). Sign in again, or refresh the token."
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "The user (or the credential's scope) may not do this (code\
|
||||
\ PERMISSION_DENIED)."
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Status"
|
||||
tags:
|
||||
- "Secured data"
|
||||
- "secured-data"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the secured-data extension."
|
||||
name: "secured-data"
|
||||
@@ -84,13 +84,23 @@ class RetentionProofTest
|
||||
|
||||
|
||||
|
||||
private static final String CANARY_PREFIX = "CANARY-PHI-";
|
||||
private static final int CANARY_RANDOM = 24;
|
||||
private static final int MIN_LEAK_FRAGMENT = 12;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Letters g-v only, so a marker can never coincide with hex digests or tokens.
|
||||
** A fixed prefix and 24 random letters g-v (so it never coincides with hex
|
||||
** digests). Leaks are matched on the whole marker or on 12-letter windows of
|
||||
** its random part: 16^12 possibilities, so ordinary text, JSON words or
|
||||
** base64 tokens cannot match by chance, while a truncated or re-encoded
|
||||
** fragment of a real leak still does.
|
||||
*******************************************************************************/
|
||||
private static String canary()
|
||||
{
|
||||
StringBuilder marker = new StringBuilder("CANARY-PHI-");
|
||||
for(int i = 0; i < 16; i++)
|
||||
StringBuilder marker = new StringBuilder(CANARY_PREFIX);
|
||||
for(int i = 0; i < CANARY_RANDOM; i++)
|
||||
{
|
||||
marker.append((char) ('g' + RANDOM.nextInt(16)));
|
||||
}
|
||||
@@ -100,21 +110,25 @@ class RetentionProofTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The marker, or any substring of it longer than four characters.
|
||||
** The whole marker, or any MIN_LEAK_FRAGMENT-letter window of its random part.
|
||||
*******************************************************************************/
|
||||
private static List<String> leaks(String haystack, String... markers)
|
||||
{
|
||||
List<String> found = new ArrayList<>();
|
||||
for(String marker : markers)
|
||||
{
|
||||
for(int length = 5; length <= marker.length(); length++)
|
||||
if(haystack.contains(marker))
|
||||
{
|
||||
for(int start = 0; start + length <= marker.length(); start++)
|
||||
found.add("whole marker");
|
||||
continue;
|
||||
}
|
||||
|
||||
String random = marker.substring(CANARY_PREFIX.length());
|
||||
for(int start = 0; start + MIN_LEAK_FRAGMENT <= random.length(); start++)
|
||||
{
|
||||
if(haystack.contains(random.substring(start, start + MIN_LEAK_FRAGMENT)))
|
||||
{
|
||||
if(haystack.contains(marker.substring(start, start + length)))
|
||||
{
|
||||
found.add("length " + length + " at " + start);
|
||||
}
|
||||
found.add("fragment at " + start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +137,23 @@ class RetentionProofTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The leak scan: whole markers and 12-letter fragments are found; the shared
|
||||
** prefix and ordinary words made of the same letters are not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void leakScanHasNoFalsePositives()
|
||||
{
|
||||
String marker = canary();
|
||||
String random = marker.substring(CANARY_PREFIX.length());
|
||||
assertThat(leaks("x" + marker + "x", marker)).containsExactly("whole marker");
|
||||
assertThat(leaks("{\"v\":\"" + random.substring(5, 5 + MIN_LEAK_FRAGMENT) + "\"}", marker)).isNotEmpty();
|
||||
assertThat(leaks(CANARY_PREFIX + " options joins strings rows points", marker)).isEmpty();
|
||||
assertThat(leaks(random.substring(0, MIN_LEAK_FRAGMENT - 1), marker)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canary through CRUD, a failing validation, a wipe run, a masked export and
|
||||
** the access log: it may appear only in the host table itself.
|
||||
|
||||
@@ -109,11 +109,25 @@ class SecuredDataHttpTest
|
||||
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);
|
||||
assertErrorResponse(request("/qbit/secured-data/reveal", body, false), 429);
|
||||
assertErrorResponse(request("/qbit/secured-data/reveal", body, true), 403);
|
||||
assertErrorResponse(request("/qbit/secured-data/reveal", "{}", false), 400);
|
||||
assertErrorResponse(request("/qbit/secured-data/reveal", "{\"primaryKey\":1}", false), 400);
|
||||
assertErrorResponse(request("/qbit/secured-data/reveal/options?tableName=person&fieldName=missing", null, false), 404);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify standard error bodies without changing endpoint status semantics.
|
||||
*******************************************************************************/
|
||||
private void assertErrorResponse(HttpResponse<String> response, int status)
|
||||
{
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(status);
|
||||
JSONObject body = new JSONObject(response.body());
|
||||
assertThat(body.keySet()).contains("error", "code", "message", "traceId");
|
||||
assertThat(body.getString("error")).isNotBlank();
|
||||
assertThat(body.getString("code")).isNotBlank();
|
||||
assertThat(body.getString("message")).isNotBlank();
|
||||
assertThat(body.getString("traceId")).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user