Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd0fd1f54
|
||
|
|
ab283c79b9
|
||
|
|
eb98ec5fd0
|
||
|
|
f09fe866ad
|
||
|
|
c4ebc6833c
|
||
|
|
2431b4723a
|
||
|
|
9fb168b96d
|
||
|
|
e0459a99ff | ||
|
|
84e2311d84
|
||
|
|
5987cb53ac
|
||
|
|
a087988f11
|
||
|
|
894c3023b4
|
||
|
|
15c2906fc1
|
||
|
|
45015b6912
|
||
|
|
0f94818084
|
||
|
|
7b302ed2b1 | ||
|
|
b917b51656 | ||
|
|
dc4734eef1 | ||
|
|
eb912ebf07 | ||
|
|
9091f26dd7 | ||
|
|
4942b7e994 | ||
|
|
0875069488 | ||
|
|
bf81c587bc | ||
|
|
70058c1ef6
|
||
|
|
af62471cf6 | ||
|
|
325150a938
|
||
|
|
72ec4a08be | ||
|
|
ef5c096e84
|
||
|
|
75b5f0b64f | ||
|
|
2cb37a067d
|
||
|
|
971ddbed2d | ||
|
|
e2f5199e06
|
||
|
|
214bd75298
|
||
|
|
6bb55f332a
|
||
|
|
781bae3efe | ||
|
|
677d611b15
|
||
|
|
527ee4fc96
|
||
|
|
c0a2d67026
|
||
|
|
f415f2cc8d | ||
|
|
0dec9463a6
|
||
|
|
e9b8a05f87 | ||
|
|
f2736311da | ||
|
|
d8e3cb976d
|
||
|
|
9b2d8fd182 |
No files matched your search
+10
-61
@@ -1,65 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [ /^v\d+\.\d+\.\d+$/ ]
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
+16
-1
@@ -4,6 +4,21 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed: access-log lifecycle
|
||||
- The access-log worker and JVM shutdown hook hold the logger weakly, so an instance the host drops (tests, reloads) can be collected; its worker stops at the next tick.
|
||||
- `withRegisterShutdownHook(false)` for hosts that call `SecuredDataRuntime.close()` themselves; `SecuredDataAccessLogger.isWorkerRunning()`.
|
||||
- Virtual (computed) fields resolve through the table classification instead of throwing (#18).
|
||||
- Idle access-log ticks open no context, so the worker never validates an unfinished host instance, and a failed tick no longer stops the worker (#18).
|
||||
|
||||
### 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.
|
||||
- Run core field-access integration and retention proof tests unconditionally.
|
||||
- Pin QQQ to `4.1.0-RC.7`, which ships field access (#1172) and audit value protection (#1182); all core stand-ins and reflective bridges are removed.
|
||||
|
||||
### Changed — RC.6 migration
|
||||
- Pin QQQ to 4.1.0-RC.6; use released classification, client-channel and table-feature APIs directly.
|
||||
- Fold UI API code and tests into the main build and remove the profile and reflective registration.
|
||||
@@ -15,7 +30,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
- Atomic wipe batches (fields, markers, key deletions, audits, access log and run row in one rollback-capable transaction), per-record and per-table legal holds, a Review/Result "Run wipe now" process and a daily scheduled run.
|
||||
- Per-record-field AES-GCM wrapped DEKs (AAD-bound) for crypto-shredding of audit tokens, `CHANGED_ONLY` audit treatment, and the audit-value protector.
|
||||
- App-home warning when known backup retention exceeds a rule's `age_days`; `withBackupRetentionDays` and `withArchiveSink` options; README retention section and the backups residual risk.
|
||||
- Tests: all actions, holds, rollback, Review/Result, schedule, permissions, wrap/unwrap, and (on the SECUREDATA snapshot) the section 15 canary and wipe-proof tests.
|
||||
- Tests: all actions, holds, rollback, Review/Result, schedule, permissions, wrap/unwrap, and the section 15 canary and wipe-proof tests.
|
||||
|
||||
### Fixed
|
||||
- The key source returned a DEK array it then zeroed; it now returns copies, with a regression test.
|
||||
|
||||
@@ -4,11 +4,11 @@ Phases (b), (c), (e) and (f) of [SECURED-DATA](https://github.com/QRun-IO/qqq/bl
|
||||
|
||||
## Compatibility
|
||||
|
||||
Java 21, Maven wrapper 3.9.11, **QQQ 4.1.0-RC.6**.
|
||||
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).
|
||||
|
||||
RC.6 supplies core classifications, AI metadata, client channels, table-feature permissions and the UI API extension points. These are used directly; the UI API and its contract test are part of the main build. The installed middleware jar provides `UiApiContract`, but no `UiApiContractAssertions` facade, so the contract test calls that released API directly.
|
||||
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.
|
||||
|
||||
Field access (#1172) and audit-value protection (#1182) are not in RC.6. Field-access stand-ins and `CoreFieldAccessBridge` remain marked for RC.7. RC.6 **does not enforce field tiers**. Integration tests can use `4.1.0-SECUREDATA-SNAPSHOT`.
|
||||
Field access (#1172), classified-value tokens and audit-value protection (#1182) use core types directly. The producer registers the resolver, key source and audit protector through core supplemental metadata; QQQ core enforces field tiers.
|
||||
|
||||
## Install and register
|
||||
|
||||
@@ -16,7 +16,7 @@ Field access (#1172) and audit-value protection (#1182) are not in RC.6. Field-a
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-secured-data</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<version>0.1.0-RC.1</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -101,7 +101,7 @@ Policy/break-glass caches are bounded per instance and keyed by relevant user pe
|
||||
|
||||
The extra `search_value_fingerprints` TEXT column persists JSON arrays of HMAC-SHA256 fingerprints for distinct-value detection after restarts and across nodes. Use a shared secret key supplier on multi-node deployments; the default is a random per-JVM key, so historical fingerprints from another JVM/key cannot be compared reliably.
|
||||
|
||||
One daemon worker drains a bounded queue as a system session. Enqueueing does not wait for persistence. Full queues and failed writes warn without values and increment `getDroppedCount()`. The app can expose this counter later. Explicitly call the instance's `SecuredDataRuntime.close()` at host shutdown; a JVM shutdown hook also drains accepted rows. An abrupt process crash can lose queued rows.
|
||||
One daemon worker drains a bounded queue as a system session. Enqueueing does not wait for persistence. Full queues and failed writes warn without values and increment `getDroppedCount()`. The app can expose this counter later. Explicitly call the instance's `SecuredDataRuntime.close()` at host shutdown; a JVM shutdown hook also drains accepted rows unless `withRegisterShutdownHook(false)` turns it off (hosts that close the runtime themselves). Neither the worker nor the hook holds the instance strongly: an instance the host drops (tests, reloads) can be collected, and its worker stops at the next tick. An abrupt process crash can lose queued rows.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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=`
|
||||
@@ -144,6 +144,7 @@ The classification field picker uses CUSTOM possible values, reading `table_name
|
||||
| `withFingerprintKeySupplier(Supplier<byte[]>)` | Random 32-byte JVM key; supplied key must be at least 16 bytes |
|
||||
| `withAccessLogQueueCapacity(int)` | 4096 rows |
|
||||
| `withAccessLogFlushIntervalMillis(long)` | 250 ms |
|
||||
| `withRegisterShutdownHook(boolean)` | true; JVM hook that drains the access log at shutdown |
|
||||
| `withExactMatchPerHour(int)` | 20; zero disables |
|
||||
| `withMissAlertConsecutive(int)` | 5; zero disables |
|
||||
| `withDistinctValueAlert(int)` | 15; zero disables |
|
||||
@@ -159,10 +160,9 @@ Configure before producing the QBit; runtime infrastructure and HMAC keys are ca
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -q -o clean verify
|
||||
./mvnw.cmd -q -o clean verify -Dqqq.snapshot.version=4.1.0-SECUREDATA-SNAPSHOT
|
||||
```
|
||||
|
||||
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.
|
||||
The suite uses memory and H2. `CoreFieldAccessIT` runs unconditionally through Surefire and exercises Query/Get, policy tiers, masking, exact matches and outcomes, sorting denial, rate/entropy refusal and the assistant wall. `RetentionProofTest` also runs unconditionally. Checkstyle and JaCoCo gates remain 70% instructions / 90% classes.
|
||||
|
||||
## Anomaly monitoring (phase e)
|
||||
|
||||
@@ -243,11 +243,11 @@ Each is read from a JVM property or the environment and is never logged. Losing
|
||||
|
||||
### Core hooks
|
||||
|
||||
Audit-value protection (QRun-IO/qqq#1182) and the wipe audit flag need core classes that are not in RC.6. On RC.6 the qbit uses marked stand-ins (`removeBy=RC.7`), writes value-free wipe audit details itself, and core does not tokenise ordinary audits. Run the build with `-Dqqq.snapshot.version=4.1.0-SECUREDATA-SNAPSHOT` to exercise the real hooks, including the section 15 canary and wipe-proof tests (`RetentionProofTest`), which skip themselves when the hooks are absent.
|
||||
Audit-value protection, wipe audit flags and shredded audit events use the core #1182 APIs directly. Core tokenises ordinary audits and writes value-free wipe details. The section 15 canary and wipe-proof tests (`RetentionProofTest`) are mandatory in the normal build.
|
||||
|
||||
## Remaining phases
|
||||
|
||||
RC.6 migration is complete; field-access compatibility types remain until RC.7. Phase (g) remains.
|
||||
Field-access and audit-token stand-ins and reflective bridges have been removed. The coordinator will replace the temporary snapshot pin with the first QQQ RC containing #1172 and #1182.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
<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>
|
||||
<!-- Released APIs; fieldaccess and audit protection remain RC.7 stand-ins. -->
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<!-- 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.26</qqq.snapshot.version>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -34,7 +35,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -92,7 +93,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* 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.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueKeySource;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueTokens;
|
||||
import com.kingsrook.qbits.secureddata.standin.DMLType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.audits.AuditInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** A single reflection boundary for #1182, failing closed for incompatible hooks.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public final class CoreAuditBridge
|
||||
{
|
||||
private static final String TOKENS = "com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.";
|
||||
private static final String AUDITS = "com.kingsrook.qqq.backend.core.actions.audits.";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CoreAuditBridge()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core hook availability (RC.6 has none).
|
||||
*******************************************************************************/
|
||||
public static boolean available()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(TOKENS + "ClassifiedValueTokens");
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Register exact #1182 contracts when present; no silent partial registration.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, SecuredDataRuntime runtime)
|
||||
{
|
||||
if(!available())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Class<?> contract = Class.forName(TOKENS + "ClassifiedValueKeySource");
|
||||
Object keyProxy = Proxy.newProxyInstance(contract.getClassLoader(), new Class<?>[] {contract}, (self, method, args) ->
|
||||
{
|
||||
if(method.getDeclaringClass() == Object.class)
|
||||
{
|
||||
return (objectMethod(self, method.getName(), args));
|
||||
}
|
||||
Optional<ClassifiedValueKeySource.KeyAndId> key = switch(method.getName())
|
||||
{
|
||||
case "instanceKey" -> runtime.keys().instanceKey();
|
||||
case "keyForRecordField" -> runtime.keys().keyForRecordField((String) args[0], (String) args[1], (Serializable) args[2], (Boolean) args[3]);
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
return (key.isEmpty() ? Optional.empty() : Optional.of(Class.forName(TOKENS + "ClassifiedValueKeySource$KeyAndId")
|
||||
.getConstructor(byte[].class, String.class).newInstance(key.get().key(), key.get().id())));
|
||||
});
|
||||
Class<?> metadata = Class.forName(TOKENS + "ClassifiedValueKeySourceMetaData");
|
||||
Object registry = metadata.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
metadata.getMethod("withKeySourceInstance", contract).invoke(registry, keyProxy);
|
||||
SecuredDataAuditProtector protector = new SecuredDataAuditProtector();
|
||||
Class<?> auditContract = Class.forName(AUDITS + "AuditValueProtectorInterface");
|
||||
Object auditProxy = Proxy.newProxyInstance(auditContract.getClassLoader(), new Class<?>[] {auditContract}, (self, method, args) ->
|
||||
{
|
||||
if(method.getDeclaringClass() == Object.class)
|
||||
{
|
||||
return (objectMethod(self, method.getName(), args));
|
||||
}
|
||||
if("classificationOf".equals(method.getName()))
|
||||
{
|
||||
return (protector.classificationOf((QTableMetaData) args[0], (QFieldMetaData) args[1]));
|
||||
}
|
||||
if(!"protect".equals(method.getName()))
|
||||
{
|
||||
throw (new UnsupportedOperationException(method.getName()));
|
||||
}
|
||||
var values = protector.protect((QTableMetaData) args[0], (QFieldMetaData) args[1], (Serializable) args[2], (Serializable) args[3], (Serializable) args[4], DMLType.valueOf(args[5].toString()));
|
||||
return (Class.forName(AUDITS + "ProtectedAuditValues").getConstructor(String.class, String.class, String.class)
|
||||
.newInstance(values.oldToken(), values.newToken(), values.message()));
|
||||
});
|
||||
metadata = Class.forName(AUDITS + "AuditValueProtectionMetaData");
|
||||
registry = metadata.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
metadata.getMethod("withProtectorInstance", auditContract).invoke(registry, auditProxy);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to register core audit protection", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable object behavior for registered service proxies.
|
||||
*******************************************************************************/
|
||||
private static Object objectMethod(Object self, String name, Object[] args)
|
||||
{
|
||||
return (switch(name)
|
||||
{
|
||||
case "toString" -> "Secured-data audit bridge";
|
||||
case "hashCode" -> System.identityHashCode(self);
|
||||
case "equals" -> self == args[0];
|
||||
default -> throw new UnsupportedOperationException(name);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate to the installed core helper, with contract-identical RC.6 fallback.
|
||||
*******************************************************************************/
|
||||
public static String token(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value)
|
||||
{
|
||||
if(!available())
|
||||
{
|
||||
return (ClassifiedValueTokens.forRecordField(table, field, pk, value));
|
||||
}
|
||||
try
|
||||
{
|
||||
return ((String) Class.forName(TOKENS + "ClassifiedValueTokens").getMethod("forRecordField", QTableMetaData.class, QFieldMetaData.class, Serializable.class, Serializable.class)
|
||||
.invoke(null, table, field, pk, value));
|
||||
}
|
||||
catch(InvocationTargetException e)
|
||||
{
|
||||
throw (new IllegalStateException("Classified token creation failed"));
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Incompatible core token API", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real WipeAuditFlag on snapshot; RC.6 uses a value-free explicit audit below.
|
||||
*******************************************************************************/
|
||||
public static void flag(UpdateInput input, String ruleId)
|
||||
{
|
||||
if(!available())
|
||||
{
|
||||
input.setOmitDmlAudit(true);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ActionFlag flag = (ActionFlag) Class.forName("com.kingsrook.qqq.backend.core.model.actions.tables.WipeAuditFlag").getConstructor(String.class).newInstance(ruleId);
|
||||
input.setFlags(Set.of(flag));
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Incompatible core wipe API", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Append immutable value-free audit evidence in the batch transaction.
|
||||
*******************************************************************************/
|
||||
public static void detail(QTableMetaData table, Serializable pk, String field, String ruleId, boolean shredded, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(shredded && available())
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(AUDITS + "AuditShreddedEvent").getMethod("shreddedDetail", QTableMetaData.class, Serializable.class, QFieldMetaData.class, String.class, QBackendTransaction.class)
|
||||
.invoke(null, table, pk, table.getField(field), ruleId, transaction);
|
||||
return;
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new QException("Unable to append shredded audit detail"));
|
||||
}
|
||||
}
|
||||
String message = (shredded ? "Audit values shredded" : "Wiped") + " per policy";
|
||||
QRecord detail = new QRecord().withValue("fieldName", field).withValue("newValue", (shredded ? "shredded:v1:" : "wiped:v1:") + ruleId).withValue("message", message);
|
||||
AuditInput input = new AuditInput();
|
||||
input.setTransaction(transaction);
|
||||
AuditAction.appendToInput(input, table.getName(), pk, AuditAction.getRecordSecurityKeyValues(table,
|
||||
new QRecord().withValue(table.getPrimaryKeyField(), pk), Optional.empty()), message, List.of(detail));
|
||||
new AuditAction().execute(input);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* 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.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessDecision;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEvent;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEventType;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessOutcome;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessResolverInterface;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The only boundary between the temporary field-access API and unreleased core.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7)
|
||||
public final class CoreFieldAccessBridge
|
||||
{
|
||||
private static final String PACKAGE = "com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.";
|
||||
private static final QLogger LOG = QLogger.getLogger(CoreFieldAccessBridge.class);
|
||||
private static final AtomicBoolean WARNED = new AtomicBoolean();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CoreFieldAccessBridge()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register only when core can actually enforce field access.
|
||||
*******************************************************************************/
|
||||
public static boolean register(QInstance instance, FieldAccessResolverInterface resolver)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> metadata = Class.forName(PACKAGE + "FieldAccessResolverMetaData");
|
||||
Class<?> contract = Class.forName(PACKAGE + "FieldAccessResolverInterface");
|
||||
Object proxy = Proxy.newProxyInstance(contract.getClassLoader(), new Class<?>[] {contract}, (self, method, args) ->
|
||||
{
|
||||
return (switch(method.getName())
|
||||
{
|
||||
case "resolve" -> decision(resolver.resolve((QTableMetaData) args[0], (QFieldMetaData) args[1]));
|
||||
case "getAccessFingerprint" -> resolver.getAccessFingerprint();
|
||||
case "isRevealActive" -> resolver.isRevealActive((String) args[0], (String) args[1], (Serializable) args[2]);
|
||||
case "fingerprintSearchValue" -> resolver.fingerprintSearchValue((Serializable) args[0]);
|
||||
case "checkExactMatch" ->
|
||||
{
|
||||
resolver.checkExactMatch(event(args[0]));
|
||||
yield null;
|
||||
}
|
||||
case "onAccess" ->
|
||||
{
|
||||
resolver.onAccess(event(args[0]));
|
||||
yield null;
|
||||
}
|
||||
case "toString" -> "SecuredData core bridge";
|
||||
case "hashCode" -> System.identityHashCode(self);
|
||||
case "equals" -> self == args[0];
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
});
|
||||
Object registry = metadata.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
metadata.getMethod("withResolverInstance", contract).invoke(registry, proxy);
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
if(WARNED.compareAndSet(false, true))
|
||||
{
|
||||
LOG.info("Secured-data field enforcement needs QQQ RC.6+; policy and access-log services are available.");
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Core field-access API is incompatible", unwrap(e)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct the core record by its declared component types and enum names.
|
||||
*******************************************************************************/
|
||||
private static Object decision(FieldAccessDecision decision) throws ReflectiveOperationException
|
||||
{
|
||||
Class<?> type = Class.forName(PACKAGE + "FieldAccessDecision");
|
||||
Class<?> tier = type.getRecordComponents()[0].getType();
|
||||
Class<?> mask = type.getRecordComponents()[1].getType();
|
||||
return (type.getConstructor(tier, mask, String.class, boolean.class).newInstance(
|
||||
enumValue(tier, decision.tier().name()), decision.maskStyle() == null ? null : enumValue(mask, decision.maskStyle().name()),
|
||||
decision.reason(), decision.exactMatchAllowed()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert enum types without sharing class identity across the bridge.
|
||||
*******************************************************************************/
|
||||
private static Object enumValue(Class<?> type, String name) throws ReflectiveOperationException
|
||||
{
|
||||
return (type.getMethod("valueOf", String.class).invoke(null, name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Events carry identifiers and keyed fingerprints, never search literals.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static FieldAccessEvent event(Object source) throws ReflectiveOperationException
|
||||
{
|
||||
Class<?> type = source.getClass();
|
||||
Object outcome = type.getMethod("getOutcome").invoke(source);
|
||||
return (new FieldAccessEvent()
|
||||
.withType(FieldAccessEventType.valueOf(type.getMethod("getType").invoke(source).toString()))
|
||||
.withTableName((String) type.getMethod("getTableName").invoke(source))
|
||||
.withFieldNames((List<String>) type.getMethod("getFieldNames").invoke(source))
|
||||
.withPrimaryKeys((List<Serializable>) type.getMethod("getPrimaryKeys").invoke(source))
|
||||
.withRecordCount((Integer) type.getMethod("getRecordCount").invoke(source))
|
||||
.withActionName((String) type.getMethod("getActionName").invoke(source))
|
||||
.withOutcome(outcome == null ? null : FieldAccessOutcome.valueOf(outcome.toString()))
|
||||
.withSearchValueFingerprints((List<String>) type.getMethod("getSearchValueFingerprints").invoke(source)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the startup failure, including duplicate resolver registration.
|
||||
*******************************************************************************/
|
||||
private static Throwable unwrap(ReflectiveOperationException exception)
|
||||
{
|
||||
return (exception instanceof InvocationTargetException invocation ? invocation.getCause() : exception);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.kingsrook.qbits.secureddata;
|
||||
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayDeque;
|
||||
@@ -38,11 +39,11 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEvent;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEventType;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessOutcome;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
@@ -60,7 +61,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded asynchronous value-free audit, with persisted and local enumeration accounting.
|
||||
*******************************************************************************/
|
||||
@@ -91,7 +91,12 @@ public class SecuredDataAccessLogger implements AutoCloseable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One daemon drains this instance's queue; the JVM hook flushes on shutdown.
|
||||
** One daemon drains this instance's queue; the optional JVM hook flushes on
|
||||
** shutdown. Neither holds the logger strongly: once the host drops its
|
||||
** instance (a test, a reloaded instance), the logger, runtime and instance
|
||||
** can be collected, and the worker stops at its next tick. Hosts that close
|
||||
** the runtime themselves can turn the hook off
|
||||
** (SecuredDataQBitConfig.withRegisterShutdownHook(false)).
|
||||
*******************************************************************************/
|
||||
public SecuredDataAccessLogger(QInstance instance, SecuredDataRuntime runtime)
|
||||
{
|
||||
@@ -104,10 +109,69 @@ public class SecuredDataAccessLogger implements AutoCloseable
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
WeakReference<SecuredDataAccessLogger> self = new WeakReference<>(this);
|
||||
long interval = runtime.config().getAccessLogFlushIntervalMillis();
|
||||
worker.scheduleWithFixedDelay(this::flush, interval, interval, TimeUnit.MILLISECONDS);
|
||||
shutdownHook = new Thread(this::close, "secured-data-access-log-shutdown");
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
worker.scheduleWithFixedDelay(new WeakTick(self, worker), interval, interval, TimeUnit.MILLISECONDS);
|
||||
shutdownHook = runtime.config().getRegisterShutdownHook() ? new Thread(new WeakClose(self), "secured-data-access-log-shutdown") : null;
|
||||
if(shutdownHook != null)
|
||||
{
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The worker's task, holding the logger weakly: when the logger has been
|
||||
** collected the worker shuts itself down.
|
||||
*******************************************************************************/
|
||||
private record WeakTick(WeakReference<SecuredDataAccessLogger> logger, ScheduledExecutorService worker) implements Runnable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Drain, or stop once the logger is gone.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
SecuredDataAccessLogger target = logger.get();
|
||||
if(target == null)
|
||||
{
|
||||
worker.shutdown();
|
||||
return;
|
||||
}
|
||||
target.scheduledFlush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The JVM shutdown hook, holding the logger weakly.
|
||||
*******************************************************************************/
|
||||
private record WeakClose(WeakReference<SecuredDataAccessLogger> logger) implements Runnable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Drain accepted rows at JVM shutdown, if the logger still exists.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
SecuredDataAccessLogger target = logger.get();
|
||||
if(target != null)
|
||||
{
|
||||
target.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the background worker is still running (false after close).
|
||||
*******************************************************************************/
|
||||
public boolean isWorkerRunning()
|
||||
{
|
||||
return (!worker.isShutdown());
|
||||
}
|
||||
|
||||
|
||||
@@ -333,13 +397,38 @@ public class SecuredDataAccessLogger implements AutoCloseable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The worker's tick. An exception would cancel every later run of a
|
||||
** scheduled task, so a failed drain is logged (value-free) and retried on
|
||||
** the next tick; the rows stay queued.
|
||||
*******************************************************************************/
|
||||
void scheduledFlush()
|
||||
{
|
||||
try
|
||||
{
|
||||
flush();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Unable to drain the secured-data access log; retrying on the next tick");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drain safely both from the worker and explicit test/host shutdown calls.
|
||||
** An empty queue opens no context: QContext validates an unvalidated
|
||||
** instance, and the worker starts while the host is still building it.
|
||||
*******************************************************************************/
|
||||
public void flush()
|
||||
{
|
||||
synchronized(flushLock)
|
||||
{
|
||||
if(queue.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
|
||||
{
|
||||
PendingLog pending;
|
||||
@@ -464,7 +553,7 @@ public class SecuredDataAccessLogger implements AutoCloseable
|
||||
}
|
||||
worker.shutdown();
|
||||
flush();
|
||||
if(Thread.currentThread() != shutdownHook)
|
||||
if(shutdownHook != null && Thread.currentThread() != shutdownHook)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -22,10 +22,11 @@ package com.kingsrook.qbits.secureddata;
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
|
||||
import com.kingsrook.qbits.secureddata.standin.AuditValueProtectorInterface;
|
||||
import com.kingsrook.qbits.secureddata.standin.DMLType;
|
||||
import com.kingsrook.qbits.secureddata.standin.ProtectedAuditValues;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditValueProtectorInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction.DMLType;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.ProtectedAuditValues;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
@@ -56,7 +57,7 @@ public final class SecuredDataAuditProtector implements AuditValueProtectorInter
|
||||
{
|
||||
boolean changed = RetentionRules.changedOnly(table.getName(), field.getName());
|
||||
String message = (oldValue == null ? "Set " : newValue == null ? "Removed " : "Changed ") + field.getLabel();
|
||||
return (new ProtectedAuditValues(oldValue == null ? null : changed ? "changed:v1" : CoreAuditBridge.token(table, field, pk, oldValue),
|
||||
newValue == null ? null : changed ? "changed:v1" : CoreAuditBridge.token(table, field, pk, newValue), message));
|
||||
return (new ProtectedAuditValues(oldValue == null ? null : changed ? "changed:v1" : ClassifiedValueTokens.forRecordField(table, field, pk, oldValue),
|
||||
newValue == null ? null : changed ? "changed:v1" : ClassifiedValueTokens.forRecordField(table, field, pk, newValue), message));
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,11 @@ public final class SecuredDataClassifications
|
||||
{
|
||||
return (DataClassification.valueOf(row.getValueString("classification")));
|
||||
}
|
||||
QFieldMetaData field = table.getField(fieldName);
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// getFields, not getField: a virtual field is not in the map and //
|
||||
// getField throws, while it still takes the table classification //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
QFieldMetaData field = table.getFields() == null || fieldName == null ? null : table.getFields().get(fieldName);
|
||||
DataClassification classification = field == null ? null : SecuredDataMetadata.classification(table, field);
|
||||
return (classification == null ? SecuredDataMetadata.classification(table, null) : classification);
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueKeySource;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueKeySource;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -280,7 +281,7 @@ public final class SecuredDataKeySource implements ClassifiedValueKeySource, Aut
|
||||
instanceOnly.set(true);
|
||||
try
|
||||
{
|
||||
return (CoreAuditBridge.token(table, field, pk, value));
|
||||
return (ClassifiedValueTokens.forRecordField(table, field, pk, value));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -29,13 +29,12 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration for policy resolution and bounded asynchronous access logging.
|
||||
*******************************************************************************/
|
||||
@@ -116,6 +115,7 @@ public class SecuredDataQBitConfig implements QBitConfig
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private int accessLogQueueCapacity = 4096;
|
||||
private long accessLogFlushIntervalMillis = 250;
|
||||
private boolean registerShutdownHook = true;
|
||||
private int exactMatchPerHour = 20;
|
||||
private int missAlertConsecutive = 5;
|
||||
private int distinctValueAlert = 15;
|
||||
@@ -217,6 +217,29 @@ public class SecuredDataQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the access logger registers a JVM shutdown hook that drains
|
||||
** accepted rows (default true). Hosts that call SecuredDataRuntime.close()
|
||||
** from their own shutdown sequence turn it off.
|
||||
*******************************************************************************/
|
||||
public boolean getRegisterShutdownHook()
|
||||
{
|
||||
return (registerShutdownHook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure registerShutdownHook.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withRegisterShutdownHook(boolean registerShutdownHook)
|
||||
{
|
||||
this.registerShutdownHook = registerShutdownHook;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure accessLogFlushIntervalMillis.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,7 +29,10 @@ import com.kingsrook.qbits.secureddata.metadata.SecuredDataTableCustomizers;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditValueProtectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueKeySourceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessResolverMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -39,7 +42,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point for secured-data policy, classification and access logging.
|
||||
*******************************************************************************/
|
||||
@@ -47,7 +49,7 @@ public class SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredData
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-secured-data";
|
||||
public static final String VERSION = "1.0.0";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private SecuredDataQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
@@ -171,8 +173,9 @@ public class SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredData
|
||||
runtime.addSelfToInstance(qInstance);
|
||||
try
|
||||
{
|
||||
CoreFieldAccessBridge.register(qInstance, runtime.resolver());
|
||||
CoreAuditBridge.register(qInstance, runtime);
|
||||
FieldAccessResolverMetaData.ofOrWithNew(qInstance).withResolverInstance(runtime.resolver());
|
||||
ClassifiedValueKeySourceMetaData.ofOrWithNew(qInstance).withKeySourceInstance(runtime.keys());
|
||||
AuditValueProtectionMetaData.ofOrWithNew(qInstance).withProtectorInstance(new SecuredDataAuditProtector());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
|
||||
@@ -36,14 +36,14 @@ import java.util.Objects;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessDecision;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEvent;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessResolverInterface;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldMaskStyle;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessDecision;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessResolverInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
@@ -57,7 +57,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seven-step policy resolution with instance-versioned, bounded per-user state.
|
||||
*******************************************************************************/
|
||||
@@ -76,9 +75,13 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
}
|
||||
|
||||
private long cachedVersion;
|
||||
private long policiesLoadedAt;
|
||||
private List<QRecord> policies = List.of();
|
||||
private final Map<String, UserState> users = new HashMap<>();
|
||||
|
||||
/** Request-scoped (QContext) holder of the access fingerprint. */
|
||||
private static final String FINGERPRINT_KEY = SecuredDataResolver.class.getName() + ".accessFingerprint";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -293,11 +296,13 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
private synchronized UserState state()
|
||||
{
|
||||
long version = runtime.policyVersion();
|
||||
if(cachedVersion != version)
|
||||
if(cachedVersion != version || SecuredDataRuntime.tooOld(policiesLoadedAt))
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
|
||||
users.clear();
|
||||
cachedVersion = version;
|
||||
policiesLoadedAt = loadedAt;
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
TreeSet<String> held = new TreeSet<>();
|
||||
@@ -309,7 +314,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
held.add(permission);
|
||||
}
|
||||
}
|
||||
String key = JsonUtils.toJson(List.of(userId(), held));
|
||||
String key = userId() + '\u0000' + String.join("\u0000", held);
|
||||
UserState state = users.get(key);
|
||||
if(state == null)
|
||||
{
|
||||
@@ -357,11 +362,82 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized String getAccessFingerprint()
|
||||
public String getAccessFingerprint()
|
||||
{
|
||||
state();
|
||||
TreeSet<String> relevant = new TreeSet<>();
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// core asks for the fingerprint before every field decision (once per //
|
||||
// field of every record of a result), so the value is kept for the //
|
||||
// request - and only while everything it is derived from is unchanged //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
QSession session = QContext.getQSession();
|
||||
String channel = Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "");
|
||||
Instant now = runtime.config().getClock().instant();
|
||||
RequestFingerprint kept = QContext.getObject(FINGERPRINT_KEY, RequestFingerprint.class).orElse(null);
|
||||
if(kept != null && kept.matches(this, session, channel, runtime.policyVersion(), runtime.classificationVersion(), now))
|
||||
{
|
||||
return (kept.value());
|
||||
}
|
||||
RequestFingerprint computed = computeAccessFingerprint(session, channel, now);
|
||||
QContext.setObject(FINGERPRINT_KEY, computed);
|
||||
return (computed.value());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint and what it was derived from. It stops matching when the
|
||||
** session, its permissions, the channel, a policy or classification version
|
||||
** changes, or when the first active break-glass grant expires.
|
||||
*******************************************************************************/
|
||||
private record RequestFingerprint(int resolver, QSession session, int permissionsIdentity, int permissionCount, String channel, long policyVersion,
|
||||
long classificationVersion, Instant validUntil, String value) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether the kept value is still the one a fresh computation would give.
|
||||
*******************************************************************************/
|
||||
boolean matches(SecuredDataResolver owner, QSession current, String currentChannel, long currentPolicyVersion, long currentClassificationVersion, Instant now)
|
||||
{
|
||||
return (resolver == System.identityHashCode(owner) && session == current && permissionsIdentity == identityOfPermissions(current) && permissionCount == countOfPermissions(current)
|
||||
&& channel.equals(currentChannel) && policyVersion == currentPolicyVersion && classificationVersion == currentClassificationVersion && now.isBefore(validUntil));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission set object of a session (it is replaced, not edited, when a
|
||||
** session is given new permissions).
|
||||
*******************************************************************************/
|
||||
private static int identityOfPermissions(QSession session)
|
||||
{
|
||||
return (session == null || session.getPermissions() == null ? 0 : System.identityHashCode(session.getPermissions()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many permissions a session holds (an edit in place changes it).
|
||||
*******************************************************************************/
|
||||
private static int countOfPermissions(QSession session)
|
||||
{
|
||||
return (session == null || session.getPermissions() == null ? -1 : session.getPermissions().size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||
*******************************************************************************/
|
||||
private synchronized RequestFingerprint computeAccessFingerprint(QSession session, String channel, Instant now)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// versions are read before the rows they describe, so a change during the //
|
||||
// computation leaves a kept value that no longer matches //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
long policyVersion = runtime.policyVersion();
|
||||
long classificationVersion = runtime.classificationVersion();
|
||||
TreeSet<String> relevant = new TreeSet<>();
|
||||
List<QRecord> breakGlass = state().breakGlass();
|
||||
for(QRecord row : policies)
|
||||
{
|
||||
String permission = row.getValueString("permission_name");
|
||||
@@ -381,12 +457,15 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> breaks = activeBreakGlass().stream().map(row -> row.getValueString("id")).sorted().toList();
|
||||
String payload = JsonUtils.toJson(List.of(userId(), relevant, runtime.policyVersion(), runtime.classificationVersion(), breaks,
|
||||
Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "")));
|
||||
List<QRecord> activeBreaks = breakGlass.stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
|
||||
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList();
|
||||
List<String> breaks = activeBreaks.stream().map(row -> row.getValueString("id")).sorted().toList();
|
||||
Instant validUntil = activeBreaks.stream().map(row -> row.getValueInstant("expires_at")).min(Instant::compareTo).orElse(Instant.MAX);
|
||||
String payload = JsonUtils.toJson(List.of(userId(), relevant, policyVersion, classificationVersion, breaks, channel));
|
||||
try
|
||||
{
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))));
|
||||
String value = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
return (new RequestFingerprint(System.identityHashCode(this), session, identityOfPermissions(session), countOfPermissions(session), channel, policyVersion, classificationVersion, validUntil, value));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
|
||||
@@ -21,9 +21,21 @@
|
||||
package com.kingsrook.qbits.secureddata;
|
||||
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
@@ -34,9 +46,33 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
|
||||
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
|
||||
{
|
||||
public static final String NAME = "securedDataRuntime";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(SecuredDataRuntime.class);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// the three sets of rule rows a node keeps in memory. A change clears them on //
|
||||
// this node at once and, through QQQ's cluster bus (ClusterCaches), on every //
|
||||
// other node. MAX_RULE_AGE is the safety net for a lost event: no node uses //
|
||||
// rule rows older than that without reading them again. //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
public static final String POLICY_CACHE = "securedData.policies";
|
||||
public static final String CLASSIFICATION_CACHE = "securedData.classifications";
|
||||
public static final String RETENTION_CACHE = "securedData.retentionRules";
|
||||
|
||||
public static final Duration MAX_RULE_AGE = Duration.ofSeconds(30);
|
||||
|
||||
static
|
||||
{
|
||||
ClusterCaches.shared(POLICY_CACHE, POLICY_CACHE, () -> clearOnThisNode(POLICY_CACHE), null);
|
||||
ClusterCaches.shared(CLASSIFICATION_CACHE, CLASSIFICATION_CACHE, () -> clearOnThisNode(CLASSIFICATION_CACHE), null);
|
||||
ClusterCaches.shared(RETENTION_CACHE, RETENTION_CACHE, () -> clearOnThisNode(RETENTION_CACHE), null);
|
||||
}
|
||||
|
||||
private static final AtomicLong STARTUP_VERSION = new AtomicLong(System.currentTimeMillis());
|
||||
private final AtomicLong classificationVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
||||
private final AtomicLong policyVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
||||
private final AtomicLong retentionVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
|
||||
private final transient WeakReference<QInstance> owner;
|
||||
private final transient SecuredDataQBitConfig config;
|
||||
private final transient SecuredDataResolver resolver;
|
||||
private final transient SecuredDataKeySource keys = new SecuredDataKeySource();
|
||||
@@ -44,6 +80,10 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
private final transient SecuredDataAnomalies anomalies;
|
||||
private transient long cachedClassificationVersion;
|
||||
private transient List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows = List.of();
|
||||
private transient long classificationRowsLoadedAt;
|
||||
private transient long cachedRetentionVersion;
|
||||
private transient long retentionRulesLoadedAt;
|
||||
private transient List<QRecord> activeRetentionRules = List.of();
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +93,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
this.owner = new WeakReference<>(instance);
|
||||
this.resolver = new SecuredDataResolver(this);
|
||||
this.anomalies = new SecuredDataAnomalies(config);
|
||||
this.logger = new SecuredDataAccessLogger(instance, this);
|
||||
@@ -137,6 +178,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
public void invalidateClassifications()
|
||||
{
|
||||
classificationVersion.incrementAndGet();
|
||||
announce(CLASSIFICATION_CACHE);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +189,102 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
public void invalidatePolicies()
|
||||
{
|
||||
policyVersion.incrementAndGet();
|
||||
announce(POLICY_CACHE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A retention rule changed: forget the active rules here and on every node.
|
||||
*******************************************************************************/
|
||||
public void invalidateRetentionRules()
|
||||
{
|
||||
retentionVersion.incrementAndGet();
|
||||
announce(RETENTION_CACHE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version of the retention rules this node holds.
|
||||
*******************************************************************************/
|
||||
public long retentionVersion()
|
||||
{
|
||||
return (retentionVersion.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether rows loaded at this System.nanoTime are too old to use unread.
|
||||
*******************************************************************************/
|
||||
static boolean tooOld(long loadedAtNanos)
|
||||
{
|
||||
return (System.nanoTime() - loadedAtNanos > MAX_RULE_AGE.toNanos());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every active retention rule, read once per version and at most MAX_RULE_AGE
|
||||
** old. A read that fails throws and leaves nothing in place of the rules:
|
||||
** callers never see "no rules" because the rules could not be read.
|
||||
*******************************************************************************/
|
||||
public synchronized List<QRecord> activeRetentionRules()
|
||||
{
|
||||
long version = retentionVersion();
|
||||
if(cachedRetentionVersion != version || tooOld(retentionRulesLoadedAt))
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
activeRetentionRules = List.copyOf(SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))));
|
||||
cachedRetentionVersion = version;
|
||||
retentionRulesLoadedAt = loadedAt;
|
||||
}
|
||||
return (activeRetentionRules);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tell the other nodes (this node has already moved its version). A failure
|
||||
** to send is logged: the other nodes then read again within MAX_RULE_AGE.
|
||||
*******************************************************************************/
|
||||
private void announce(String cache)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance instance = owner.get();
|
||||
if(instance != null)
|
||||
{
|
||||
ClusterBus.publish(instance, new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, cache, null));
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not announce a secured-data rule change; other nodes read again within " + MAX_RULE_AGE.toSeconds() + " seconds", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rule change announced on the cluster bus (also by this node): move the
|
||||
** version of the runtime of the instance the event arrived on.
|
||||
*******************************************************************************/
|
||||
private static void clearOnThisNode(String cache)
|
||||
{
|
||||
SecuredDataRuntime runtime = current();
|
||||
if(runtime == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch(cache)
|
||||
{
|
||||
case POLICY_CACHE -> runtime.policyVersion.incrementAndGet();
|
||||
case CLASSIFICATION_CACHE -> runtime.classificationVersion.incrementAndGet();
|
||||
default -> runtime.retentionVersion.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,11 +295,13 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
|
||||
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
|
||||
{
|
||||
long version = classificationVersion();
|
||||
if(cachedClassificationVersion != version)
|
||||
if(cachedClassificationVersion != version || tooOld(classificationRowsLoadedAt))
|
||||
{
|
||||
long loadedAt = System.nanoTime();
|
||||
classificationRows = List.copyOf(com.kingsrook.qbits.secureddata.support.SystemWrites.query(
|
||||
com.kingsrook.qbits.secureddata.metadata.SecuredDataTables.CLASSIFICATION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
|
||||
cachedClassificationVersion = version;
|
||||
classificationRowsLoadedAt = loadedAt;
|
||||
}
|
||||
return (classificationRows);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,14 @@ package com.kingsrook.qbits.secureddata.metadata;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String-valued possible-value sources shared by UI metadata and the DDL.
|
||||
*******************************************************************************/
|
||||
|
||||
+6
-1
@@ -238,7 +238,8 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
||||
}
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
|
||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
|
||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies
|
||||
: SecuredDataTables.RETENTION_RULE.equals(table) ? runtime::invalidateRetentionRules : null;
|
||||
if(invalidate != null)
|
||||
{
|
||||
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
|
||||
@@ -248,6 +249,10 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
||||
}
|
||||
else
|
||||
{
|
||||
if(SecuredDataTables.RETENTION_RULE.equals(table))
|
||||
{
|
||||
com.kingsrook.qbits.secureddata.retention.RetentionRules.editedInTransaction();
|
||||
}
|
||||
active.addAfterCommitCallback(invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,15 +58,68 @@ public final class RetentionRules
|
||||
return (names == null ? List.of() : Arrays.stream(names.split(",", -1)).map(String::trim).toList());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// set on a thread that changed a rule inside a transaction: until that //
|
||||
// transaction ends, the thread reads the rules through it (its own edits), //
|
||||
// not the rules every other thread shares //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
private static final ThreadLocal<Boolean> EDITED_IN_TRANSACTION = ThreadLocal.withInitial(() -> false);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh rules in the current transaction; edits take effect without a stale cache.
|
||||
** The active rules of a table that name this field. Core asks this for every
|
||||
** field of every audited write and every filter criterion of every query, so
|
||||
** the active rules are read once (SecuredDataRuntime.activeRetentionRules: per
|
||||
** change, and at most MAX_RULE_AGE old) instead of once per call. A committed
|
||||
** change is seen at once on the node that made it and on every node the
|
||||
** cluster event reaches; a transaction that edits rules reads its own edits.
|
||||
** A failed read throws: it is never "no rules".
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> active(String table, String field)
|
||||
{
|
||||
return (SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)))
|
||||
.stream().filter(rule -> fields(rule).contains(field)).toList());
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
List<QRecord> rules;
|
||||
if(runtime == null || readsOwnEdits())
|
||||
{
|
||||
rules = SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)));
|
||||
}
|
||||
else
|
||||
{
|
||||
rules = runtime.activeRetentionRules();
|
||||
}
|
||||
return (rules.stream().filter(rule -> table != null && table.equals(rule.getValueString("table_name"))).filter(rule -> fields(rule).contains(field)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rule was written on this thread inside a transaction that is still open.
|
||||
*******************************************************************************/
|
||||
public static void editedInTransaction()
|
||||
{
|
||||
EDITED_IN_TRANSACTION.set(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this thread must read the rules through its open transaction.
|
||||
*******************************************************************************/
|
||||
private static boolean readsOwnEdits()
|
||||
{
|
||||
if(!EDITED_IN_TRANSACTION.get())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(QContext.getQBackendTransaction() == null)
|
||||
{
|
||||
EDITED_IN_TRANSACTION.remove();
|
||||
return (false);
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -35,19 +35,20 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.secureddata.CoreAuditBridge;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataKeySource;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataRuntime;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.support.ActivityLog;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditShreddedEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
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.actions.tables.WipeAuditFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
@@ -292,7 +293,7 @@ public final class WipeService
|
||||
{
|
||||
throw (new QException("Audit key deletion failed."));
|
||||
}
|
||||
CoreAuditBridge.detail(table, pk, field, rule.getValueString("id"), true, transaction);
|
||||
AuditShreddedEvent.shreddedDetail(table, pk, table.getField(field), rule.getValueString("id"), transaction);
|
||||
}
|
||||
markers.add(new QRecord().withValue("table_name", table.getName()).withValue("record_id", String.valueOf(pk)).withValue("field_name", field)
|
||||
.withValue("rule_id", rule.getValue("id")).withValue("action", action).withValue("wiped_at", started));
|
||||
@@ -385,15 +386,9 @@ public final class WipeService
|
||||
patch.setValue(field, value);
|
||||
}
|
||||
UpdateInput input = new UpdateInput(table.getName()).withRecord(patch).withTransaction(transaction);
|
||||
CoreAuditBridge.flag(input, rule.getValueString("id"));
|
||||
input.setFlags(Set.of(new WipeAuditFlag(rule.getValueString("id"))));
|
||||
check(new UpdateAction().execute(input).getRecords());
|
||||
if(!CoreAuditBridge.available())
|
||||
{
|
||||
for(String field : fields)
|
||||
{
|
||||
CoreAuditBridge.detail(table, pk, field, rule.getValueString("id"), false, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -30,13 +30,13 @@ 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.SecuredDataMetadata;
|
||||
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.permissions.fieldaccess.FieldAccessTier;
|
||||
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;
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qbits.secureddata.standin.DMLType;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Application hook for audit tokens and effective classification. Never return plain values.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public interface AuditValueProtectorInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Protect the values in the current action's transaction before audit insertion.
|
||||
*******************************************************************************/
|
||||
ProtectedAuditValues protect(QTableMetaData table, QFieldMetaData field, Serializable primaryKey, Serializable oldValue, Serializable newValue, DMLType dmlType);
|
||||
|
||||
/*******************************************************************************
|
||||
** Override to use an application-owned classification source.
|
||||
*******************************************************************************/
|
||||
default DataClassification classificationOf(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
return (AiDataClassifications.ofField(table, field.getName()));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplies instance keys and optional per-record, per-field shreddable keys.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public interface ClassifiedValueKeySource
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The active instance key, if configured.
|
||||
*******************************************************************************/
|
||||
Optional<KeyAndId> instanceKey();
|
||||
|
||||
/*******************************************************************************
|
||||
** Verification uses create=false and must never recreate a destroyed key.
|
||||
*******************************************************************************/
|
||||
Optional<KeyAndId> keyForRecordField(String table, String field, Serializable pk, boolean create);
|
||||
|
||||
/*******************************************************************************
|
||||
** Key bytes and the public identifier stored in a token.
|
||||
*******************************************************************************/
|
||||
record KeyAndId(byte[] key, String id)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Own the key bytes; identifiers must keep tokens ASCII and under 200 characters.
|
||||
*******************************************************************************/
|
||||
public KeyAndId
|
||||
{
|
||||
if(key == null || key.length == 0 || id == null || !id.matches("[A-Za-z0-9._-]{1,140}"))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid classified-value key or identifier");
|
||||
}
|
||||
key = key.clone();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not expose mutable key material.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public byte[] key()
|
||||
{
|
||||
return (key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueKeySource.KeyAndId;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared, versioned audit/email value tokens. The MAC input is UTF-8.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public final class ClassifiedValueTokens
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Static utility.
|
||||
*******************************************************************************/
|
||||
private ClassifiedValueTokens()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind a value to the table, field and primary key, preferring a shreddable key.
|
||||
*******************************************************************************/
|
||||
public static String forRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
ClassifiedValueKeySource source = source();
|
||||
Optional<KeyAndId> key = source.keyForRecordField(table.getName(), field.getName(), pk, true);
|
||||
return key.isPresent() ? token("shred", key.get(), binding(table, field, pk), value)
|
||||
: instanceToken(source, binding(table, field, pk), value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-record bindings must start with a nonempty domain.
|
||||
*******************************************************************************/
|
||||
public static String forContext(List<String> bindingParts, Serializable value)
|
||||
{
|
||||
validateDomain(bindingParts);
|
||||
return value == null ? null : instanceToken(source(), bindingParts, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No derivative of the original value.
|
||||
*******************************************************************************/
|
||||
public static String changed()
|
||||
{
|
||||
return "changed:v1";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A key-destruction event, never a derivative of the value.
|
||||
*******************************************************************************/
|
||||
public static String shreddedToken(String ruleId)
|
||||
{
|
||||
if(ruleId == null || !ruleId.matches("[A-Za-z0-9._-]{1,180}"))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid shred rule identifier");
|
||||
}
|
||||
return "shredded:v1:" + ruleId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify only keyed tokens, using existing keys and constant-time MAC comparison.
|
||||
*******************************************************************************/
|
||||
public static boolean verifyRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable candidate, String token)
|
||||
{
|
||||
if(candidate == null || token == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ClassifiedValueKeySource source = source();
|
||||
if(token.startsWith("shred:v1:"))
|
||||
{
|
||||
return verify(source.keyForRecordField(table.getName(), field.getName(), pk, false), "shred", binding(table, field, pk), candidate, token);
|
||||
}
|
||||
return verify(source.instanceKey(), "hmac", binding(table, field, pk), candidate, token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify a non-record token without creating a key.
|
||||
*******************************************************************************/
|
||||
public static boolean verifyContext(List<String> bindingParts, Serializable candidate, String token)
|
||||
{
|
||||
validateDomain(bindingParts);
|
||||
return candidate != null && token != null && verify(source().instanceKey(), "hmac", bindingParts, candidate, token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical ValueUtils strings, with scale-independent plain decimal notation.
|
||||
*******************************************************************************/
|
||||
public static String canonical(Serializable value)
|
||||
{
|
||||
return value instanceof BigDecimal decimal ? decimal.stripTrailingZeros().toPlainString() : ValueUtils.getValueAsString(value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the context even for absent values.
|
||||
*******************************************************************************/
|
||||
private static void validateDomain(List<String> parts)
|
||||
{
|
||||
if(parts == null || parts.isEmpty() || parts.get(0) == null || parts.get(0).isBlank() || parts.stream().anyMatch(p -> p == null || p.indexOf('\u001F') >= 0))
|
||||
{
|
||||
throw new IllegalArgumentException("A nonempty domain and unambiguous binding parts are required");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The agreed record binding does not prepend an additional domain.
|
||||
*******************************************************************************/
|
||||
private static List<String> binding(QTableMetaData table, QFieldMetaData field, Serializable pk)
|
||||
{
|
||||
return List.of(table.getName(), field.getName(), String.valueOf(pk));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Emit a keyed token or a value-free fallback.
|
||||
*******************************************************************************/
|
||||
private static String instanceToken(ClassifiedValueKeySource source, List<String> parts, Serializable value)
|
||||
{
|
||||
return source.instanceKey().map(key -> token("hmac", key, parts, value)).orElseGet(ClassifiedValueTokens::changed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare full tokens, including the key identifier and version.
|
||||
*******************************************************************************/
|
||||
private static boolean verify(Optional<KeyAndId> key, String kind, List<String> parts, Serializable value, String supplied)
|
||||
{
|
||||
return key.map(k -> MessageDigest.isEqual(token(kind, k, parts, value).getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8))).orElse(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode the HMAC with URL-safe base64 and no padding.
|
||||
*******************************************************************************/
|
||||
private static String token(String kind, KeyAndId key, List<String> parts, Serializable value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key.key(), "HmacSHA256"));
|
||||
byte[] digest = mac.doFinal((String.join("\u001F", parts) + "\u001F" + canonical(value)).getBytes(StandardCharsets.UTF_8));
|
||||
return kind + ":v1:" + key.id() + ":" + Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
catch(GeneralSecurityException e)
|
||||
{
|
||||
throw new IllegalStateException("Unable to compute classified-value token", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use instance metadata, falling back to environment/property configuration.
|
||||
*******************************************************************************/
|
||||
private static ClassifiedValueKeySource source()
|
||||
{
|
||||
return (com.kingsrook.qbits.secureddata.SecuredDataRuntime.current().keys());
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* 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.standin;
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6's nested DMLType is package-private; #1182 makes it public.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public enum DMLType
|
||||
{
|
||||
INSERT, UPDATE, DELETE, OTHER
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolver decision; the reason must never contain a field value.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessDecision; delete when the pin moves to RC.7.
|
||||
public record FieldAccessDecision(FieldAccessTier tier, FieldMaskStyle maskStyle, String reason, boolean exactMatchAllowed)
|
||||
{
|
||||
public static final FieldAccessDecision FULL = new FieldAccessDecision(FieldAccessTier.FULL, null, null);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility constructor: exact-match searches are allowed by default.
|
||||
*******************************************************************************/
|
||||
public FieldAccessDecision(FieldAccessTier tier, FieldMaskStyle maskStyle, String reason)
|
||||
{
|
||||
this(tier, maskStyle, reason, true);
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Value-free audit notification. Primary key samples contain at most 50 keys.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent; delete when the pin moves to RC.7.
|
||||
public class FieldAccessEvent
|
||||
{
|
||||
private FieldAccessEventType type;
|
||||
private String tableName;
|
||||
private List<String> fieldNames;
|
||||
private List<Serializable> primaryKeys;
|
||||
private Integer recordCount;
|
||||
private String actionName;
|
||||
private FieldAccessOutcome outcome;
|
||||
private List<String> searchValueFingerprints;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public FieldAccessEventType getType()
|
||||
{
|
||||
return (this.type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for type
|
||||
*******************************************************************************/
|
||||
public void setType(FieldAccessEventType type)
|
||||
{
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for type
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withType(FieldAccessEventType type)
|
||||
{
|
||||
setType(type);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withTableName(String tableName)
|
||||
{
|
||||
setTableName(tableName);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldNames
|
||||
*******************************************************************************/
|
||||
public List<String> getFieldNames()
|
||||
{
|
||||
return (this.fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldNames
|
||||
*******************************************************************************/
|
||||
public void setFieldNames(List<String> fieldNames)
|
||||
{
|
||||
this.fieldNames = fieldNames == null ? null : new ArrayList<>(fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldNames
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withFieldNames(List<String> fieldNames)
|
||||
{
|
||||
setFieldNames(fieldNames);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for primaryKeys
|
||||
*******************************************************************************/
|
||||
public List<Serializable> getPrimaryKeys()
|
||||
{
|
||||
return (this.primaryKeys == null ? null : Collections.unmodifiableList(this.primaryKeys));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for primaryKeys
|
||||
*******************************************************************************/
|
||||
public void setPrimaryKeys(List<Serializable> primaryKeys)
|
||||
{
|
||||
this.primaryKeys = primaryKeys == null ? null : new ArrayList<>(primaryKeys.subList(0, Math.min(50, primaryKeys.size())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for primaryKeys
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withPrimaryKeys(List<Serializable> primaryKeys)
|
||||
{
|
||||
setPrimaryKeys(primaryKeys);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordCount
|
||||
*******************************************************************************/
|
||||
public Integer getRecordCount()
|
||||
{
|
||||
return (this.recordCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordCount
|
||||
*******************************************************************************/
|
||||
public void setRecordCount(Integer recordCount)
|
||||
{
|
||||
this.recordCount = recordCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordCount
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withRecordCount(Integer recordCount)
|
||||
{
|
||||
setRecordCount(recordCount);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for actionName
|
||||
*******************************************************************************/
|
||||
public String getActionName()
|
||||
{
|
||||
return (this.actionName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for actionName
|
||||
*******************************************************************************/
|
||||
public void setActionName(String actionName)
|
||||
{
|
||||
this.actionName = actionName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for actionName
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withActionName(String actionName)
|
||||
{
|
||||
setActionName(actionName);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outcome
|
||||
*******************************************************************************/
|
||||
public FieldAccessOutcome getOutcome()
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outcome
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withOutcome(FieldAccessOutcome outcome)
|
||||
{
|
||||
this.outcome = outcome;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for searchValueFingerprints
|
||||
*******************************************************************************/
|
||||
public List<String> getSearchValueFingerprints()
|
||||
{
|
||||
return (searchValueFingerprints);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for searchValueFingerprints
|
||||
*******************************************************************************/
|
||||
public FieldAccessEvent withSearchValueFingerprints(List<String> searchValueFingerprints)
|
||||
{
|
||||
this.searchValueFingerprints = searchValueFingerprints == null ? null : List.copyOf(searchValueFingerprints);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Kinds of field access reported to the resolver.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType; delete when the pin moves to RC.7.
|
||||
public enum FieldAccessEventType
|
||||
{
|
||||
VIEW, EXACT_MATCH_FILTER, DENIED
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of an exact-match search.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome; delete when the pin moves to RC.7.
|
||||
public enum FieldAccessOutcome
|
||||
{
|
||||
MATCH, NO_MATCH, REFUSED
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-aware policy, reveal grants and value-free access notifications.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessResolverInterface; delete when the pin moves to RC.7.
|
||||
public interface FieldAccessResolverInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve using the current QContext session.
|
||||
*******************************************************************************/
|
||||
FieldAccessDecision resolve(QTableMetaData table, QFieldMetaData field);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this record and field have an active reveal grant.
|
||||
*******************************************************************************/
|
||||
default boolean isRevealActive(String tableName, String fieldName, Serializable primaryKey)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identity of effective access, excluding reveal grants.
|
||||
*******************************************************************************/
|
||||
default String getAccessFingerprint()
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receive an audit notification; failures never prevent enforcement.
|
||||
*******************************************************************************/
|
||||
default void onAccess(FieldAccessEvent event)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse an exact-match search before execution; the exception message is user-facing.
|
||||
*******************************************************************************/
|
||||
default void checkExactMatch(FieldAccessEvent event) throws QPermissionDeniedException
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One-way fingerprint of a search literal. Raw values never enter access events.
|
||||
*******************************************************************************/
|
||||
default String fingerprintSearchValue(Serializable value)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective field access, ordered from least to most restrictive.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier; delete when the pin moves to RC.7.
|
||||
public enum FieldAccessTier
|
||||
{
|
||||
FULL, REVEALABLE, MASKED, HIDDEN;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** canReadValue
|
||||
*******************************************************************************/
|
||||
public boolean canReadValue()
|
||||
{
|
||||
return (this == FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** canSort
|
||||
*******************************************************************************/
|
||||
public boolean canSort()
|
||||
{
|
||||
return (this == FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** canGroupOrAggregate
|
||||
*******************************************************************************/
|
||||
public boolean canGroupOrAggregate()
|
||||
{
|
||||
return (this == FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** canQuickSearch
|
||||
*******************************************************************************/
|
||||
public boolean canQuickSearch()
|
||||
{
|
||||
return (this == FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** canWrite
|
||||
*******************************************************************************/
|
||||
public boolean canWrite()
|
||||
{
|
||||
return (this == FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the operator is available for this tier.
|
||||
*******************************************************************************/
|
||||
public boolean canFilter(QCriteriaOperator operator)
|
||||
{
|
||||
return (this == FULL || (this == REVEALABLE && (operator == QCriteriaOperator.EQUALS || operator == QCriteriaOperator.IN)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the field may be described to the caller.
|
||||
*******************************************************************************/
|
||||
public boolean isInMetaData()
|
||||
{
|
||||
return (this != HIDDEN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Combine tiers; null means FULL.
|
||||
*******************************************************************************/
|
||||
public FieldAccessTier mostRestrictive(FieldAccessTier other)
|
||||
{
|
||||
FieldAccessTier candidate = other == null ? FULL : other;
|
||||
return (ordinal() > candidate.ordinal() ? this : candidate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Combine tiers; null means FULL.
|
||||
*******************************************************************************/
|
||||
public FieldAccessTier leastRestrictive(FieldAccessTier other)
|
||||
{
|
||||
FieldAccessTier candidate = other == null ? FULL : other;
|
||||
return (ordinal() < candidate.ordinal() ? this : candidate);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presentation rules for a restricted value.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1172, removeBy=RC.7): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle; delete when the pin moves to RC.7.
|
||||
public enum FieldMaskStyle
|
||||
{
|
||||
AUTO, EMAIL, LAST_4, FIRST_LAST, FULL
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.standin;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens and a value-free message supplied before an audit is inserted.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1182, removeBy=RC.7)
|
||||
public record ProtectedAuditValues(String oldToken, String newToken, String message)
|
||||
{
|
||||
}
|
||||
@@ -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"
|
||||
+2
-25
@@ -35,8 +35,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.channelUser;
|
||||
@@ -55,34 +53,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core field access bridge it.
|
||||
** Core field access integration tests.
|
||||
*******************************************************************************/
|
||||
class CoreFieldAccessBridgeIT
|
||||
class CoreFieldAccessIT
|
||||
{
|
||||
static final String EMAIL = "CANARY-EMAIL-918273@example.invalid";
|
||||
static final String SSN = "CANARY-PHI-192837465";
|
||||
|
||||
/*******************************************************************************
|
||||
** Require core.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void requireCore()
|
||||
{
|
||||
boolean available;
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessResolverMetaData");
|
||||
available = true;
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
available = false;
|
||||
}
|
||||
Assumptions.assumeTrue(available, "QQQ RC.7 field-access core is not on this classpath");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@@ -35,9 +35,9 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.retention.WipeService;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueKeySource.KeyAndId;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueTokens;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueKeySource.KeyAndId;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.actions.reporting.ExportAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -56,8 +56,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.secureddata.RetentionTestSupport.rows;
|
||||
import static com.kingsrook.qbits.secureddata.RetentionTestSupport.rule;
|
||||
@@ -67,7 +65,6 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
/*******************************************************************************
|
||||
** SECURED-DATA section 15 proofs: a planted canary never leaves the host table,
|
||||
** and shredded audit tokens cannot be brute-forced once the data key is gone.
|
||||
** Both need the core audit-protection hooks (#1182), so RC.6 skips them.
|
||||
*******************************************************************************/
|
||||
class RetentionProofTest
|
||||
{
|
||||
@@ -76,17 +73,6 @@ class RetentionProofTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip unless core supplies the audit hooks.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void requireCoreHooks()
|
||||
{
|
||||
Assumptions.assumeTrue(CoreAuditBridge.available(), "QQQ core audit-protection hooks (#1182) are not on this classpath");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@@ -98,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)));
|
||||
}
|
||||
@@ -114,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,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.
|
||||
|
||||
@@ -20,23 +20,23 @@
|
||||
|
||||
package com.kingsrook.qbits.secureddata;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
|
||||
import com.kingsrook.qbits.secureddata.retention.RetentionWidgets;
|
||||
import com.kingsrook.qbits.secureddata.retention.WipeService;
|
||||
import com.kingsrook.qbits.secureddata.retention.WipeProcesses;
|
||||
import com.kingsrook.qbits.secureddata.standin.DMLType;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClassifiedValueTokens;
|
||||
import com.kingsrook.qbits.secureddata.retention.WipeService;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction.DMLType;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -50,8 +50,8 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
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.QSystemUserSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static com.kingsrook.qbits.secureddata.RetentionTestSupport.*;
|
||||
|
||||
@@ -31,9 +31,9 @@ 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.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
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;
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.kingsrook.qbits.secureddata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -33,9 +34,9 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.IntStream;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEvent;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEventType;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
@@ -46,10 +47,10 @@ 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.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.CLOCK;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.NOW;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.channelUser;
|
||||
@@ -360,4 +361,92 @@ class SecuredDataAccessLoggerTest
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The worker starts while the host is still building its instance. An idle
|
||||
** tick opens no context, so it never validates (or enriches) an unfinished
|
||||
** instance on the worker thread, and a failing tick is logged instead of
|
||||
** cancelling every later tick.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idleTicksNeverValidateAnUnfinishedInstance() throws Exception
|
||||
{
|
||||
setup();
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
QInstance unfinished = SecuredDataTestSupport.base();
|
||||
unfinished.addTable(new QTableMetaData().withName("unfinished").withBackendName("not_registered_yet"));
|
||||
SecuredDataAccessLogger idle = new SecuredDataAccessLogger(unfinished, runtime);
|
||||
try
|
||||
{
|
||||
idle.flush();
|
||||
idle.scheduledFlush();
|
||||
assertThat(unfinished.getHasBeenValidated()).isFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
idle.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Neither the worker thread nor the JVM shutdown hook keeps a discarded
|
||||
** instance alive: a host or test that drops its instance without closing
|
||||
** the runtime lets it be collected.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aDiscardedInstanceCanBeCollected() throws Exception
|
||||
{
|
||||
close();
|
||||
WeakReference<QInstance> discarded = discardedInstance();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
|
||||
List<byte[]> pressure = new ArrayList<>();
|
||||
while(discarded.get() != null && System.nanoTime() < deadline)
|
||||
{
|
||||
pressure.add(new byte[1 << 20]);
|
||||
if(pressure.size() > 64)
|
||||
{
|
||||
pressure.clear();
|
||||
}
|
||||
System.gc();
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertThat(discarded.get()).as("instance still reachable from the access logger").isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build, use and drop an instance whose worker ticks every 10 ms.
|
||||
*******************************************************************************/
|
||||
private static WeakReference<QInstance> discardedInstance() throws Exception
|
||||
{
|
||||
QInstance instance = SecuredDataTestSupport.base();
|
||||
instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("memory")
|
||||
.withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
|
||||
instance.addTable(person("memory"));
|
||||
new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig().withAccessLogFlushIntervalMillis(10)).produce(instance);
|
||||
Thread.sleep(50);
|
||||
QContext.clear();
|
||||
return (new WeakReference<>(instance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts that close the runtime themselves can skip the JVM hook; close
|
||||
** still drains and stops the worker.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shutdownHookIsOptional() throws Exception
|
||||
{
|
||||
setup(config -> config.withRegisterShutdownHook(false));
|
||||
var logger = SecuredDataRuntime.current().logger();
|
||||
assertThat(logger.isWorkerRunning()).isTrue();
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
logger.close();
|
||||
assertThat(logger.isWorkerRunning()).isFalse();
|
||||
assertThat(logs()).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,16 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.breakglass.BreakGlassService;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.reveal.RevealService;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessEventType;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessOutcome;
|
||||
import com.kingsrook.qbits.secureddata.support.ActivityLog;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.*;
|
||||
|
||||
@@ -33,7 +33,6 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.CLOCK;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.RUNTIMES;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.base;
|
||||
@@ -97,8 +96,8 @@ class SecuredDataDdlTest
|
||||
classification(null, "PII");
|
||||
policy("cs_admin", "PII", "REVEALABLE");
|
||||
assertThat(SecuredDataRuntime.current().resolver().resolve(instance.getTable("person"), instance.getTable("person").getField("email")).tier().name()).isEqualTo("REVEALABLE");
|
||||
SecuredDataRuntime.current().logger().onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qbits.secureddata.standin.FieldAccessEventType.EXACT_MATCH_FILTER,
|
||||
com.kingsrook.qbits.secureddata.standin.FieldAccessOutcome.MATCH, "hmac"));
|
||||
SecuredDataRuntime.current().logger().onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType.EXACT_MATCH_FILTER,
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome.MATCH, "hmac"));
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs()).extracting(row -> row.getValueString("event_type")).containsExactly("CLASSIFICATION_CHANGE", "POLICY_CHANGE", "EXACT_MATCH_FILTER");
|
||||
// Equal timestamps must be counted by SQL as well as the memory backend.
|
||||
|
||||
@@ -88,8 +88,8 @@ class SecuredDataHomeTest
|
||||
delete(SecuredDataTables.POLICY, policy);
|
||||
classification("email", "PII");
|
||||
var logger = SecuredDataRuntime.current().logger();
|
||||
logger.onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qbits.secureddata.standin.FieldAccessEventType.VIEW, null, null));
|
||||
logger.onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qbits.secureddata.standin.FieldAccessEventType.VIEW, null, null));
|
||||
logger.onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType.VIEW, null, null));
|
||||
logger.onAccess(SecuredDataAccessLoggerTest.event(com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType.VIEW, null, null));
|
||||
var data = SecuredDataHomeData.load();
|
||||
assertThat(data.getKpis()).containsEntry("Reveals today", 15L).containsEntry("Active reveal grants", 1L)
|
||||
.containsEntry("Active break-glass sessions", 1L).containsEntry("Anomaly flags (7 days)", 1L)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,11 +26,9 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.secureddata.metadata.FieldsOfTablePossibleValueSource;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataEnums;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -40,11 +38,12 @@ import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSo
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.NOW;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.RUNTIMES;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.base;
|
||||
@@ -89,7 +88,7 @@ class SecuredDataQBitProducerTest
|
||||
{
|
||||
QInstance instance = setup();
|
||||
assertThat(instance.getTables().keySet()).containsAll(SecuredDataTables.ALL);
|
||||
assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-secured-data").getVersion()).isEqualTo("1.0.0");
|
||||
assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-secured-data").getVersion()).isEqualTo("0.1.0");
|
||||
for(String name : SecuredDataTables.ALL)
|
||||
{
|
||||
QTableMetaData table = instance.getTable(name);
|
||||
|
||||
@@ -23,23 +23,23 @@ package com.kingsrook.qbits.secureddata;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldMaskStyle;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.CLOCK;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.NOW;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.channelUser;
|
||||
@@ -327,4 +327,22 @@ class SecuredDataResolverTest
|
||||
CLOCK.now = NOW.plusSeconds(10);
|
||||
assertThat(resolver.isRevealActive("person", "email", 7)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A virtual (computed) field is not in getFields, and getField throws for
|
||||
** it: it resolves through the table classification instead of failing the
|
||||
** whole read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void virtualFieldsTakeTheTableClassification() throws Exception
|
||||
{
|
||||
QVirtualFieldMetaData display = new QVirtualFieldMetaData("displayName", QFieldType.STRING);
|
||||
person.withVirtualFields(List.of(display));
|
||||
assertThat(SecuredDataClassifications.effective(person, "displayName")).isNull();
|
||||
assertThat(resolver.resolve(person, display).tier()).isEqualTo(FieldAccessTier.FULL);
|
||||
person.withSupplementalMetaData(new AiTableMetaData().withClassification(DataClassification.PII));
|
||||
assertThat(SecuredDataClassifications.effective(person, "displayName")).isEqualTo(DataClassification.PII);
|
||||
assertThat(resolver.resolve(person, display).tier()).isEqualTo(FieldAccessTier.MASKED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
|
||||
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
|
||||
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
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.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
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.close;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.delete;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rule rows a node keeps (retention rules, policies, classifications) and
|
||||
** the access fingerprint of a request: read once, cleared by every change on
|
||||
** every node, never older than the maximum age, and never an empty answer when
|
||||
** the rules cannot be read.
|
||||
*******************************************************************************/
|
||||
class SecuredDataRuleCachesTest
|
||||
{
|
||||
private static final List<String> ANNOUNCED = new ArrayList<>();
|
||||
|
||||
static
|
||||
{
|
||||
ClusterBus.on(ClusterEvent.TYPE_MEMO_INVALIDATE, (instance, event) -> ANNOUNCED.add(event.getName()));
|
||||
}
|
||||
|
||||
QInstance instance;
|
||||
QTableMetaData person;
|
||||
SecuredDataRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A person table with a date, so a retention rule on it is valid.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void init() throws Exception
|
||||
{
|
||||
instance = setup();
|
||||
person = instance.getTable("person");
|
||||
person.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("createDate"));
|
||||
runtime = SecuredDataRuntime.current();
|
||||
MemoryRecordStore.setCollectStatistics(true);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
ANNOUNCED.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
MemoryRecordStore.setCollectStatistics(false);
|
||||
close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An active rule on one field of the person table.
|
||||
*******************************************************************************/
|
||||
private static QRecord rule(String field) throws Exception
|
||||
{
|
||||
return (insert(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("name", "30 days: " + field).withValue("table_name", "person")
|
||||
.withValue("field_names", field).withValue("age_field_name", "createDate").withValue("age_days", 30).withValue("action", "NULL_WITH_MARKER")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queries the in-memory store has run since the last reset.
|
||||
*******************************************************************************/
|
||||
private static int queries()
|
||||
{
|
||||
return (MemoryRecordStore.getStatistics().getOrDefault(MemoryRecordStore.STAT_QUERIES_RAN, 0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core asks for the rules of a field on every audited field and filter
|
||||
** criterion: many questions are one read, and every committed change (insert,
|
||||
** update, delete) is seen by the next question.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void activeRetentionRulesAreReadOncePerChange() throws Exception
|
||||
{
|
||||
QRecord notes = rule("notes");
|
||||
assertThat(notes.getErrors()).isNullOrEmpty();
|
||||
MemoryRecordStore.resetStatistics();
|
||||
for(int i = 0; i < 50; i++)
|
||||
{
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
assertThat(RetentionRules.active("person", "email")).isEmpty();
|
||||
assertThat(RetentionRules.active("anotherTable", "notes")).isEmpty();
|
||||
assertThat(RetentionRules.changedOnly("person", "notes")).isFalse();
|
||||
}
|
||||
assertThat(queries()).isLessThanOrEqualTo(1);
|
||||
|
||||
QRecord ssn = rule("ssn");
|
||||
assertThat(RetentionRules.active("person", "ssn")).hasSize(1);
|
||||
|
||||
update(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("id", notes.getValue("id")).withValue("is_active", false));
|
||||
assertThat(RetentionRules.active("person", "notes")).isEmpty();
|
||||
|
||||
delete(SecuredDataTables.RETENTION_RULE, ssn);
|
||||
assertThat(RetentionRules.active("person", "ssn")).isEmpty();
|
||||
assertThat(ANNOUNCED).contains(SecuredDataRuntime.RETENTION_CACHE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rule change made on another node arrives as a cluster cache event: each
|
||||
** of the three kinds of rules is read again here. A change made here is
|
||||
** announced to the other nodes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aChangeOnAnyNodeClearsTheRulesOnEveryNode() throws Exception
|
||||
{
|
||||
rule("notes");
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
|
||||
long retention = runtime.retentionVersion();
|
||||
long policies = runtime.policyVersion();
|
||||
long classifications = runtime.classificationVersion();
|
||||
ClusterCaches.invalidate(SecuredDataRuntime.RETENTION_CACHE);
|
||||
ClusterCaches.invalidate(SecuredDataRuntime.POLICY_CACHE);
|
||||
ClusterCaches.invalidate(SecuredDataRuntime.CLASSIFICATION_CACHE);
|
||||
assertThat(runtime.retentionVersion()).isGreaterThan(retention);
|
||||
assertThat(runtime.policyVersion()).isGreaterThan(policies);
|
||||
assertThat(runtime.classificationVersion()).isGreaterThan(classifications);
|
||||
|
||||
MemoryRecordStore.resetStatistics();
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
assertThat(queries()).isGreaterThanOrEqualTo(1);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
assertThat(queries()).isZero();
|
||||
|
||||
ANNOUNCED.clear();
|
||||
runtime.invalidatePolicies();
|
||||
runtime.invalidateClassifications();
|
||||
runtime.invalidateRetentionRules();
|
||||
assertThat(ANNOUNCED).contains(SecuredDataRuntime.POLICY_CACHE, SecuredDataRuntime.CLASSIFICATION_CACHE, SecuredDataRuntime.RETENTION_CACHE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed: when the rules cannot be read, asking for them throws. The
|
||||
** rules read before are not used, and the answer is never an empty list.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rulesThatCannotBeReadAreNeverAnEmptyAnswer() throws Exception
|
||||
{
|
||||
rule("notes");
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
|
||||
QTableMetaData ruleTable = instance.getTables().remove(SecuredDataTables.RETENTION_RULE);
|
||||
try
|
||||
{
|
||||
runtime.invalidateRetentionRules();
|
||||
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
|
||||
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
finally
|
||||
{
|
||||
instance.getTables().put(SecuredDataTables.RETENTION_RULE, ruleTable);
|
||||
}
|
||||
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The safety net for a lost cluster event: rows are read again once they are
|
||||
** older than the maximum age.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ruleRowsAreNotUsedPastTheirMaximumAge()
|
||||
{
|
||||
assertThat(SecuredDataRuntime.tooOld(System.nanoTime())).isFalse();
|
||||
assertThat(SecuredDataRuntime.tooOld(System.nanoTime() - SecuredDataRuntime.MAX_RULE_AGE.plusSeconds(1).toNanos())).isTrue();
|
||||
assertThat(SecuredDataRuntime.MAX_RULE_AGE.toSeconds()).isLessThanOrEqualTo(60);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core asks for the access fingerprint before every field decision. Within a
|
||||
** request the same value object is returned; a permission that matters, given
|
||||
** to the same session object, changes it at once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theAccessFingerprintIsKeptForTheRequestAndTracksThePermissions()
|
||||
{
|
||||
SecuredDataResolver resolver = runtime.resolver();
|
||||
QSession session = QContext.getQSession();
|
||||
String first = resolver.getAccessFingerprint();
|
||||
for(int i = 0; i < 20; i++)
|
||||
{
|
||||
assertThat(resolver.getAccessFingerprint()).isSameAs(first);
|
||||
}
|
||||
|
||||
session.getPermissions().add("unrelated.permission");
|
||||
assertThat(resolver.getAccessFingerprint()).isEqualTo(first);
|
||||
|
||||
session.getPermissions().add(SecuredDataMetadata.permissionBase(person) + "." + SecuredDataPermissions.FULL);
|
||||
String granted = resolver.getAccessFingerprint();
|
||||
assertThat(granted).isNotEqualTo(first);
|
||||
|
||||
session.setPermissions(new HashSet<>(List.of("person.read")));
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(granted);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user