Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3290128de9
|
||
|
|
0963b45467
|
||
|
|
99c97932b6 | ||
|
|
3d50612a45
|
||
|
|
4e74994cc9 | ||
|
|
cc629078e9
|
No files matched your search
+58
-14
@@ -1,21 +1,65 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.5.0
|
||||
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
|
||||
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
|
||||
|
||||
workflows:
|
||||
build:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
|
||||
test_only:
|
||||
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+$/ ]
|
||||
@@ -0,0 +1,20 @@
|
||||
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -0,0 +1,25 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once. Security updates are enabled in the repository settings and are
|
||||
# not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
update-types: [minor, patch]
|
||||
@@ -0,0 +1,31 @@
|
||||
# GitHub Release Configuration
|
||||
# Auto-generates release notes from PR labels
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- duplicate
|
||||
- invalid
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
+16
-15
@@ -1,20 +1,21 @@
|
||||
# Maven
|
||||
target/
|
||||
.env
|
||||
.idea
|
||||
*.iml
|
||||
*.class
|
||||
*.log
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
hs_err_pid*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.sw*
|
||||
.flattened-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/.env.local
|
||||
/.cache/
|
||||
.claude/settings.local.json
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
@@ -0,0 +1,42 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
QBit Secured Data provides FULL/REVEALABLE/MASKED/HIDDEN field tiers by role × DataClassification, reveal grants, break-glass access and an access log. Masking is enforced in QQQ core. This is a scaffold only; domain behavior is not implemented yet.
|
||||
|
||||
Design: https://github.com/QRun-IO/qqq/blob/develop/docs/SECURED-DATA.md (§5–§8).
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
./mvnw compile
|
||||
./mvnw test
|
||||
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
|
||||
./mvnw -q -o verify
|
||||
```
|
||||
|
||||
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Package: `com.kingsrook.qbits.secureddata`. Entry point: `SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredDataQBitConfig>`. It registers QBit metadata and returns it in a `MetaDataProducerMultiOutput`, with no tables yet. Config exposes `tableMetaDataCustomizer` and an initially empty `Map<String, String> defaultTierByClassification`.
|
||||
|
||||
Planned tables: `secured_data_classification`, `secured_data_policy`, `secured_data_reveal_grant`, `secured_data_break_glass`, `secured_data_access_log`.
|
||||
|
||||
## Invariants and naming
|
||||
|
||||
QQQ core owns masking enforcement. Reveal, break-glass and access logging follow the spec; do not imply this scaffold enforces access. Tables use lowercase snake_case with the `secured_data_` prefix; backend names equal table names. Permissions use the full `secured_data` prefix. Processes are table or record actions, not navigation entries.
|
||||
|
||||
## Code style
|
||||
|
||||
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-SNAPSHOT`, pending the RC with fieldaccess hooks. Core and Javalin are compile dependencies; memory/RDBMS backends, frontend-next, H2, JUnit and AssertJ support tests. Do not add qbit-alerts yet.
|
||||
|
||||
## Release flow
|
||||
|
||||
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. Hotfix branches run tests. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never publish manually.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Phase (b) secured-data classification, policy, reveal-grant, break-glass and insert-only access-log tables, metadata, permissions, pickers and app navigation.
|
||||
- Versioned classification and policy resolution with channel caps, table features, break-glass expiry and access fingerprints.
|
||||
- Bounded asynchronous value-free logging, HMAC search fingerprints, rate limits, entropy rules and enumeration alerts.
|
||||
- RC.5-compatible stand-ins and reflective core adapters; conditional core Query/Get integration coverage.
|
||||
- PostgreSQL DDL, H2 persistence/constraint coverage, and configuration/deployment documentation.
|
||||
@@ -1,17 +1,42 @@
|
||||
# qbit-template-extension
|
||||
# CLAUDE.md
|
||||
|
||||
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## QQQ 4.0 baseline — 2026-09-24
|
||||
## Overview
|
||||
|
||||
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
|
||||
QBit Secured Data provides FULL/REVEALABLE/MASKED/HIDDEN field tiers by role × DataClassification, reveal grants, break-glass access and an access log. Masking is enforced in QQQ core. This is a scaffold only; domain behavior is not implemented yet.
|
||||
|
||||
## Knowledge base
|
||||
Design: https://github.com/QRun-IO/qqq/blob/develop/docs/SECURED-DATA.md (§5–§8).
|
||||
|
||||
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
|
||||
## Build commands
|
||||
|
||||
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
|
||||
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
|
||||
```bash
|
||||
./mvnw compile
|
||||
./mvnw test
|
||||
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
|
||||
./mvnw -q -o verify
|
||||
```
|
||||
|
||||
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
|
||||
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Package: `com.kingsrook.qbits.secureddata`. Entry point: `SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredDataQBitConfig>`. It registers QBit metadata and returns it in a `MetaDataProducerMultiOutput`, with no tables yet. Config exposes `tableMetaDataCustomizer` and an initially empty `Map<String, String> defaultTierByClassification`.
|
||||
|
||||
Planned tables: `secured_data_classification`, `secured_data_policy`, `secured_data_reveal_grant`, `secured_data_break_glass`, `secured_data_access_log`.
|
||||
|
||||
## Invariants and naming
|
||||
|
||||
QQQ core owns masking enforcement. Reveal, break-glass and access logging follow the spec; do not imply this scaffold enforces access. Tables use lowercase snake_case with the `secured_data_` prefix; backend names equal table names. Permissions use the full `secured_data` prefix. Processes are table or record actions, not navigation entries.
|
||||
|
||||
## Code style
|
||||
|
||||
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-SNAPSHOT`, pending the RC with fieldaccess hooks. Core and Javalin are compile dependencies; memory/RDBMS backends, frontend-next, H2, JUnit and AssertJ support tests. Do not add qbit-alerts yet.
|
||||
|
||||
## Release flow
|
||||
|
||||
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. Hotfix branches run tests. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never publish manually.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Contributing to QBit Secured Data
|
||||
|
||||
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
|
||||
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
|
||||
* Commits are signed and follow Conventional Commits.
|
||||
* Build and test locally with `./mvnw verify` (Java 21).
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
Copyright 2024 Kingsrook, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
qBit Template - Extension
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
QBit Secured Data
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
@@ -1,42 +1,146 @@
|
||||
# QBit Template: Extension
|
||||
# QBit Secured Data
|
||||
|
||||
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
|
||||
Phase (b) of [SECURED-DATA](https://github.com/QRun-IO/qqq/blob/develop/docs/SECURED-DATA.md): classification overrides, policy resolution, enumeration guardrails, and value-free access logging. QQQ core owns masking and query enforcement. Reveal/Start/End operations, home widgets, retention and audit-value protection are later phases.
|
||||
|
||||
## What is an Extension QBit?
|
||||
## Compatibility
|
||||
|
||||
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
|
||||
- New backend implementations
|
||||
- Authentication mechanisms
|
||||
- Audit capabilities
|
||||
- Table customizers
|
||||
- Action handlers
|
||||
Java 21, Maven wrapper 3.9.11, **QQQ 4.1.0-RC.5**. The released dependency pin is unchanged.
|
||||
|
||||
## Quick Start
|
||||
RC.5 contains AI exposure metadata, the alerts producer SPI and the tables picker. It does **not** contain field-access enforcement, `DataClassification`, classification properties on AI metadata, client channels, or table-feature permissions. Jar contents and embedded sources were inspected locally (`jar tf`; Git's `unzip` could not run in the Windows sandbox).
|
||||
|
||||
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
|
||||
Missing APIs live under `com.kingsrook.qbits.secureddata.standin`, with `STAND-IN` removal markers. `CoreFieldAccessBridge` registers a dynamic proxy only when core's `FieldAccessResolverMetaData` exists. It converts decision record components and event enums by name. On RC.5 it logs one INFO that enforcement requires RC.6+; installing this QBit on RC.5 **does not enforce field tiers**.
|
||||
|
||||
## Structure
|
||||
`CoreMetadataBridge` promotes temporary classifications into core AI metadata when supported, registers the channel wall and table features reflectively, and uses core feature-permission checks when present. A missing API is supported; a present but incompatible API fails startup. The public classification API temporarily returns the stand-in `DataClassification` because the core enum is absent from RC.5. At RC.6, move these imports to core and delete the adapters/stand-ins.
|
||||
|
||||
```
|
||||
src/main/java/com/kingsrook/qbits/example/
|
||||
├── ExampleExtensionQBitConfig.java # Configuration
|
||||
├── ExampleExtensionQBitProducer.java # Entry point
|
||||
└── customizers/
|
||||
└── ExampleTableCustomizer.java # Table customization
|
||||
## Install and register
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-secured-data</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Key Characteristics
|
||||
```java
|
||||
new SecuredDataQBitProducer()
|
||||
.withQBitConfig(new SecuredDataQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withDefaultTier(DataClassification.PII, FieldAccessTier.MASKED))
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
- **No QAppSection** - Pure infrastructure
|
||||
- **No user-facing tables** (or minimal operational)
|
||||
- **Wraps existing tables/processes** with new behavior
|
||||
- **Validates dependencies** on other QQQ components
|
||||
Register the host backend and application tables before producing the QBit. Enable core permissions (`qInstance.withEnforcePermissionsInCoreActions(true)`) at application entry points. Only one secured-data runtime is supported per instance. A namespace labels the QBit identity; table names remain fixed.
|
||||
|
||||
## Documentation
|
||||
The host supplies database connection metadata. Physical table names and field backend names are the same snake_case names as the QQQ metadata; an RDBMS host may set `RDBMSTableBackendDetails.withTableName(table.getName())` through `withTableMetaDataCustomizer(...)`. Apply [the PostgreSQL 15+ DDL](docs/ddl/secured-data-postgres.sql) before serving requests. The same SQL is tested with H2 in PostgreSQL mode.
|
||||
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Extension Patterns](docs/01-extension-patterns.md)
|
||||
## Tables, screens and permissions
|
||||
|
||||
| Table | Label | User access |
|
||||
|---|---|---|
|
||||
| `secured_data_classification` | Classification | Admin management |
|
||||
| `secured_data_policy` | Policy | Admin management |
|
||||
| `secured_data_reveal_grant` | Reveal | Admin read; system writes |
|
||||
| `secured_data_break_glass` | Break-glass session | Admin read; system writes |
|
||||
| `secured_data_access_log` | Access log | Admin read; system insert only |
|
||||
|
||||
Every table has audit rules, explicit record sections and CONFIDENTIAL code classification. The Secured Data app navigates to Classification, Policy, Access log and Break-glass session. Reveal grants are a read-only child list joined by `access_log_id`; no process or home widget appears in navigation. The classification target `(table_name, field_name)` is unique, including the null field used for a table-wide override.
|
||||
|
||||
`secured_data.admin` gates configuration editing and activity reads through a standard QQQ custom permission checker. It gives no implicit access to classified fields on application tables. `secured_data.hasAccess` is the catalog base permission; the custom checker requires admin. Activity writes are refused for ordinary users even when they hold admin. Customizers also protect system-only writes, and update/delete capabilities are disabled on the access log, including system calls.
|
||||
|
||||
Both table features are registered for **all tables**: `secured_data_full` (Secured data: full) and `secured_data_reveal` (Secured data: reveal). This deliberate catalog choice supports new admin classification rows without a restart. Core's permission-base, READ prerequisite and NOT_PROTECTED semantics are preserved: NOT_PROTECTED application tables grant their features without an explicit permission. Use protected table permission rules when field policies must restrict users. Feature grants have no standard WRITE/admin implication.
|
||||
|
||||
The assistant is denied `secured_data_*` through core client-channel metadata. The local custom checker enforces the same wall on RC.5 when the trusted host uses `standin.ClientChannelSession`. On newer core use `QSession.withClientChannel("assistant/t1")`. The normalized channel `assistant` is stored in logs and used for policy caps. A host must set the channel from trusted request context.
|
||||
|
||||
ESB integration follows webhooks' host-wiring pattern: `SecuredDataTables.esbTableNames()` lists Classification, Policy, Access log and Break-glass. Connect those names to the host ESB table-event producer. No ESB or qbit-alerts dependency is added.
|
||||
|
||||
## Classifying fields
|
||||
|
||||
Code metadata establishes defaults. While pinned to RC.5:
|
||||
|
||||
```java
|
||||
import com.kingsrook.qbits.secureddata.standin.AiFieldMetaData;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
|
||||
email.withSupplementalMetaData(
|
||||
new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
```
|
||||
|
||||
The matching stand-in `AiTableMetaData` sets a table default. These supplemental objects coexist with RC.5's core AI exposure metadata. The producer promotes them into core AI metadata when the newer API is available. After moving to RC.6, use `com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData` and core `DataClassification` with the same classification pattern.
|
||||
|
||||
Admin rows in Classification can raise **or lower** the default. Effective order:
|
||||
|
||||
1. Field-specific classification row.
|
||||
2. Table-wide row (`field_name` null).
|
||||
3. Code field classification.
|
||||
4. Code table classification.
|
||||
5. Unclassified.
|
||||
|
||||
Rows can also set `mask_style` and `low_cardinality`: true/false override entropy detection in either direction; null selects automatic rules. The tables picker reuses core's PVS, and the fields picker uses the selected `table_name`.
|
||||
|
||||
Other QBits use `SecuredDataClassifications.effective(table, fieldName)`, `effective(table)`, `highest(table)`, or `highest(table, fieldNames)` under their QContext. An empty field subset returns null. Rows are cached by classification version; startup and successful post-commit insert/update/delete change the version.
|
||||
|
||||
## Policy resolution and guardrails
|
||||
|
||||
Unclassified/PUBLIC fields and primary keys are FULL. Active, unexpired break-glass grants cover a table or all tables up to their classification ceiling. Full/reveal table features combine with the least restrictive matching non-channel policy tier. A policy matches an active row, the session's `permission_name`, classification and optional table. If no grant row matches, code defaults are CONFIDENTIAL FULL, PII MASKED and PHI MASKED.
|
||||
|
||||
A `client_channel` policy row **only caps** the result. Caps also restrict break-glass and feature grants; a channel-only FULL row cannot elevate a default MASKED tier. This makes the channel a ceiling regardless of how access was granted. The admin shortcut applies only to this QBit's own tables.
|
||||
|
||||
The four policy guardrail settings combine most restrictively across applicable rows. Positive numeric limits combine by minimum; zero disables a control only when no matching row enables it. `min_entropy_enabled` is enabled if any matching row enables it. When no policy matches, configuration defaults apply.
|
||||
|
||||
- `exact_match_per_hour`: 20 searches per user/table/field, including MATCH, NO_MATCH and REFUSED rows. The next request is refused with `Exact-match search limit reached for [field]. Try again later or ask an administrator.`
|
||||
- `miss_alert_consecutive`: 5 consecutive NO_MATCH outcomes, including across hour boundaries; other outcomes reset the streak.
|
||||
- `distinct_value_alert`: more than 15 HMAC fingerprints per hour flags the row.
|
||||
- `min_entropy_enabled`: disables exact match for boolean, PVS/enum, DATE/DATE_TIME, integer year names/labels, state/region/province/zip3 names/labels, and strings with maxLength under 6. Explicit classification-row true/false takes precedence over detection; disabling the control bypasses both.
|
||||
|
||||
Database history plus unflushed rows and local in-flight reservations prevent local concurrent overspending. Failed queries/dropped audit rows retain conservative reservations for an hour. Database history is shared, but another node's pending queue/reservations are not: this is not an atomic distributed quota. Hosts requiring that must serialize exact-search preflight across nodes.
|
||||
|
||||
Policy/break-glass caches are bounded per instance and keyed by relevant user permissions and policy version; expiry is rechecked at use time. Fingerprints hash user, held permissions mentioned in any policy or either table feature, policy/classification versions, active break-glass ids, and channel. Unrelated records/permissions and reveal grants do not change them. Multi-node hosts must propagate configuration/break-glass changes to `SecuredDataRuntime.invalidatePolicies()` / `invalidateClassifications()` on other nodes; local customizers only invalidate their own instance.
|
||||
|
||||
## Logging and lifecycle
|
||||
|
||||
`SecuredDataAccessLogger` captures identifiers, counts, normalized channel and outcomes, never field/search values. VIEW includes only configured classifications (PII/PHI by default), at most 50 primary keys encoded as JSON, and one row per table/action. Exact searches produce one row per affected field with MATCH/NO_MATCH/REFUSED; DENIED events become FILTER_DENIED. Break-glass id and refusal reason are recorded when available.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Fluent option | Default / purpose |
|
||||
|---|---|
|
||||
| `withBackendName(String)` | `memory`; host backend name |
|
||||
| `withTableMetaDataCustomizer(...)` | Optional host storage/security customization |
|
||||
| `withDefaultTier(DataClassification, FieldAccessTier)` | CONFIDENTIAL FULL; PII/PHI MASKED |
|
||||
| `withDefaultTierByClassification(Map<String,String>)` | Compatibility bulk setter; omitted restricted types fail closed |
|
||||
| `withNameHeuristics(boolean)` | false; opt-in PII for ssn/dob/date_of_birth/email/phone, PHI for diagnosis* |
|
||||
| `withViewLogClassifications(Set<DataClassification>)` | PII, PHI |
|
||||
| `withFingerprintKeySupplier(Supplier<byte[]>)` | Random 32-byte JVM key; supplied key must be at least 16 bytes |
|
||||
| `withAccessLogQueueCapacity(int)` | 4096 rows |
|
||||
| `withAccessLogFlushIntervalMillis(long)` | 250 ms |
|
||||
| `withExactMatchPerHour(int)` | 20; zero disables |
|
||||
| `withMissAlertConsecutive(int)` | 5; zero disables |
|
||||
| `withDistinctValueAlert(int)` | 15; zero disables |
|
||||
| `withMinEntropyEnabled(boolean)` | true |
|
||||
| `withClock(Clock)` | UTC system clock; injectable for deterministic expiry tests |
|
||||
|
||||
Configure before producing the QBit; runtime infrastructure and HMAC keys are captured at startup. Classification/policy/break-glass lookups always run as system, with a thread-local recursion guard. No reveal values are cached.
|
||||
|
||||
## Build and tests
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -q -o verify
|
||||
./mvnw.cmd -q -o -Dqqq.snapshot.version=4.1.0-SECUREDATA-SNAPSHOT verify
|
||||
```
|
||||
|
||||
The direct suite uses memory and H2. `CoreFieldAccessBridgeIT` is included by Surefire and uses JUnit assumptions to skip unless the actual core resolver metadata is present. With the installed core snapshot it exercises Query/Get, policy tiers, masking, exact matches and outcomes, sorting denial, rate/entropy refusal and the assistant wall. Checkstyle and JaCoCo remain 70% instructions / 90% classes; stand-ins are not excluded.
|
||||
|
||||
## Remaining phases
|
||||
|
||||
RC.6 migration removes temporary types and reflection. Phase (c) adds Reveal and Start/End break-glass operations and expiry scheduling; this phase already reads active grants/sessions. Phase (e) adds the app home and broader anomaly rules. Retention, wipe and audit-value protection remain phases (f)/(g).
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
[Apache License 2.0](LICENSE). Copyright Kingsrook, LLC.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
|
||||
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
|
||||
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. 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.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<!--
|
||||
See reference at https://checkstyle.sourceforge.io/checks.html
|
||||
-->
|
||||
<module name = "Checker">
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<!--
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
-->
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder"/>
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap"/>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
-->
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="3"/>
|
||||
<property name="braceAdjustment" value="0"/>
|
||||
<property name="caseIndent" value="3"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="3"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="1"/>
|
||||
</module>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
</module>
|
||||
-->
|
||||
<module name="CustomImportOrder">
|
||||
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
|
||||
<property name="specialImportsRegExp" value="^javax\."/>
|
||||
<property name="standardPackageRegExp" value="^java\."/>
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="false"/>
|
||||
</module>
|
||||
<module name="MethodParamPad"/>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad"/>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation"/>
|
||||
|
||||
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
|
||||
<module name="MissingOverride"/>
|
||||
|
||||
</module>
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
@@ -1,28 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
|
||||
|
||||
1. Choose **Use this template** on GitHub, then clone the repository you created.
|
||||
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
|
||||
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
|
||||
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
|
||||
5. Build the generated project:
|
||||
|
||||
```bash
|
||||
mvn clean verify
|
||||
```
|
||||
|
||||
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
|
||||
|
||||
## Register with a host application
|
||||
|
||||
The following uses the original example names; substitute the names chosen above:
|
||||
|
||||
```java
|
||||
new ExampleExtensionQBitProducer()
|
||||
.withConfig(new ExampleExtensionQBitConfig()
|
||||
.withTargetTableName("order"))
|
||||
.produce(qInstance, "my-extension");
|
||||
```
|
||||
|
||||
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
|
||||
@@ -1,16 +0,0 @@
|
||||
# Extension Patterns
|
||||
|
||||
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
|
||||
|
||||
```java
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Target table not found: " + tableName);
|
||||
}
|
||||
new ExampleTableCustomizer().customize(table);
|
||||
```
|
||||
|
||||
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
|
||||
|
||||
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
|
||||
@@ -0,0 +1 @@
|
||||
The design lives in QRun-IO/qqq docs/SECURED-DATA.md; this repo implements §5–§8.
|
||||
@@ -0,0 +1,95 @@
|
||||
-- PostgreSQL 15+ (NULLS NOT DISTINCT makes the table-wide classification unique).
|
||||
-- Extra value-free fingerprint column supports enumeration detection across restarts/nodes.
|
||||
|
||||
CREATE TABLE secured_data_classification (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
table_name VARCHAR(255) NOT NULL,
|
||||
field_name VARCHAR(255),
|
||||
classification VARCHAR(255) NOT NULL,
|
||||
mask_style VARCHAR(255),
|
||||
low_cardinality BOOLEAN,
|
||||
notes TEXT,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE,
|
||||
modified_by VARCHAR(255),
|
||||
CONSTRAINT secured_data_classification_target UNIQUE NULLS NOT DISTINCT (table_name, field_name)
|
||||
);
|
||||
|
||||
CREATE TABLE secured_data_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
permission_name VARCHAR(255) NOT NULL,
|
||||
classification VARCHAR(255) NOT NULL,
|
||||
table_name VARCHAR(255),
|
||||
tier VARCHAR(255) NOT NULL,
|
||||
reveal_minutes INTEGER DEFAULT 5,
|
||||
daily_reveal_cap INTEGER,
|
||||
reason_options TEXT,
|
||||
allow_free_text_reason BOOLEAN DEFAULT true,
|
||||
require_free_text BOOLEAN DEFAULT false,
|
||||
client_channel VARCHAR(255),
|
||||
exact_match_per_hour INTEGER DEFAULT 20,
|
||||
miss_alert_consecutive INTEGER DEFAULT 5,
|
||||
distinct_value_alert INTEGER DEFAULT 15,
|
||||
min_entropy_enabled BOOLEAN DEFAULT true,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE,
|
||||
modified_by VARCHAR(255),
|
||||
CHECK (reveal_minutes BETWEEN 1 AND 60),
|
||||
CHECK (exact_match_per_hour >= 0 AND miss_alert_consecutive >= 0 AND distinct_value_alert >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE secured_data_reveal_grant (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255),
|
||||
table_name VARCHAR(255) NOT NULL,
|
||||
record_id VARCHAR(255) NOT NULL,
|
||||
field_name VARCHAR(255) NOT NULL,
|
||||
reason VARCHAR(255),
|
||||
reason_text TEXT,
|
||||
granted_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
access_log_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE secured_data_break_glass (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255),
|
||||
reason VARCHAR(255),
|
||||
reason_text TEXT,
|
||||
classification_ceiling VARCHAR(255) DEFAULT 'PHI',
|
||||
table_name VARCHAR(255),
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
ended_at TIMESTAMP WITH TIME ZONE,
|
||||
ended_by VARCHAR(255),
|
||||
status VARCHAR(255) DEFAULT 'ACTIVE'
|
||||
);
|
||||
|
||||
CREATE TABLE secured_data_access_log (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
event_type VARCHAR(255) NOT NULL,
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255),
|
||||
table_name VARCHAR(255) NOT NULL,
|
||||
record_id TEXT,
|
||||
field_name TEXT,
|
||||
record_count INTEGER,
|
||||
action_name VARCHAR(255),
|
||||
reason VARCHAR(255),
|
||||
reason_text TEXT,
|
||||
client_channel VARCHAR(255),
|
||||
outcome VARCHAR(255),
|
||||
anomaly_flag BOOLEAN DEFAULT false,
|
||||
break_glass_id INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
search_value_fingerprints TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX secured_data_policy_lookup ON secured_data_policy (is_active, permission_name, classification, table_name);
|
||||
CREATE INDEX secured_data_break_glass_lookup ON secured_data_break_glass (user_id, status, expires_at);
|
||||
CREATE INDEX secured_data_reveal_lookup ON secured_data_reveal_grant (user_id, table_name, field_name, record_id, expires_at);
|
||||
CREATE INDEX secured_data_access_lookup ON secured_data_access_log (user_id, table_name, field_name, event_type, created_at);
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you 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
|
||||
#
|
||||
# http://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.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -1,36 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-example-extension</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Example Extension</name>
|
||||
<description>Example Extension QBit - rename this for your QBit</description>
|
||||
<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>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<h2.version>2.2.224</h2.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-secured-data</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Secured Data</name>
|
||||
<description>QBit for role-based field access by data classification, reveal grants, break-glass access and access logging in QQQ applications</description>
|
||||
<url>https://github.com/QRun-IO/qbit-secured-data</url>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-secured-data.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-secured-data.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-secured-data</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- The memory backend is included in qqq-backend-core. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>${h2.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -45,14 +116,115 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.12.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<headerLocation>checkstyle/license.txt</headerLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
<profiles>
|
||||
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<!-- Pinned to the latest released QQQ RC (secured-data#4). Raise to RC.6, which ships the fieldaccess hooks; local builds against unreleased core pass -Dqqq.snapshot.version=... -->
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -1,75 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Configuration for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitConfig implements QBitConfig
|
||||
{
|
||||
private String targetTableName;
|
||||
private Boolean enableFeatureX = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate configuration before QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
|
||||
{
|
||||
errors.add("Target table not found: " + targetTableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTableName
|
||||
*******************************************************************************/
|
||||
public String getTargetTableName()
|
||||
{
|
||||
return targetTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTableName
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
|
||||
{
|
||||
this.targetTableName = targetTableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public Boolean getEnableFeatureX()
|
||||
{
|
||||
return enableFeatureX;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
|
||||
{
|
||||
this.enableFeatureX = enableFeatureX;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-extension";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleExtensionQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce this QBit into the given QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
if(config != null && config.getTargetTableName() != null)
|
||||
{
|
||||
applyTableCustomizations(qInstance, config.getTargetTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the target table.
|
||||
*******************************************************************************/
|
||||
private void applyTableCustomizations(QInstance qInstance, String tableName)
|
||||
{
|
||||
// Implement your extension logic here
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for config
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example table customizer.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.customizers;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Example table customizer that adds behavior to existing tables.
|
||||
*******************************************************************************/
|
||||
public class ExampleTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the given table.
|
||||
*******************************************************************************/
|
||||
public void customize(QTableMetaData table)
|
||||
{
|
||||
// Example: Add audit tracking fields
|
||||
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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.
|
||||
*******************************************************************************/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Executors;
|
||||
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.CoreMetadataBridge;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
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.tables.QTableMetaData;
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataAccessLogger implements AutoCloseable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SecuredDataAccessLogger.class);
|
||||
private final QInstance instance;
|
||||
private final SecuredDataRuntime runtime;
|
||||
private final ArrayBlockingQueue<PendingLog> queue;
|
||||
private final ScheduledExecutorService worker;
|
||||
private final Thread shutdownHook;
|
||||
private final AtomicLong dropped = new AtomicLong();
|
||||
private final Object flushLock = new Object();
|
||||
private final Object lifecycleLock = new Object();
|
||||
private final ThreadLocal<String> refusalReason = new ThreadLocal<>();
|
||||
private final Map<String, ArrayDeque<Instant>> reservations = new HashMap<>();
|
||||
private volatile boolean closed;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Settings are captured for the acting user, before the worker becomes system.
|
||||
*******************************************************************************/
|
||||
private record PendingLog(QRecord row, SecuredDataResolver.Guardrails guardrails)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One daemon drains this instance's queue; the JVM hook flushes on shutdown.
|
||||
*******************************************************************************/
|
||||
public SecuredDataAccessLogger(QInstance instance, SecuredDataRuntime runtime)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.runtime = runtime;
|
||||
this.queue = new ArrayBlockingQueue<>(runtime.config().getAccessLogQueueCapacity());
|
||||
this.worker = Executors.newSingleThreadScheduledExecutor(task ->
|
||||
{
|
||||
Thread thread = new Thread(task, "secured-data-access-log");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop count for operational monitoring and the future app home.
|
||||
*******************************************************************************/
|
||||
public long getDroppedCount()
|
||||
{
|
||||
return (dropped.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot identifiers only. A failed/full logger must never fail the read.
|
||||
*******************************************************************************/
|
||||
public void onAccess(FieldAccessEvent event)
|
||||
{
|
||||
if(SystemWrites.active())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
QTableMetaData table = instance.getTable(event.getTableName());
|
||||
if(table == null || SecuredDataTables.ALL.contains(table.getName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<String> fields = event.getFieldNames() == null ? List.of() : event.getFieldNames();
|
||||
if(event.getType() == FieldAccessEventType.VIEW)
|
||||
{
|
||||
fields = fields.stream().filter(field -> runtime.config().getViewLogClassifications().contains(SecuredDataClassifications.effective(table, field))).toList();
|
||||
if(fields.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(event.getType() == FieldAccessEventType.EXACT_MATCH_FILTER)
|
||||
{
|
||||
for(String field : fields.stream().distinct().toList())
|
||||
{
|
||||
offer(row(event, List.of(field)), runtime.resolver().guardrails(table, field));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
offer(row(event, fields), null);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
drop("Unable to queue secured-data access log");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a row without copying record values, arbitrary reasons or filter objects.
|
||||
*******************************************************************************/
|
||||
private QRecord row(FieldAccessEvent event, List<String> fields)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
String type = event.getType() == FieldAccessEventType.DENIED ? "FILTER_DENIED" : event.getType().name();
|
||||
String outcome = event.getOutcome() == null ? (event.getType() == FieldAccessEventType.DENIED ? "DENIED" : "ALLOWED") : event.getOutcome().name();
|
||||
QRecord row = new QRecord().withValue("event_type", type).withValue("user_id", SecuredDataResolver.userId())
|
||||
.withValue("user_name", session == null || session.getUser() == null ? null : session.getUser().getFullName())
|
||||
.withValue("table_name", event.getTableName()).withValue("field_name", String.join(",", fields))
|
||||
.withValue("record_count", event.getRecordCount()).withValue("action_name", event.getActionName())
|
||||
.withValue("client_channel", CoreMetadataBridge.channel(session)).withValue("outcome", outcome)
|
||||
.withValue("anomaly_flag", false).withValue("created_at", runtime.config().getClock().instant());
|
||||
if(event.getPrimaryKeys() != null)
|
||||
{
|
||||
row.setValue("record_id", JsonUtils.toJson(event.getPrimaryKeys().stream().limit(50).toList()));
|
||||
}
|
||||
if(event.getSearchValueFingerprints() != null)
|
||||
{
|
||||
row.setValue("search_value_fingerprints", JsonUtils.toJson(event.getSearchValueFingerprints()));
|
||||
}
|
||||
if(event.getOutcome() == FieldAccessOutcome.REFUSED)
|
||||
{
|
||||
row.setValue("reason", Objects.requireNonNullElse(refusalReason.get(), "Exact-match search refused by access policy or enumeration guardrail."));
|
||||
refusalReason.remove();
|
||||
}
|
||||
QTableMetaData table = instance.getTable(event.getTableName());
|
||||
QRecord breakGlass = runtime.resolver().activeBreakGlass(table.getName(), SecuredDataClassifications.highest(table, fields));
|
||||
if(breakGlass != null)
|
||||
{
|
||||
row.setValue("break_glass_id", breakGlass.getValue("id"));
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A full queue drops without waiting, warning only with value-free text.
|
||||
*******************************************************************************/
|
||||
private void offer(QRecord row, SecuredDataResolver.Guardrails guardrails)
|
||||
{
|
||||
synchronized(lifecycleLock)
|
||||
{
|
||||
if(closed || !queue.offer(new PendingLog(row, guardrails)))
|
||||
{
|
||||
drop("Secured-data access-log queue is full or closed; dropped one row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep failed persistence observable without dumping sensitive record objects.
|
||||
*******************************************************************************/
|
||||
private void drop(String message)
|
||||
{
|
||||
dropped.incrementAndGet();
|
||||
LOG.warn(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authoritative persisted count plus queued rows and uncompleted local searches.
|
||||
** Serializes preflight and drain accounting, never ordinary VIEW enqueues.
|
||||
*******************************************************************************/
|
||||
public void checkExactMatch(FieldAccessEvent event) throws QPermissionDeniedException
|
||||
{
|
||||
synchronized(flushLock)
|
||||
{
|
||||
refusalReason.remove();
|
||||
String user = SecuredDataResolver.userId();
|
||||
QTableMetaData table = instance.getTable(event.getTableName());
|
||||
Instant now = runtime.config().getClock().instant();
|
||||
Instant since = now.minus(1, ChronoUnit.HOURS);
|
||||
expireReservations(since);
|
||||
List<String> fields = event.getFieldNames().stream().distinct().toList();
|
||||
for(String field : fields)
|
||||
{
|
||||
if(!runtime.resolver().resolve(table, table.getField(field)).exactMatchAllowed())
|
||||
{
|
||||
refusalReason.set("Exact-match search is disabled for this low-cardinality field.");
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
int limit = runtime.resolver().guardrails(table, field).exactMatchPerHour();
|
||||
if(limit == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<QRecord> persisted = history(user, table.getName(), field, since);
|
||||
long pending = queue.stream().filter(item -> matches(item.row(), user, table.getName(), field, since)).count();
|
||||
long reserved = reservations.getOrDefault(key(user, table.getName(), field), new ArrayDeque<>()).size();
|
||||
// Accepted outcomes in the queue still have their reservation until flush.
|
||||
long completedReserved = queue.stream().filter(item -> matches(item.row(), user, table.getName(), field, since))
|
||||
.filter(item -> !"REFUSED".equals(item.row().getValueString("outcome"))).count();
|
||||
if(persisted.size() + pending + Math.max(0, reserved - completedReserved) >= limit)
|
||||
{
|
||||
String reason = "Exact-match search limit reached for [" + field + "]. Try again later or ask an administrator.";
|
||||
refusalReason.set(reason);
|
||||
throw (new QPermissionDeniedException(reason));
|
||||
}
|
||||
}
|
||||
for(String field : fields)
|
||||
{
|
||||
if(runtime.resolver().guardrails(table, field).exactMatchPerHour() > 0)
|
||||
{
|
||||
reservations.computeIfAbsent(key(user, table.getName(), field), ignored -> new ArrayDeque<>()).addLast(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retire conservative reservations left behind by failed queries or dropped logs.
|
||||
*******************************************************************************/
|
||||
private void expireReservations(Instant since)
|
||||
{
|
||||
reservations.values().forEach(times -> times.removeIf(time -> !time.isAfter(since)));
|
||||
reservations.values().removeIf(ArrayDeque::isEmpty);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier-safe composite key.
|
||||
*******************************************************************************/
|
||||
private static String key(String user, String table, String field)
|
||||
{
|
||||
return (JsonUtils.toJson(List.of(user, table, field)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The queue contains one exact-match row per field, matching the indexed query.
|
||||
*******************************************************************************/
|
||||
private static boolean matches(QRecord row, String user, String table, String field, Instant since)
|
||||
{
|
||||
return ("EXACT_MATCH_FILTER".equals(row.getValueString("event_type")) && Objects.equals(user, row.getValueString("user_id"))
|
||||
&& Objects.equals(table, row.getValueString("table_name")) && Objects.equals(field, row.getValueString("field_name"))
|
||||
&& row.getValueInstant("created_at").isAfter(since));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Indexed history lookup, always under a system session.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> history(String user, String table, String field, Instant since)
|
||||
{
|
||||
return (SystemWrites.query(SecuredDataTables.ACCESS_LOG, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, user))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table))
|
||||
.withCriteria(new QFilterCriteria("field_name", QCriteriaOperator.EQUALS, field))
|
||||
.withCriteria(new QFilterCriteria("event_type", QCriteriaOperator.EQUALS, "EXACT_MATCH_FILTER"))
|
||||
.withCriteria(new QFilterCriteria("created_at", QCriteriaOperator.GREATER_THAN, since))
|
||||
.withOrderBy(new QFilterOrderBy("created_at", false)).withOrderBy(new QFilterOrderBy("id", false))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drain safely both from the worker and explicit test/host shutdown calls.
|
||||
*******************************************************************************/
|
||||
public void flush()
|
||||
{
|
||||
synchronized(flushLock)
|
||||
{
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
|
||||
{
|
||||
PendingLog pending;
|
||||
while((pending = queue.peek()) != null)
|
||||
{
|
||||
QRecord row = pending.row();
|
||||
try
|
||||
{
|
||||
boolean anomaly = isAnomaly(pending);
|
||||
row.setValue("anomaly_flag", anomaly);
|
||||
SystemWrites.insertOne(SecuredDataTables.ACCESS_LOG, row);
|
||||
if(anomaly)
|
||||
{
|
||||
raiseAlert(row);
|
||||
}
|
||||
completeReservation(row);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
drop("Unable to persist secured-data access log; dropped one row");
|
||||
}
|
||||
finally
|
||||
{
|
||||
queue.poll();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only persisted successful searches release their in-flight reservation.
|
||||
*******************************************************************************/
|
||||
private void completeReservation(QRecord row)
|
||||
{
|
||||
if(!"EXACT_MATCH_FILTER".equals(row.getValueString("event_type")) || "REFUSED".equals(row.getValueString("outcome")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
ArrayDeque<Instant> times = reservations.get(key(row.getValueString("user_id"), row.getValueString("table_name"), row.getValueString("field_name")));
|
||||
if(times != null)
|
||||
{
|
||||
times.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate each row in drain order against already persisted predecessors.
|
||||
*******************************************************************************/
|
||||
private boolean isAnomaly(PendingLog pending) throws java.io.IOException
|
||||
{
|
||||
SecuredDataResolver.Guardrails settings = pending.guardrails();
|
||||
if(settings == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord row = pending.row();
|
||||
List<QRecord> history = history(row.getValueString("user_id"), row.getValueString("table_name"), row.getValueString("field_name"),
|
||||
row.getValueInstant("created_at").minus(1, ChronoUnit.HOURS));
|
||||
int misses = "NO_MATCH".equals(row.getValueString("outcome")) ? 1 : 0;
|
||||
if(misses > 0 && settings.missAlertConsecutive() > 0)
|
||||
{
|
||||
List<QRecord> recent = SystemWrites.query(SecuredDataTables.ACCESS_LOG, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, row.getValueString("user_id")))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, row.getValueString("table_name")))
|
||||
.withCriteria(new QFilterCriteria("field_name", QCriteriaOperator.EQUALS, row.getValueString("field_name")))
|
||||
.withCriteria(new QFilterCriteria("event_type", QCriteriaOperator.EQUALS, "EXACT_MATCH_FILTER"))
|
||||
.withOrderBy(new QFilterOrderBy("created_at", false)).withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(Math.max(1, settings.missAlertConsecutive())));
|
||||
for(QRecord previous : recent)
|
||||
{
|
||||
if(!"NO_MATCH".equals(previous.getValueString("outcome")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
Set<String> distinct = new HashSet<>();
|
||||
List<QRecord> combined = new ArrayList<>(history);
|
||||
combined.add(row);
|
||||
for(QRecord item : combined)
|
||||
{
|
||||
String fingerprints = item.getValueString("search_value_fingerprints");
|
||||
if(fingerprints != null)
|
||||
{
|
||||
distinct.addAll(Arrays.asList(JsonUtils.toObject(fingerprints, String[].class)));
|
||||
}
|
||||
}
|
||||
return ((settings.missAlertConsecutive() > 0 && misses >= settings.missAlertConsecutive())
|
||||
|| (settings.distinctValueAlert() > 0 && distinct.size() > settings.distinctValueAlert()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core #1090 is released in RC.5; its optional sink is supplied by qbit-alerts.
|
||||
*******************************************************************************/
|
||||
private static void raiseAlert(QRecord row)
|
||||
{
|
||||
String dedup = "secured_data.enumeration:" + row.getValueString("user_id") + ":" + row.getValueString("table_name") + "."
|
||||
+ row.getValueString("field_name") + ":" + row.getValueInstant("created_at").atOffset(java.time.ZoneOffset.UTC).toLocalDate();
|
||||
QAlerts.raise(new AlertSignal("secured_data.enumeration").withDedupKey(dedup)
|
||||
.withTitle("Repeated exact-match searches on secured data").withSummary("Review the secured-data access log for enumeration activity."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent shutdown; stops scheduling and drains accepted rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
synchronized(lifecycleLock)
|
||||
{
|
||||
closed = true;
|
||||
}
|
||||
worker.shutdown();
|
||||
flush();
|
||||
if(Thread.currentThread() != shutdownHook)
|
||||
{
|
||||
try
|
||||
{
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
}
|
||||
catch(IllegalStateException expected)
|
||||
{
|
||||
// JVM shutdown has already begun.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.Collection;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Public classification API shared by QBits, with runtime rows overriding code metadata.
|
||||
*******************************************************************************/
|
||||
public final class SecuredDataClassifications
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SecuredDataClassifications()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Field row, table row, code field, code table, then unclassified.
|
||||
*******************************************************************************/
|
||||
public static DataClassification effective(QTableMetaData table, String fieldName)
|
||||
{
|
||||
QRecord row = row(table, fieldName);
|
||||
if(row != null)
|
||||
{
|
||||
return (DataClassification.valueOf(row.getValueString("classification")));
|
||||
}
|
||||
QFieldMetaData field = table.getField(fieldName);
|
||||
DataClassification classification = field == null ? null : CoreMetadataBridge.classification(table, field);
|
||||
return (classification == null ? CoreMetadataBridge.classification(table, null) : classification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table-level override or code-level table classification.
|
||||
*******************************************************************************/
|
||||
public static DataClassification effective(QTableMetaData table)
|
||||
{
|
||||
QRecord row = row(table, null);
|
||||
return (row == null ? CoreMetadataBridge.classification(table, null) : DataClassification.valueOf(row.getValueString("classification")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Most sensitive effective field, including an empty table's classification.
|
||||
*******************************************************************************/
|
||||
public static DataClassification highest(QTableMetaData table)
|
||||
{
|
||||
return (table.getFields().isEmpty() ? effective(table) : highest(table, table.getFields().keySet()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Most sensitive among the requested fields only; an empty subset is null.
|
||||
*******************************************************************************/
|
||||
public static DataClassification highest(QTableMetaData table, Collection<String> fieldNames)
|
||||
{
|
||||
DataClassification highest = null;
|
||||
for(String fieldName : fieldNames)
|
||||
{
|
||||
highest = DataClassification.max(highest, effective(table, fieldName));
|
||||
}
|
||||
return (highest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective classification row also owns mask and low-cardinality overrides.
|
||||
*******************************************************************************/
|
||||
static QRecord row(QTableMetaData table, String fieldName)
|
||||
{
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
if(runtime == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord tableRow = null;
|
||||
for(QRecord row : runtime.classificationRows())
|
||||
{
|
||||
if(!Objects.equals(table.getName(), row.getValueString("table_name")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String field = row.getValueString("field_name");
|
||||
if(Objects.equals(fieldName, field))
|
||||
{
|
||||
return (row);
|
||||
}
|
||||
if(field == null || field.isBlank())
|
||||
{
|
||||
tableRow = row;
|
||||
}
|
||||
}
|
||||
return (tableRow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* 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.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.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.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataQBitConfig implements QBitConfig
|
||||
{
|
||||
private static final byte[] JVM_KEY = new SecureRandom().generateSeed(32);
|
||||
private Map<String, String> defaultTierByClassification = new HashMap<>(Map.of("CONFIDENTIAL", "FULL", "PII", "MASKED", "PHI", "MASKED"));
|
||||
private String backendName = "memory";
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private int accessLogQueueCapacity = 4096;
|
||||
private long accessLogFlushIntervalMillis = 250;
|
||||
private int exactMatchPerHour = 20;
|
||||
private int missAlertConsecutive = 5;
|
||||
private int distinctValueAlert = 15;
|
||||
private boolean minEntropyEnabled = true;
|
||||
private boolean nameHeuristics = false;
|
||||
private Set<DataClassification> viewLogClassifications = EnumSet.of(DataClassification.PII, DataClassification.PHI);
|
||||
private Supplier<byte[]> fingerprintKeySupplier = () -> JVM_KEY.clone();
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get backendName.
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure backendName.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get clock.
|
||||
*******************************************************************************/
|
||||
public Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure clock.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withClock(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get accessLogQueueCapacity.
|
||||
*******************************************************************************/
|
||||
public int getAccessLogQueueCapacity()
|
||||
{
|
||||
return (accessLogQueueCapacity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure accessLogQueueCapacity.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withAccessLogQueueCapacity(int accessLogQueueCapacity)
|
||||
{
|
||||
this.accessLogQueueCapacity = accessLogQueueCapacity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get accessLogFlushIntervalMillis.
|
||||
*******************************************************************************/
|
||||
public long getAccessLogFlushIntervalMillis()
|
||||
{
|
||||
return (accessLogFlushIntervalMillis);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure accessLogFlushIntervalMillis.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withAccessLogFlushIntervalMillis(long accessLogFlushIntervalMillis)
|
||||
{
|
||||
this.accessLogFlushIntervalMillis = accessLogFlushIntervalMillis;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get exactMatchPerHour.
|
||||
*******************************************************************************/
|
||||
public int getExactMatchPerHour()
|
||||
{
|
||||
return (exactMatchPerHour);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure exactMatchPerHour.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withExactMatchPerHour(int exactMatchPerHour)
|
||||
{
|
||||
this.exactMatchPerHour = exactMatchPerHour;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get missAlertConsecutive.
|
||||
*******************************************************************************/
|
||||
public int getMissAlertConsecutive()
|
||||
{
|
||||
return (missAlertConsecutive);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure missAlertConsecutive.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withMissAlertConsecutive(int missAlertConsecutive)
|
||||
{
|
||||
this.missAlertConsecutive = missAlertConsecutive;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get distinctValueAlert.
|
||||
*******************************************************************************/
|
||||
public int getDistinctValueAlert()
|
||||
{
|
||||
return (distinctValueAlert);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure distinctValueAlert.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withDistinctValueAlert(int distinctValueAlert)
|
||||
{
|
||||
this.distinctValueAlert = distinctValueAlert;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get minEntropyEnabled.
|
||||
*******************************************************************************/
|
||||
public boolean getMinEntropyEnabled()
|
||||
{
|
||||
return (minEntropyEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure minEntropyEnabled.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withMinEntropyEnabled(boolean minEntropyEnabled)
|
||||
{
|
||||
this.minEntropyEnabled = minEntropyEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get nameHeuristics.
|
||||
*******************************************************************************/
|
||||
public boolean getNameHeuristics()
|
||||
{
|
||||
return (nameHeuristics);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure nameHeuristics.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withNameHeuristics(boolean nameHeuristics)
|
||||
{
|
||||
this.nameHeuristics = nameHeuristics;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get viewLogClassifications.
|
||||
*******************************************************************************/
|
||||
public Set<DataClassification> getViewLogClassifications()
|
||||
{
|
||||
return (viewLogClassifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure viewLogClassifications.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withViewLogClassifications(Set<DataClassification> viewLogClassifications)
|
||||
{
|
||||
this.viewLogClassifications = viewLogClassifications;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get fingerprintKeySupplier.
|
||||
*******************************************************************************/
|
||||
public Supplier<byte[]> getFingerprintKeySupplier()
|
||||
{
|
||||
return (fingerprintKeySupplier);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure fingerprintKeySupplier.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withFingerprintKeySupplier(Supplier<byte[]> fingerprintKeySupplier)
|
||||
{
|
||||
this.fingerprintKeySupplier = fingerprintKeySupplier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
|
||||
{
|
||||
return (tableMetaDataCustomizer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
|
||||
{
|
||||
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default tier for a classification.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withDefaultTier(DataClassification classification, FieldAccessTier tier)
|
||||
{
|
||||
defaultTierByClassification.put(classification.name(), tier.name());
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility getter for scaffold configuration.
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getDefaultTierByClassification()
|
||||
{
|
||||
return (Map.copyOf(defaultTierByClassification));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace configured defaults; missing restricted classifications fail closed.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig withDefaultTierByClassification(Map<String, String> tiers)
|
||||
{
|
||||
tiers.forEach((classification, tier) ->
|
||||
{
|
||||
DataClassification.valueOf(classification);
|
||||
FieldAccessTier.valueOf(tier);
|
||||
});
|
||||
defaultTierByClassification = new HashMap<>(tiers);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the configured default.
|
||||
*******************************************************************************/
|
||||
public FieldAccessTier defaultTier(DataClassification classification)
|
||||
{
|
||||
return (FieldAccessTier.valueOf(defaultTierByClassification.getOrDefault(classification.name(), "MASKED")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject invalid infrastructure and guardrail settings at startup.
|
||||
*******************************************************************************/
|
||||
public void validate()
|
||||
{
|
||||
if(backendName == null || backendName.isBlank() || accessLogQueueCapacity < 1 || accessLogFlushIntervalMillis < 1
|
||||
|| exactMatchPerHour < 0 || missAlertConsecutive < 0 || distinctValueAlert < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid secured-data configuration"));
|
||||
}
|
||||
Objects.requireNonNull(clock);
|
||||
Objects.requireNonNull(viewLogClassifications);
|
||||
Objects.requireNonNull(fingerprintKeySupplier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTableCustomizers;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataQBitProducer implements QBitMetaDataProducer<SecuredDataQBitConfig>
|
||||
{
|
||||
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";
|
||||
|
||||
private SecuredDataQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitProducer withQBitConfig(SecuredDataQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the config.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SecuredDataQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the namespace.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the namespace.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity and configuration of this qbit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-scanned producers have no config: skip them.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register tables, permissions, runtime and the optional core enforcement bridge.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
qBitConfig.validate();
|
||||
if(qInstance.getSupplementalMetaData(SecuredDataRuntime.NAME) != null)
|
||||
{
|
||||
throw (new IllegalStateException("Only one secured-data QBit may be registered per instance"));
|
||||
}
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
qInstance.addQBit(qBit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
SecuredDataTables.possibleValueSources(qInstance).forEach(output::add);
|
||||
QAppMetaData app = new QAppMetaData().withName("secured_data").withLabel("Secured Data")
|
||||
.withIcon(new QIcon("shield")).withPermissionRules(SecuredDataPermissions.rules());
|
||||
Map<String, QTableMetaData> tables = new LinkedHashMap<>();
|
||||
for(QTableMetaData definition : SecuredDataTables.tables(qBitConfig))
|
||||
{
|
||||
QTableMetaData table = qBitConfig.getTableMetaDataCustomizer() == null ? definition
|
||||
: qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, definition);
|
||||
for(TableCustomizers role : List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD, TableCustomizers.PRE_DELETE_RECORD,
|
||||
TableCustomizers.POST_INSERT_RECORD, TableCustomizers.POST_UPDATE_RECORD, TableCustomizers.POST_DELETE_RECORD))
|
||||
{
|
||||
table.withCustomizer(role, new QCodeReference(SecuredDataTableCustomizers.class));
|
||||
}
|
||||
table.setSourceQBitName(qBit.getName());
|
||||
tables.put(table.getName(), table);
|
||||
output.add(table);
|
||||
}
|
||||
SecuredDataTables.MAIN.forEach(name -> app.withChild(tables.get(name)));
|
||||
output.add(app);
|
||||
output.add(SecuredDataTables.revealsJoin());
|
||||
output.add(SecuredDataTables.revealsWidget());
|
||||
output.addSelfToInstance(qInstance);
|
||||
CoreMetadataBridge.register(qInstance);
|
||||
if(qBitConfig.getNameHeuristics())
|
||||
{
|
||||
seedHeuristics(qInstance);
|
||||
}
|
||||
CoreMetadataBridge.promoteClassifications(qInstance);
|
||||
SecuredDataRuntime runtime = new SecuredDataRuntime(qInstance, qBitConfig);
|
||||
runtime.addSelfToInstance(qInstance);
|
||||
try
|
||||
{
|
||||
CoreFieldAccessBridge.register(qInstance, runtime.resolver());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
runtime.close();
|
||||
throw e;
|
||||
}
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in common-name defaults, preserving existing field/table classifications.
|
||||
*******************************************************************************/
|
||||
private static void seedHeuristics(QInstance instance)
|
||||
{
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(SecuredDataTables.ALL.contains(table.getName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(CoreMetadataBridge.classification(table, field) != null || CoreMetadataBridge.classification(table, null) != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String name = field.getName().toLowerCase(Locale.ROOT);
|
||||
if(name.startsWith("diagnosis"))
|
||||
{
|
||||
CoreMetadataBridge.classifyField(field, DataClassification.PHI);
|
||||
}
|
||||
else if(List.of("ssn", "dob", "date_of_birth", "email", "phone").contains(name))
|
||||
{
|
||||
CoreMetadataBridge.classifyField(field, DataClassification.PII);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* 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 javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
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.DataClassification;
|
||||
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.CoreMetadataBridge;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
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;
|
||||
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.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.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seven-step policy resolution with instance-versioned, bounded per-user state.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataResolver implements FieldAccessResolverInterface
|
||||
{
|
||||
private final SecuredDataRuntime runtime;
|
||||
private final byte[] fingerprintKey;
|
||||
private final ThreadLocal<Boolean> resolving = ThreadLocal.withInitial(() -> false);
|
||||
private long cachedVersion;
|
||||
private List<QRecord> policies = List.of();
|
||||
private final Map<String, UserState> users = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached policy matches and break-glass rows. Expiration is checked on every use.
|
||||
*******************************************************************************/
|
||||
private record UserState(List<QRecord> policies, List<QRecord> breakGlass)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Zero thresholds disable that guardrail; positive thresholds combine by minimum.
|
||||
*******************************************************************************/
|
||||
public record Guardrails(int exactMatchPerHour, int missAlertConsecutive, int distinctValueAlert, boolean minEntropyEnabled)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the supplied key once; default keys are shared within this JVM.
|
||||
*******************************************************************************/
|
||||
public SecuredDataResolver(SecuredDataRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.fingerprintKey = Objects.requireNonNull(runtime.config().getFingerprintKeySupplier().get()).clone();
|
||||
if(fingerprintKey.length < 16)
|
||||
{
|
||||
throw (new IllegalArgumentException("Fingerprint keys must contain at least 16 bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve classification, grants, policies, channel caps and entropy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FieldAccessDecision resolve(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
if(SystemWrites.active() || QContext.getQSession() instanceof QSystemUserSession
|
||||
|| Objects.equals(table.getPrimaryKeyField(), field.getName()))
|
||||
{
|
||||
return (FieldAccessDecision.FULL);
|
||||
}
|
||||
if(resolving.get())
|
||||
{
|
||||
throw (new IllegalStateException("Recursive secured-data resolution outside a system lookup"));
|
||||
}
|
||||
resolving.set(true);
|
||||
try
|
||||
{
|
||||
DataClassification classification = SecuredDataClassifications.effective(table, field.getName());
|
||||
if(classification == null || classification == DataClassification.PUBLIC)
|
||||
{
|
||||
return (FieldAccessDecision.FULL);
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
if(SecuredDataTables.ALL.contains(table.getName()) && session != null && session.hasPermission(SecuredDataPermissions.ADMIN))
|
||||
{
|
||||
return (FieldAccessDecision.FULL);
|
||||
}
|
||||
List<QRecord> matches = matching(table, classification);
|
||||
FieldAccessTier tier = null;
|
||||
for(QRecord row : matches)
|
||||
{
|
||||
if(blank(row.getValueString("client_channel")))
|
||||
{
|
||||
FieldAccessTier candidate = FieldAccessTier.valueOf(row.getValueString("tier"));
|
||||
tier = tier == null ? candidate : tier.leastRestrictive(candidate);
|
||||
}
|
||||
}
|
||||
if(tier == null)
|
||||
{
|
||||
tier = runtime.config().defaultTier(classification);
|
||||
}
|
||||
if(session != null && CoreMetadataBridge.hasFeature(table, SecuredDataPermissions.REVEAL))
|
||||
{
|
||||
tier = tier.leastRestrictive(FieldAccessTier.REVEALABLE);
|
||||
}
|
||||
if(activeBreakGlass(table.getName(), classification) != null
|
||||
|| (session != null && CoreMetadataBridge.hasFeature(table, SecuredDataPermissions.FULL)))
|
||||
{
|
||||
tier = FieldAccessTier.FULL;
|
||||
}
|
||||
for(QRecord row : matches)
|
||||
{
|
||||
if(!blank(row.getValueString("client_channel")))
|
||||
{
|
||||
tier = tier.mostRestrictive(FieldAccessTier.valueOf(row.getValueString("tier")));
|
||||
}
|
||||
}
|
||||
QRecord override = SecuredDataClassifications.row(table, field.getName());
|
||||
String style = override == null ? null : override.getValueString("mask_style");
|
||||
boolean exactAllowed = !guardrails(matches).minEntropyEnabled() || !lowCardinality(field, override);
|
||||
return (new FieldAccessDecision(tier, blank(style) ? null : FieldMaskStyle.valueOf(style), "Secured-data policy", exactAllowed));
|
||||
}
|
||||
finally
|
||||
{
|
||||
resolving.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The field's matching policies also drive enumeration controls.
|
||||
*******************************************************************************/
|
||||
public Guardrails guardrails(QTableMetaData table, String fieldName)
|
||||
{
|
||||
return (guardrails(matching(table, SecuredDataClassifications.effective(table, fieldName))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Most restrictive enabled setting across matching rows; config when none match.
|
||||
*******************************************************************************/
|
||||
private Guardrails guardrails(List<QRecord> matches)
|
||||
{
|
||||
SecuredDataQBitConfig config = runtime.config();
|
||||
if(matches.isEmpty())
|
||||
{
|
||||
return (new Guardrails(config.getExactMatchPerHour(), config.getMissAlertConsecutive(), config.getDistinctValueAlert(), config.getMinEntropyEnabled()));
|
||||
}
|
||||
int rate = 0;
|
||||
int misses = 0;
|
||||
int distinct = 0;
|
||||
boolean entropy = false;
|
||||
for(QRecord row : matches)
|
||||
{
|
||||
rate = minimumEnabled(rate, setting(row, "exact_match_per_hour", config.getExactMatchPerHour()));
|
||||
misses = minimumEnabled(misses, setting(row, "miss_alert_consecutive", config.getMissAlertConsecutive()));
|
||||
distinct = minimumEnabled(distinct, setting(row, "distinct_value_alert", config.getDistinctValueAlert()));
|
||||
entropy |= row.getValueBoolean("min_entropy_enabled") == null ? config.getMinEntropyEnabled() : row.getValueBoolean("min_entropy_enabled");
|
||||
}
|
||||
return (new Guardrails(rate, misses, distinct, entropy));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read an optional numeric setting.
|
||||
*******************************************************************************/
|
||||
private static int setting(QRecord row, String name, int fallback)
|
||||
{
|
||||
Integer value = row.getValueInteger(name);
|
||||
return (value == null ? fallback : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Zero means off, so an enabled row always wins over a disabled row.
|
||||
*******************************************************************************/
|
||||
private static int minimumEnabled(int left, int right)
|
||||
{
|
||||
return (left == 0 ? right : right == 0 ? left : Math.min(left, right));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit true/false wins over the field type/name heuristic.
|
||||
*******************************************************************************/
|
||||
static boolean lowCardinality(QFieldMetaData field, QRecord override)
|
||||
{
|
||||
Boolean explicit = override == null ? null : override.getValueBoolean("low_cardinality");
|
||||
if(explicit != null)
|
||||
{
|
||||
return (explicit);
|
||||
}
|
||||
QFieldType type = field.getType();
|
||||
String name = (field.getName() + " " + field.getLabel()).replaceAll("([a-z])([A-Z])", "$1 $2").toLowerCase(Locale.ROOT);
|
||||
return (type == QFieldType.BOOLEAN || type == QFieldType.DATE || type == QFieldType.DATE_TIME
|
||||
|| field.getPossibleValueSourceName() != null || field.getInlinePossibleValueSource() != null
|
||||
|| (type == QFieldType.INTEGER && name.contains("year"))
|
||||
|| name.matches(".*(?:^|[^a-z])(state|region|province|zip3)(?:[^a-z]|$).*")
|
||||
|| (type == QFieldType.STRING && field.getMaxLength() != null && field.getMaxLength() < 6));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policies for this user, classification, table and current normalized channel.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> matching(QTableMetaData table, DataClassification classification)
|
||||
{
|
||||
String channel = CoreMetadataBridge.channel(QContext.getQSession());
|
||||
return (state().policies().stream().filter(row -> classification != null && classification.name().equals(row.getValueString("classification")))
|
||||
.filter(row -> blank(row.getValueString("table_name")) || table.getName().equals(row.getValueString("table_name")))
|
||||
.filter(row -> blank(row.getValueString("client_channel")) || Objects.equals(channel, row.getValueString("client_channel"))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh policy rows once per version and matched state per relevant permissions.
|
||||
*******************************************************************************/
|
||||
private synchronized UserState state()
|
||||
{
|
||||
long version = runtime.policyVersion();
|
||||
if(cachedVersion != version)
|
||||
{
|
||||
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
|
||||
users.clear();
|
||||
cachedVersion = version;
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
TreeSet<String> held = new TreeSet<>();
|
||||
for(QRecord policy : policies)
|
||||
{
|
||||
String permission = policy.getValueString("permission_name");
|
||||
if(session != null && session.hasPermission(permission))
|
||||
{
|
||||
held.add(permission);
|
||||
}
|
||||
}
|
||||
String key = JsonUtils.toJson(List.of(userId(), held));
|
||||
UserState state = users.get(key);
|
||||
if(state == null)
|
||||
{
|
||||
if(users.size() >= 1024)
|
||||
{
|
||||
users.clear();
|
||||
}
|
||||
List<QRecord> matching = policies.stream().filter(row -> Boolean.TRUE.equals(row.getValueBoolean("is_active")))
|
||||
.filter(row -> held.contains(row.getValueString("permission_name"))).toList();
|
||||
List<QRecord> breaks = SystemWrites.query(SecuredDataTables.BREAK_GLASS, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, userId())));
|
||||
state = new UserState(matching, List.copyOf(breaks));
|
||||
users.put(key, state);
|
||||
}
|
||||
return (state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact expiry is checked at use time, independent of the future expiry job.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> activeBreakGlass()
|
||||
{
|
||||
Instant now = runtime.config().getClock().instant();
|
||||
return (state().breakGlass().stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
|
||||
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A break-glass grant must cover both the table and classification.
|
||||
*******************************************************************************/
|
||||
QRecord activeBreakGlass(String table, DataClassification classification)
|
||||
{
|
||||
return (activeBreakGlass().stream().filter(row -> blank(row.getValueString("table_name")) || table.equals(row.getValueString("table_name")))
|
||||
.filter(row -> DataClassification.valueOf(Objects.requireNonNullElse(row.getValueString("classification_ceiling"), "PHI")).isAtLeast(classification))
|
||||
.findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable across unrelated permissions/records; includes channel and exact expiry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized String getAccessFingerprint()
|
||||
{
|
||||
state();
|
||||
TreeSet<String> relevant = new TreeSet<>();
|
||||
QSession session = QContext.getQSession();
|
||||
for(QRecord row : policies)
|
||||
{
|
||||
String permission = row.getValueString("permission_name");
|
||||
if(session != null && session.hasPermission(permission))
|
||||
{
|
||||
relevant.add(permission);
|
||||
}
|
||||
}
|
||||
for(QTableMetaData table : QContext.getQInstance().getTables().values())
|
||||
{
|
||||
String base = CoreMetadataBridge.permissionBase(table);
|
||||
for(String suffix : List.of(SecuredDataPermissions.FULL, SecuredDataPermissions.REVEAL))
|
||||
{
|
||||
if(session != null && session.hasPermission(base + "." + suffix))
|
||||
{
|
||||
relevant.add(base + "." + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
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(CoreMetadataBridge.channel(session), "")));
|
||||
try
|
||||
{
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing grants are read as system; phase (c) owns their creation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isRevealActive(String tableName, String fieldName, Serializable primaryKey)
|
||||
{
|
||||
return (SystemWrites.query(SecuredDataTables.REVEAL_GRANT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("user_id", QCriteriaOperator.EQUALS, userId()))
|
||||
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, tableName))
|
||||
.withCriteria(new QFilterCriteria("field_name", QCriteriaOperator.EQUALS, fieldName))
|
||||
.withCriteria(new QFilterCriteria("record_id", QCriteriaOperator.EQUALS, String.valueOf(primaryKey)))
|
||||
.withCriteria(new QFilterCriteria("expires_at", QCriteriaOperator.GREATER_THAN, runtime.config().getClock().instant()))).size() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Logging never throws into a core read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onAccess(FieldAccessEvent event)
|
||||
{
|
||||
runtime.logger().onAccess(event);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preflight enumeration limits; core reports the REFUSED outcome exactly once.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkExactMatch(FieldAccessEvent event) throws QPermissionDeniedException
|
||||
{
|
||||
runtime.logger().checkExactMatch(event);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A keyed, non-reversible search identifier. No raw value leaves this method.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String fingerprintSearchValue(Serializable value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(fingerprintKey, "HmacSHA256"));
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// keep only 16 bits: enough to count distinct searched values in an hour, //
|
||||
// far too few to confirm a guessed value (each fingerprint matches ~1 in //
|
||||
// 65536 of the value space), so a persisted fingerprint is not a derivative //
|
||||
// of the searched value that could be brute-forced with the key. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
byte[] digest = mac.doFinal(JsonUtils.toJson(value).getBytes(StandardCharsets.UTF_8));
|
||||
return (String.format("%02x%02x", digest[0], digest[1]));
|
||||
}
|
||||
catch(java.security.GeneralSecurityException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to fingerprint search", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable user identity for all of that user's sessions.
|
||||
*******************************************************************************/
|
||||
public static String userId()
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
return (session == null || session.getUser() == null ? "anonymous" : Objects.requireNonNullElse(session.getUser().getIdReference(), "anonymous"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Treat empty optional selectors like null.
|
||||
*******************************************************************************/
|
||||
private static boolean blank(String value)
|
||||
{
|
||||
return (value == null || value.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-scoped services and invalidation clocks, never shared across applications.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
|
||||
{
|
||||
public static final String NAME = "securedDataRuntime";
|
||||
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 transient SecuredDataQBitConfig config;
|
||||
private final transient SecuredDataResolver resolver;
|
||||
private final transient SecuredDataAccessLogger logger;
|
||||
private transient long cachedClassificationVersion;
|
||||
private transient List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct services only after tables have been registered.
|
||||
*******************************************************************************/
|
||||
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
this.resolver = new SecuredDataResolver(this);
|
||||
this.logger = new SecuredDataAccessLogger(instance, this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplemental metadata identity.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Services for the current QContext.
|
||||
*******************************************************************************/
|
||||
public static SecuredDataRuntime current()
|
||||
{
|
||||
return (QContext.getQInstance() == null ? null : QSupplementalInstanceMetaData.of(QContext.getQInstance(), NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code configuration.
|
||||
*******************************************************************************/
|
||||
public SecuredDataQBitConfig config()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolver service.
|
||||
*******************************************************************************/
|
||||
public SecuredDataResolver resolver()
|
||||
{
|
||||
return (resolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Logger service and drop counter.
|
||||
*******************************************************************************/
|
||||
public SecuredDataAccessLogger logger()
|
||||
{
|
||||
return (logger);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification invalidation clock.
|
||||
*******************************************************************************/
|
||||
public long classificationVersion()
|
||||
{
|
||||
return (classificationVersion.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy/break-glass invalidation clock.
|
||||
*******************************************************************************/
|
||||
public long policyVersion()
|
||||
{
|
||||
return (policyVersion.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Post-commit customizers and host distributed invalidation call this.
|
||||
*******************************************************************************/
|
||||
public void invalidateClassifications()
|
||||
{
|
||||
classificationVersion.incrementAndGet();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Post-commit customizers and host distributed invalidation call this.
|
||||
*******************************************************************************/
|
||||
public void invalidatePolicies()
|
||||
{
|
||||
policyVersion.incrementAndGet();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read classification rows once per version, always as the system.
|
||||
*******************************************************************************/
|
||||
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
|
||||
{
|
||||
long version = classificationVersion();
|
||||
if(cachedClassificationVersion != version)
|
||||
{
|
||||
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;
|
||||
}
|
||||
return (classificationRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drain the audit queue during normal host shutdown.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
logger.close();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fields of the selected table; table_name is supplied through the dependent picker.
|
||||
*******************************************************************************/
|
||||
public class FieldsOfTablePossibleValueSource implements QCustomPossibleValueProvider<String>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stored ids are unqualified field names; labels are provided by search.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValue<String> getPossibleValue(Serializable id)
|
||||
{
|
||||
return (id == null ? null : new QPossibleValue<>(id.toString(), id.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never offer fields from a table the administrator cannot read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
|
||||
{
|
||||
Object tableName = input.getOtherValues() == null ? null : input.getOtherValues().get("table_name");
|
||||
List<QPossibleValue<String>> values = new ArrayList<>();
|
||||
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName.toString());
|
||||
if(table != null && PermissionsHelper.hasTablePermission(new GetInput(table.getName()), table.getName(), TablePermissionSubType.READ))
|
||||
{
|
||||
table.getFields().values().forEach(field -> values.add(new QPossibleValue<>(field.getName(), field.getLabel())));
|
||||
}
|
||||
return (completeCustomPVSSearch(input, values));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.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.
|
||||
*******************************************************************************/
|
||||
public final class SecuredDataEnums
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Activity event names include the future Reveal and break-glass actions.
|
||||
*******************************************************************************/
|
||||
public enum EventType
|
||||
{
|
||||
VIEW, REVEAL, REVEAL_DENIED, EXACT_MATCH_FILTER, FILTER_DENIED, BREAK_GLASS_START, BREAK_GLASS_END,
|
||||
BREAK_GLASS_EXPIRED, POLICY_CHANGE, CLASSIFICATION_CHANGE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access outcomes.
|
||||
*******************************************************************************/
|
||||
public enum Outcome
|
||||
{
|
||||
ALLOWED, DENIED, CAPPED, MATCH, NO_MATCH, REFUSED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Break-glass lifecycle.
|
||||
*******************************************************************************/
|
||||
public enum BreakGlassStatus
|
||||
{
|
||||
ACTIVE, ENDED, EXPIRED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SecuredDataEnums()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All enum sources, keeping the stand-in core types exact copies.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> sources()
|
||||
{
|
||||
return (List.of(source("classification", DataClassification.values()), source("tier", FieldAccessTier.values()),
|
||||
source("mask_style", FieldMaskStyle.values()), source("event_type", EventType.values()), source("outcome", Outcome.values()),
|
||||
source("break_glass_status", BreakGlassStatus.values())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert enum names to stored string ids.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource source(String name, Enum<?>[] values)
|
||||
{
|
||||
List<QPossibleValue<?>> options = new ArrayList<>();
|
||||
for(Enum<?> value : values)
|
||||
{
|
||||
options.add(new QPossibleValue<>(value.name(), value.name().replace('_', ' ')));
|
||||
}
|
||||
return (new QPossibleValueSource().withName("secured_data_" + name).withType(QPossibleValueSourceType.ENUM)
|
||||
.withIdType(QFieldType.STRING).withEnumValues(options));
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataResolver;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataRuntime;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
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.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validation, activity-table write protection and post-commit cache invalidation.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataTableCustomizers implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Defend system-only activity writes even when a caller skips permission checks.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
String table = input instanceof InsertInput insert ? insert.getTableName() : ((UpdateInput) input).getTableName();
|
||||
assertWrite(table, input instanceof UpdateInput);
|
||||
if(!SecuredDataTables.SYSTEM_WRITTEN.contains(table))
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = oldRecords.orElse(List.of()).stream().filter(row -> Objects.equals(row.getValue("id"), record.getValue("id"))).findFirst().orElse(null);
|
||||
QRecord effective = old == null ? new QRecord() : new QRecord(old);
|
||||
record.getValues().forEach(effective::setValue);
|
||||
validate(table, record, effective);
|
||||
Instant now = SecuredDataRuntime.current().config().getClock().instant();
|
||||
if(old == null)
|
||||
{
|
||||
record.setValue("create_date", now);
|
||||
}
|
||||
record.setValue("modify_date", now);
|
||||
record.setValue("modified_by", SecuredDataResolver.userId());
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access logs are append-only even for the system.
|
||||
*******************************************************************************/
|
||||
private static void assertWrite(String table, boolean mutation) throws QPermissionDeniedException
|
||||
{
|
||||
if((mutation && SecuredDataTables.ACCESS_LOG.equals(table))
|
||||
|| (SecuredDataTables.SYSTEM_WRITTEN.contains(table) && !SystemWrites.active() && !(QContext.getQSession() instanceof QSystemUserSession)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Secured-data activity is system-written; access logs are insert-only."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate target names, unique table-wide classifications and guardrail ranges.
|
||||
*******************************************************************************/
|
||||
private static void validate(String table, QRecord record, QRecord effective)
|
||||
{
|
||||
String target = effective.getValueString("table_name");
|
||||
QTableMetaData metadata = target == null ? null : QContext.getQInstance().getTable(target);
|
||||
if(target != null && metadata == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Select an existing table."));
|
||||
}
|
||||
if(SecuredDataTables.CLASSIFICATION.equals(table))
|
||||
{
|
||||
String field = effective.getValueString("field_name");
|
||||
if(field != null && field.isBlank())
|
||||
{
|
||||
field = null;
|
||||
record.setValue("field_name", null);
|
||||
}
|
||||
if(field != null && (metadata == null || !metadata.getFields().containsKey(field)))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Select a field of the selected table."));
|
||||
}
|
||||
if(target != null)
|
||||
{
|
||||
for(QRecord row : SystemWrites.query(table, new QQueryFilter().withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, target))))
|
||||
{
|
||||
if(Objects.equals(field, row.getValueString("field_name")) && !Objects.equals(row.getValue("id"), effective.getValue("id")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("A classification for this table and field already exists."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Integer minutes = effective.getValueInteger("reveal_minutes");
|
||||
if(minutes != null && (minutes < 1 || minutes > 60))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Reveal duration must be 1 to 60 minutes."));
|
||||
}
|
||||
for(String name : List.of("exact_match_per_hour", "miss_alert_consecutive", "distinct_value_alert", "daily_reveal_cap"))
|
||||
{
|
||||
Integer value = effective.getValueInteger(name);
|
||||
if(value != null && value < 0)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Guardrail limits cannot be negative."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User deletes of activity are forbidden; the log is immutable for everyone.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
|
||||
{
|
||||
assertWrite(input.getTableName(), true);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only successful mutations invalidate; transactions invalidate after commit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecords)
|
||||
{
|
||||
if(input instanceof InsertInput insert)
|
||||
{
|
||||
changed(insert.getTableName(), records, insert.getTransaction());
|
||||
}
|
||||
else if(input instanceof UpdateInput update)
|
||||
{
|
||||
changed(update.getTableName(), records, update.getTransaction());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deletions change the same versions as inserts and updates.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput input, List<QRecord> records)
|
||||
{
|
||||
changed(input.getTableName(), records, input.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the instance service; callbacks need no current user context.
|
||||
*******************************************************************************/
|
||||
private static void changed(String table, List<QRecord> records, QBackendTransaction transaction)
|
||||
{
|
||||
if(records.stream().noneMatch(row -> row.getErrors() == null || row.getErrors().isEmpty()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
SecuredDataRuntime runtime = SecuredDataRuntime.current();
|
||||
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
|
||||
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
|
||||
if(invalidate != null)
|
||||
{
|
||||
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
|
||||
if(active == null)
|
||||
{
|
||||
invalidate.run();
|
||||
}
|
||||
else
|
||||
{
|
||||
active.addAfterCommitCallback(invalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataQBitConfig;
|
||||
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
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.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
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.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PVSValueFormatAndFields;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The five audited tables, record sections, possible values and reveal child list.
|
||||
*******************************************************************************/
|
||||
public final class SecuredDataTables
|
||||
{
|
||||
public static final String CLASSIFICATION = "secured_data_classification";
|
||||
public static final String POLICY = "secured_data_policy";
|
||||
public static final String REVEAL_GRANT = "secured_data_reveal_grant";
|
||||
public static final String BREAK_GLASS = "secured_data_break_glass";
|
||||
public static final String ACCESS_LOG = "secured_data_access_log";
|
||||
public static final List<String> ALL = List.of(CLASSIFICATION, POLICY, REVEAL_GRANT, BREAK_GLASS, ACCESS_LOG);
|
||||
public static final List<String> MAIN = List.of(CLASSIFICATION, POLICY, ACCESS_LOG, BREAK_GLASS);
|
||||
public static final List<String> SYSTEM_WRITTEN = List.of(REVEAL_GRANT, BREAK_GLASS, ACCESS_LOG);
|
||||
public static final String REVEALS_JOIN = "secured_data_access_log_reveals";
|
||||
public static final String REVEALS_WIDGET = "securedDataReveals";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SecuredDataTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Main table events for the host ESB wiring, following WebhooksIntegrations.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames()
|
||||
{
|
||||
return (MAIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete table metadata.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(SecuredDataQBitConfig config)
|
||||
{
|
||||
return (List.of(classification(config), policy(config), revealGrant(config), breakGlass(config), accessLog(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common identity, audit and administration rules.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(SecuredDataQBitConfig config, String name, String label)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER).withBackendName("id").withIsEditable(false))
|
||||
.withPermissionRules(SecuredDataPermissions.rules()).withIcon(new QIcon("shield"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
CoreMetadataBridge.classifyTable(table);
|
||||
if(ACCESS_LOG.equals(name))
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every database column has an explicit snake_case name.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
QFieldMetaData field = new QFieldMetaData(name, type).withBackendName(name).withLabel(name.replace('_', ' '));
|
||||
if("field_name".equals(name))
|
||||
{
|
||||
field.withPossibleValueSourceFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()
|
||||
.withCriteria(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("table_name",
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "${input.table_name}")));
|
||||
}
|
||||
return (field);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define Classification.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData classification(SecuredDataQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, CLASSIFICATION, "Classification")
|
||||
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("tables"))
|
||||
.withField(field("field_name", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("secured_data_fields"))
|
||||
.withField(field("classification", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_classification"))
|
||||
.withField(field("mask_style", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("secured_data_mask_style"))
|
||||
.withField(field("low_cardinality", QFieldType.BOOLEAN))
|
||||
.withField(field("notes", QFieldType.TEXT))
|
||||
.withField(field("create_date", QFieldType.DATE_TIME))
|
||||
.withField(field("modify_date", QFieldType.DATE_TIME))
|
||||
.withField(field("modified_by", QFieldType.STRING).withMaxLength(255));
|
||||
table.withSection(SectionFactory.defaultT1("id", "table_name", "field_name", "classification"));
|
||||
table.withSection(SectionFactory.defaultT2("mask_style", "low_cardinality", "notes"));
|
||||
table.withSection(SectionFactory.defaultT3("create_date", "modify_date", "modified_by"));
|
||||
table.withUniqueKey(new UniqueKey().withFieldNames(List.of("table_name", "field_name")));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define Policy.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData policy(SecuredDataQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, POLICY, "Policy")
|
||||
.withField(field("name", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
|
||||
.withField(field("permission_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
|
||||
.withField(field("classification", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_classification"))
|
||||
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("tables"))
|
||||
.withField(field("tier", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_tier"))
|
||||
.withField(field("reveal_minutes", QFieldType.INTEGER).withDefaultValue(5))
|
||||
.withField(field("daily_reveal_cap", QFieldType.INTEGER))
|
||||
.withField(field("reason_options", QFieldType.TEXT))
|
||||
.withField(field("allow_free_text_reason", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("require_free_text", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("client_channel", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("exact_match_per_hour", QFieldType.INTEGER).withDefaultValue(20))
|
||||
.withField(field("miss_alert_consecutive", QFieldType.INTEGER).withDefaultValue(5))
|
||||
.withField(field("distinct_value_alert", QFieldType.INTEGER).withDefaultValue(15))
|
||||
.withField(field("min_entropy_enabled", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("is_active", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("create_date", QFieldType.DATE_TIME))
|
||||
.withField(field("modify_date", QFieldType.DATE_TIME))
|
||||
.withField(field("modified_by", QFieldType.STRING).withMaxLength(255));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "permission_name", "classification"));
|
||||
table.withSection(SectionFactory.defaultT2("table_name", "tier", "reveal_minutes", "daily_reveal_cap", "reason_options", "allow_free_text_reason", "require_free_text", "client_channel", "exact_match_per_hour", "miss_alert_consecutive", "distinct_value_alert", "min_entropy_enabled", "is_active"));
|
||||
table.withSection(SectionFactory.defaultT3("create_date", "modify_date", "modified_by"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define Reveal.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData revealGrant(SecuredDataQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, REVEAL_GRANT, "Reveal")
|
||||
.withField(field("user_id", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
|
||||
.withField(field("user_name", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("tables"))
|
||||
.withField(field("record_id", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
|
||||
.withField(field("field_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_fields"))
|
||||
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("reason_text", QFieldType.TEXT))
|
||||
.withField(field("granted_at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(field("expires_at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(field("access_log_id", QFieldType.INTEGER).withIsRequired(true));
|
||||
table.withSection(SectionFactory.defaultT1("id", "user_id", "user_name", "table_name"));
|
||||
table.withSection(SectionFactory.defaultT2("record_id", "field_name", "reason", "reason_text", "access_log_id"));
|
||||
table.withSection(SectionFactory.defaultT3("granted_at", "expires_at"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define Break-glass session.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData breakGlass(SecuredDataQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, BREAK_GLASS, "Break-glass session")
|
||||
.withField(field("user_id", QFieldType.STRING).withMaxLength(255).withIsRequired(true))
|
||||
.withField(field("user_name", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("reason_text", QFieldType.TEXT))
|
||||
.withField(field("classification_ceiling", QFieldType.STRING).withMaxLength(255).withDefaultValue("PHI").withPossibleValueSourceName("secured_data_classification"))
|
||||
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("tables"))
|
||||
.withField(field("started_at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(field("expires_at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(field("ended_at", QFieldType.DATE_TIME))
|
||||
.withField(field("ended_by", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("status", QFieldType.STRING).withMaxLength(255).withDefaultValue("ACTIVE").withPossibleValueSourceName("secured_data_break_glass_status"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "user_id", "user_name", "reason"));
|
||||
table.withSection(SectionFactory.defaultT2("reason_text", "classification_ceiling", "table_name", "ended_by", "status"));
|
||||
table.withSection(SectionFactory.defaultT3("started_at", "expires_at", "ended_at"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define Access log.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData accessLog(SecuredDataQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ACCESS_LOG, "Access log")
|
||||
.withField(field("event_type", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("secured_data_event_type"))
|
||||
.withField(field("user_id", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("user_name", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("table_name", QFieldType.STRING).withMaxLength(255).withIsRequired(true).withPossibleValueSourceName("tables"))
|
||||
.withField(field("record_id", QFieldType.TEXT))
|
||||
.withField(field("field_name", QFieldType.TEXT))
|
||||
.withField(field("record_count", QFieldType.INTEGER))
|
||||
.withField(field("action_name", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("reason", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("reason_text", QFieldType.TEXT))
|
||||
.withField(field("client_channel", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(field("outcome", QFieldType.STRING).withMaxLength(255).withPossibleValueSourceName("secured_data_outcome"))
|
||||
.withField(field("anomaly_flag", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("break_glass_id", QFieldType.INTEGER))
|
||||
.withField(field("created_at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(field("search_value_fingerprints", QFieldType.TEXT));
|
||||
table.withSection(SectionFactory.defaultT1("id", "event_type", "user_id", "user_name"));
|
||||
table.withSection(SectionFactory.defaultT2("table_name", "record_id", "field_name", "record_count", "action_name", "reason", "reason_text", "client_channel", "outcome", "anomaly_flag", "break_glass_id", "search_value_fingerprints"));
|
||||
table.withSection(SectionFactory.defaultT3("created_at"));
|
||||
table.withSection(SectionFactory.customT2("reveals", new QIcon("visibility")).withLabel("Reveals").withWidgetName(REVEALS_WIDGET));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core table picker plus a dependent field picker.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources(QInstance instance)
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>(SecuredDataEnums.sources());
|
||||
if(instance.getPossibleValueSource("tables") == null)
|
||||
{
|
||||
sources.add(TablesPossibleValueSourceMetaDataProvider.defineTablesPossibleValueSource(instance));
|
||||
}
|
||||
sources.add(new QPossibleValueSource().withName("secured_data_fields").withType(QPossibleValueSourceType.CUSTOM)
|
||||
.withIdType(QFieldType.STRING).withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY)
|
||||
.withCustomCodeReference(new QCodeReference(FieldsOfTablePossibleValueSource.class)));
|
||||
return (sources);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reveals belong to the access-log record that authorized them.
|
||||
*******************************************************************************/
|
||||
public static QJoinMetaData revealsJoin()
|
||||
{
|
||||
return (new QJoinMetaData().withName(REVEALS_JOIN).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ACCESS_LOG).withRightTable(REVEAL_GRANT).withJoinOn(new JoinOn("id", "access_log_id")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only child list, never a navigation entry.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData revealsWidget()
|
||||
{
|
||||
return (ChildRecordListRenderer.widgetMetaDataBuilder(revealsJoin()).withName(REVEALS_WIDGET).withLabel("Reveals")
|
||||
.withCanAddChildRecord(false).withMaxRows(50).getWidgetMetaData().withGridColumns(12)
|
||||
.withPermissionRules(SecuredDataPermissions.rules()));
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin-only management and read access; activity writes belong to system operations.
|
||||
*******************************************************************************/
|
||||
public class SecuredDataPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Enforce the assistant wall on RC.5 too, when a trusted channel is supplied.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
boolean write = input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput;
|
||||
if(QContext.getQSession() == null || "assistant".equals(CoreMetadataBridge.channel(QContext.getQSession()))
|
||||
|| !QContext.getQSession().hasPermission(SecuredDataPermissions.ADMIN)
|
||||
|| (write && SecuredDataTables.SYSTEM_WRITTEN.contains(metadata.getName())))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative access is independent of access to classified application data.
|
||||
*******************************************************************************/
|
||||
public final class SecuredDataPermissions
|
||||
{
|
||||
public static final String ADMIN = "secured_data.admin";
|
||||
public static final String FULL = "secured_data_full";
|
||||
public static final String REVEAL = "secured_data_reveal";
|
||||
public static final List<String> ALL = List.of(ADMIN, "secured_data.hasAccess");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SecuredDataPermissions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ custom checker, shared by tables, app and child widget.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules rules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("secured_data")
|
||||
.withCustomPermissionChecker(new QCodeReference(SecuredDataPermissionChecker.class)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary classification metadata alongside the released AI exposure metadata.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1142, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData classification properties; delete when the pin moves to RC.6.
|
||||
public class AiFieldMetaData extends QSupplementalFieldMetaData
|
||||
{
|
||||
public static final String TYPE = "securedDataAi";
|
||||
private DataClassification classification;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata key, separate from RC.5's exposure-only AI metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification.
|
||||
*******************************************************************************/
|
||||
public DataClassification getClassification()
|
||||
{
|
||||
return (classification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code classification until the released core supports it.
|
||||
*******************************************************************************/
|
||||
public AiFieldMetaData withClassification(DataClassification classification)
|
||||
{
|
||||
this.classification = classification;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary classification metadata alongside the released AI exposure metadata.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1142, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData classification properties; delete when the pin moves to RC.6.
|
||||
public class AiTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
public static final String TYPE = "securedDataAi";
|
||||
private DataClassification classification;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata key, separate from RC.5's exposure-only AI metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification.
|
||||
*******************************************************************************/
|
||||
public DataClassification getClassification()
|
||||
{
|
||||
return (classification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code classification until the released core supports it.
|
||||
*******************************************************************************/
|
||||
public AiTableMetaData withClassification(DataClassification classification)
|
||||
{
|
||||
this.classification = classification;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 host adapter for a trusted, server-supplied client channel.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1141, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.model.session.QSession client channel properties; delete when the pin moves to RC.6.
|
||||
public class ClientChannelSession extends QSession
|
||||
{
|
||||
private String clientChannel;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host channel value; never take this from an unvalidated request.
|
||||
*******************************************************************************/
|
||||
public ClientChannelSession withClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full channel including optional request detail.
|
||||
*******************************************************************************/
|
||||
public String getClientChannel()
|
||||
{
|
||||
return (clientChannel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalized channel for policy matching.
|
||||
*******************************************************************************/
|
||||
public String getClientChannelName()
|
||||
{
|
||||
return (clientChannel == null || clientChannel.isBlank() ? null : clientChannel.strip().split("/", 2)[0].toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary registry for the subset needed by this QBit.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1141, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData registration subset; delete when the pin moves to RC.6.
|
||||
public class ClientChannelsMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
private final Map<String, String> entries = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry name, distinct from the future core registry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("securedDataClientChannels");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance registry.
|
||||
*******************************************************************************/
|
||||
public static ClientChannelsMetaData ofOrWithNew(QInstance instance)
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(instance, "securedDataClientChannels", ClientChannelsMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the QBit's entry.
|
||||
*******************************************************************************/
|
||||
public ClientChannelsMetaData withDeniedPattern(String key, String value)
|
||||
{
|
||||
entries.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only registration view.
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getEntries()
|
||||
{
|
||||
return (Map.copyOf(entries));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Data sensitivity, ordered from least to most sensitive.
|
||||
** Null means not classified and is less sensitive than every declared value.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1142, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.actions.ai.DataClassification; delete when the pin moves to RC.6.
|
||||
public enum DataClassification
|
||||
{
|
||||
/*******************************************************************************
|
||||
** May be shown to anyone.
|
||||
*******************************************************************************/
|
||||
PUBLIC,
|
||||
|
||||
/*******************************************************************************
|
||||
** Business-confidential information.
|
||||
*******************************************************************************/
|
||||
CONFIDENTIAL,
|
||||
|
||||
/*******************************************************************************
|
||||
** Personally identifiable information.
|
||||
*******************************************************************************/
|
||||
PII,
|
||||
|
||||
/*******************************************************************************
|
||||
** Protected health information under HIPAA.
|
||||
*******************************************************************************/
|
||||
PHI;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this classification is at least as sensitive as other.
|
||||
** Every classification is at least as sensitive as null.
|
||||
*******************************************************************************/
|
||||
public boolean isAtLeast(DataClassification other)
|
||||
{
|
||||
return (other == null || ordinal() >= other.ordinal());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The more sensitive classification; null loses to any declared value.
|
||||
** Returns null when both values are null.
|
||||
*******************************************************************************/
|
||||
public static DataClassification max(DataClassification a, DataClassification b)
|
||||
{
|
||||
return (a == null ? b : a.isAtLeast(b) ? a : b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessDecision; delete when the pin moves to RC.6.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent; delete when the pin moves to RC.6.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType; delete when the pin moves to RC.6.
|
||||
public enum FieldAccessEventType
|
||||
{
|
||||
VIEW, EXACT_MATCH_FILTER, DENIED
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome; delete when the pin moves to RC.6.
|
||||
public enum FieldAccessOutcome
|
||||
{
|
||||
MATCH, NO_MATCH, REFUSED
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessResolverInterface; delete when the pin moves to RC.6.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier; delete when the pin moves to RC.6.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.6): copy of com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle; delete when the pin moves to RC.6.
|
||||
public enum FieldMaskStyle
|
||||
{
|
||||
AUTO, EMAIL, LAST_4, FIRST_LAST, FULL
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary registry for the subset needed by this QBit.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1142, removeBy=RC.6): copy of com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData registration subset; delete when the pin moves to RC.6.
|
||||
public class TableFeaturePermissionsMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
private final Map<String, String> entries = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry name, distinct from the future core registry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("securedDataTableFeatures");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance registry.
|
||||
*******************************************************************************/
|
||||
public static TableFeaturePermissionsMetaData ofOrWithNew(QInstance instance)
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(instance, "securedDataTableFeatures", TableFeaturePermissionsMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the QBit's entry.
|
||||
*******************************************************************************/
|
||||
public TableFeaturePermissionsMetaData withPermission(String key, String value)
|
||||
{
|
||||
entries.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only registration view.
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getEntries()
|
||||
{
|
||||
return (Map.copyOf(entries));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.secureddata.support;
|
||||
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClientChannelsMetaData;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
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.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adapters for #1141/#1142 APIs missing from the pinned released core.
|
||||
*******************************************************************************/
|
||||
public final class CoreMetadataBridge
|
||||
{
|
||||
private static final String PERMISSIONS = "com.kingsrook.qqq.backend.core.model.metadata.permissions.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CoreMetadataBridge()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalized client channel, with RC.5 session-adapter support.
|
||||
*******************************************************************************/
|
||||
public static String channel(QSession session)
|
||||
{
|
||||
return (session == null ? null : (String) optional(session, "getClientChannelName"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a code classification from released/future core or temporary metadata.
|
||||
*******************************************************************************/
|
||||
public static DataClassification classification(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
Object ai = field == null ? AiTableMetaData.of(table) : AiFieldMetaData.of(field);
|
||||
Object result = ai == null ? null : optional(ai, "getClassification");
|
||||
if(result != null)
|
||||
{
|
||||
return (DataClassification.valueOf(result.toString()));
|
||||
}
|
||||
Object fallback = field == null ? table.getSupplementalMetaData("securedDataAi") : field.getSupplementalMetaData("securedDataAi");
|
||||
return (fallback == null ? null : (DataClassification) optional(fallback, "getClassification"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Protect the QBit's own AI routing metadata on either core version.
|
||||
*******************************************************************************/
|
||||
public static void classifyTable(QTableMetaData table)
|
||||
{
|
||||
AiTableMetaData ai = AiTableMetaData.ofOrWithNew(table);
|
||||
ai.withExposure(AiExposure.NONE);
|
||||
setClassification(ai, DataClassification.CONFIDENTIAL);
|
||||
table.withSupplementalMetaData(new com.kingsrook.qbits.secureddata.standin.AiTableMetaData().withClassification(DataClassification.CONFIDENTIAL));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed opt-in name heuristics without overriding explicit code metadata.
|
||||
*******************************************************************************/
|
||||
public static void classifyField(QFieldMetaData field, DataClassification classification)
|
||||
{
|
||||
AiFieldMetaData ai = AiFieldMetaData.of(field);
|
||||
if(ai == null)
|
||||
{
|
||||
ai = new AiFieldMetaData();
|
||||
field.withSupplementalMetaData(ai);
|
||||
}
|
||||
setClassification(ai, classification);
|
||||
field.withSupplementalMetaData(new com.kingsrook.qbits.secureddata.standin.AiFieldMetaData().withClassification(classification));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy the classification into core when that property exists.
|
||||
*******************************************************************************/
|
||||
private static void setClassification(Object ai, DataClassification classification)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.DataClassification");
|
||||
Object value = type.getMethod("valueOf", String.class).invoke(null, classification.name());
|
||||
ai.getClass().getMethod("withClassification", type).invoke(ai, value);
|
||||
}
|
||||
catch(ClassNotFoundException | NoSuchMethodException expected)
|
||||
{
|
||||
// RC.5 has exposure-only metadata; the stand-in carries classification.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to set core classification", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the assistant wall and both features. Catalog applies to all tables.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
ClientChannelsMetaData.ofOrWithNew(instance).withDeniedPattern("assistant", "secured_data_*");
|
||||
registerChannel(instance);
|
||||
for(String suffix : new String[] {"secured_data_full", "secured_data_reveal"})
|
||||
{
|
||||
String label = "Secured data: " + suffix.substring("secured_data_".length());
|
||||
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(suffix, label);
|
||||
try
|
||||
{
|
||||
Class<?> registry = Class.forName(PERMISSIONS + "TableFeaturePermissionsMetaData");
|
||||
Class<?> entry = Class.forName(PERMISSIONS + "TableFeaturePermission");
|
||||
Object feature = entry.getConstructor().newInstance();
|
||||
entry.getMethod("withSuffix", String.class).invoke(feature, suffix);
|
||||
entry.getMethod("withLabel", String.class).invoke(feature, label);
|
||||
Object metadata = registry.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
registry.getMethod("withPermission", entry).invoke(metadata, feature);
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
// RC.5 uses the stand-in catalog until core exposes the registry.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to register table features", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core channel enforcement when the #1141 API is installed.
|
||||
*******************************************************************************/
|
||||
private static void registerChannel(QInstance instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName(PERMISSIONS + "ClientChannelsMetaData");
|
||||
Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
type.getMethod("withDeniedPattern", String.class, String.class).invoke(metadata, "assistant", "secured_data_*");
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
// The RC.5 custom permission checker also enforces the local wall.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to register client channel wall", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use core's feature semantics, including table READ and permission base names.
|
||||
*******************************************************************************/
|
||||
public static boolean hasFeature(QTableMetaData table, String suffix)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ((Boolean) PermissionsHelper.class.getMethod("hasTableFeaturePermission", QTableMetaData.class, String.class).invoke(null, table, suffix));
|
||||
}
|
||||
catch(NoSuchMethodException expected)
|
||||
{
|
||||
if(table.getIsHidden() || !PermissionsHelper.hasTablePermission(new GetInput(table.getName()), table.getName(), TablePermissionSubType.READ))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, QContext.getQInstance());
|
||||
String base = permissionBase(table);
|
||||
return (rules.getLevel() == PermissionLevel.NOT_PROTECTED || QContext.getQSession().hasPermission(base + "." + suffix));
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Core feature permission check failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing properties are expected only on RC.5; invocation errors fail closed.
|
||||
*******************************************************************************/
|
||||
private static Object optional(Object target, String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (target.getClass().getMethod(name).invoke(target));
|
||||
}
|
||||
catch(NoSuchMethodException expected)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
catch(IllegalAccessException | InvocationTargetException e)
|
||||
{
|
||||
throw (new IllegalStateException("Core metadata adapter failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 keeps core's equivalent helper package-private.
|
||||
*******************************************************************************/
|
||||
public static String permissionBase(QTableMetaData table)
|
||||
{
|
||||
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, QContext.getQInstance());
|
||||
String base = rules.getPermissionBaseName();
|
||||
return (base == null || base.isBlank() ? table.getName() : base);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Promote temporary host code classifications so future core AI routing sees them.
|
||||
*******************************************************************************/
|
||||
public static void promoteClassifications(QInstance instance)
|
||||
{
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
DataClassification tableClassification = classification(table, null);
|
||||
if(tableClassification != null)
|
||||
{
|
||||
setClassification(AiTableMetaData.ofOrWithNew(table), tableClassification);
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
DataClassification fieldClassification = classification(table, field);
|
||||
if(fieldClassification != null)
|
||||
{
|
||||
AiFieldMetaData ai = AiFieldMetaData.of(field);
|
||||
if(ai == null)
|
||||
{
|
||||
ai = new AiFieldMetaData();
|
||||
field.withSupplementalMetaData(ai);
|
||||
}
|
||||
setClassification(ai, fieldClassification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.secureddata.support;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System-session lookups and writes; restoring the caller even after a failure.
|
||||
*******************************************************************************/
|
||||
public final class SystemWrites
|
||||
{
|
||||
private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work inside a system session.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Work<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Execute.
|
||||
*******************************************************************************/
|
||||
T run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SystemWrites()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the transaction and instance, replacing only the session.
|
||||
*******************************************************************************/
|
||||
public static <T> T call(Work<T> work) throws QException
|
||||
{
|
||||
QSession previous = QContext.getQSession();
|
||||
DEPTH.set(DEPTH.get() + 1);
|
||||
QContext.setQSession(new QSystemUserSession(QContext.getQInstance()));
|
||||
try(CorePermissionEnforcer.AuthorizedScope ignored = CorePermissionEnforcer.openAuthorizedScope())
|
||||
{
|
||||
return (work.run());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setQSession(previous);
|
||||
DEPTH.set(DEPTH.get() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reentrancy guard used by the resolver.
|
||||
*******************************************************************************/
|
||||
public static boolean active()
|
||||
{
|
||||
return (DEPTH.get() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query without user locks or field access recursion.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Unable to read secured-data state", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert and reject backend record errors.
|
||||
*******************************************************************************/
|
||||
public static QRecord insertOne(String table, QRecord record) throws QException
|
||||
{
|
||||
QRecord inserted = call(() -> new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().getFirst());
|
||||
if(inserted.getErrors() != null && !inserted.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Secured-data system insert failed"));
|
||||
}
|
||||
return (inserted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.logs;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.person;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policy;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core field access bridge it.
|
||||
*******************************************************************************/
|
||||
class CoreFieldAccessBridgeIT
|
||||
{
|
||||
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.6 field-access core is not on this classpath");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** End to end query get exact match and denial matrix.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void endToEndQueryGetExactMatchAndDenialMatrix() throws Exception
|
||||
{
|
||||
QInstance instance = setup();
|
||||
for(String classification : List.of("PII", "PHI"))
|
||||
{
|
||||
policy("cs_rep", classification, "MASKED");
|
||||
policy("cs_admin", classification, "REVEALABLE");
|
||||
policy("admin", classification, "FULL");
|
||||
}
|
||||
QRecord person = insert("person", new QRecord().withValue("email", EMAIL).withValue("ssn", SSN).withValue("notes", "ordinary"));
|
||||
for(String role : List.of("cs_rep", "cs_admin", "admin"))
|
||||
{
|
||||
QContext.setQSession(user(role, role));
|
||||
QRecord query = new QueryAction().execute(new QueryInput("person")).getRecords().getFirst();
|
||||
QRecord get = new GetAction().execute(new GetInput("person").withPrimaryKey(person.getValue("id"))).getRecord();
|
||||
for(QRecord row : List.of(query, get))
|
||||
{
|
||||
assertThat(row.getValueString("notes")).isEqualTo("ordinary");
|
||||
if(role.equals("admin"))
|
||||
{
|
||||
assertThat(row.getValueString("email")).isEqualTo(EMAIL);
|
||||
assertThat(row.getValueString("ssn")).isEqualTo(SSN);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(row.getValueString("email")).isNotNull().isNotEqualTo(EMAIL);
|
||||
assertThat(row.getValueString("ssn")).isNotNull().isNotEqualTo(SSN);
|
||||
}
|
||||
}
|
||||
}
|
||||
QContext.setQSession(user("cs_admin", "cs_admin"));
|
||||
assertThat(search(EMAIL)).hasSize(1).allMatch(row -> !EMAIL.equals(row.getValueString("email")));
|
||||
assertThat(search("NOT-IN-TABLE")).isEmpty();
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput("person").withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("email"))))).hasMessageContaining("Permission denied");
|
||||
QContext.setQSession(user("cs_rep", "cs_rep"));
|
||||
assertThatThrownBy(() -> search(EMAIL)).hasMessageContaining("Permission denied");
|
||||
QContext.setQSession(user("admin", "admin"));
|
||||
assertThat(search(EMAIL)).hasSize(1).allMatch(row -> EMAIL.equals(row.getValueString("email")));
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs().stream().filter(row -> "EXACT_MATCH_FILTER".equals(row.getValueString("event_type"))).map(row -> row.getValueString("outcome"))).contains("MATCH", "NO_MATCH");
|
||||
assertThat(logs().stream().filter(row -> "FILTER_DENIED".equals(row.getValueString("event_type")))).isNotEmpty();
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(logs())).doesNotContain(EMAIL, SSN, "NOT-IN-TABLE");
|
||||
QContext.setQSession(channelUser("assistant/t1", "secured_data.admin"));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SecuredDataTables.ACCESS_LOG))).hasMessageContaining("Permission denied");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core preflight reports exactly one refused log and honors entropy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void corePreflightReportsExactlyOneRefusedLogAndHonorsEntropy() throws Exception
|
||||
{
|
||||
setup();
|
||||
policy("cs_admin", "PII", "REVEALABLE");
|
||||
for(int i = 0; i < 20; i++)
|
||||
{
|
||||
assertThat(search("known" + i)).isEmpty();
|
||||
}
|
||||
assertThatThrownBy(() -> search("known21")).hasMessageContaining("Exact-match search limit reached for [email]");
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs().stream().filter(row -> "EXACT_MATCH_FILTER".equals(row.getValueString("event_type")))) .hasSize(21);
|
||||
assertThat(logs().stream().filter(row -> "REFUSED".equals(row.getValueString("outcome")))) .hasSize(1);
|
||||
classification("email", "PII");
|
||||
QRecord row = com.kingsrook.qbits.secureddata.support.SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter()).getFirst();
|
||||
update(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("id", row.getValue("id")).withValue("low_cardinality", true));
|
||||
assertThatThrownBy(() -> search("known")).hasMessageContaining("Permission denied");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> search(String email) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("person").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email)))).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.secureddata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.IntStream;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.standin.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.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.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;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.logs;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.person;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policy;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured data access logger test.
|
||||
*******************************************************************************/
|
||||
class SecuredDataAccessLoggerTest
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
static FieldAccessEvent event(FieldAccessEventType type, FieldAccessOutcome outcome, String fingerprint)
|
||||
{
|
||||
return (new FieldAccessEvent().withType(type).withOutcome(outcome).withTableName("person").withFieldNames(List.of("email"))
|
||||
.withActionName("query").withRecordCount(outcome == FieldAccessOutcome.MATCH ? 1 : 0)
|
||||
.withSearchValueFingerprints(fingerprint == null ? null : List.of(fingerprint)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Twenty first search refused including unflushed rows and persisted history.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void twentyFirstSearchRefusedIncludingUnflushedRowsAndPersistedHistory() throws Exception
|
||||
{
|
||||
setup();
|
||||
policy("cs_admin", "PII", "REVEALABLE");
|
||||
var resolver = SecuredDataRuntime.current().resolver();
|
||||
var logger = SecuredDataRuntime.current().logger();
|
||||
for(int i = 0; i < 20; i++)
|
||||
{
|
||||
var event = event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.MATCH, resolver.fingerprintSearchValue("known"));
|
||||
resolver.checkExactMatch(event);
|
||||
resolver.onAccess(event);
|
||||
if(i == 9)
|
||||
{
|
||||
logger.flush();
|
||||
}
|
||||
}
|
||||
var refused = event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.REFUSED, resolver.fingerprintSearchValue("sentinel"));
|
||||
assertThatThrownBy(() -> resolver.checkExactMatch(refused)).isInstanceOf(QPermissionDeniedException.class)
|
||||
.hasMessage("Exact-match search limit reached for [email]. Try again later or ask an administrator.");
|
||||
resolver.onAccess(refused);
|
||||
logger.flush();
|
||||
assertThat(logs()).hasSize(21);
|
||||
assertThat(logs().stream().filter(row -> "REFUSED".equals(row.getValueString("outcome")))).hasSize(1);
|
||||
assertThat(JsonUtils.toJson(logs())).doesNotContain("sentinel").doesNotContain("known");
|
||||
CLOCK.now = NOW.plusSeconds(3601);
|
||||
resolver.checkExactMatch(event(FieldAccessEventType.EXACT_MATCH_FILTER, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Misses and distinct values flag and raise through real alerts spi.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missesAndDistinctValuesFlagAndRaiseThroughRealAlertsSpi() throws Exception
|
||||
{
|
||||
QInstance instance = setup();
|
||||
AlertsSink.signals.clear();
|
||||
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(AlertsSink.class));
|
||||
var resolver = SecuredDataRuntime.current().resolver();
|
||||
for(int i = 0; i < 5; i++)
|
||||
{
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, resolver.fingerprintSearchValue("same")));
|
||||
}
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs().stream().filter(row -> Boolean.TRUE.equals(row.getValueBoolean("anomaly_flag")))) .hasSize(1);
|
||||
assertThat(AlertsSink.signals).hasSize(1);
|
||||
assertThat(AlertsSink.signals.getFirst().getDedupKey()).isEqualTo("secured_data.enumeration:u1:person.email:2026-10-13");
|
||||
assertThat(AlertsSink.signals.getFirst().getAlertTypeName()).isEqualTo("secured_data.enumeration");
|
||||
QContext.setQSession(user("different", "cs_admin"));
|
||||
for(int i = 0; i < 16; i++)
|
||||
{
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.MATCH, resolver.fingerprintSearchValue("value-" + i)));
|
||||
}
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs().stream().filter(row -> Boolean.TRUE.equals(row.getValueBoolean("anomaly_flag")))).hasSize(2);
|
||||
assertThat(AlertsSink.signals).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match resets miss streak and policy can disable all controls.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void matchResetsMissStreakAndPolicyCanDisableAllControls() throws Exception
|
||||
{
|
||||
setup();
|
||||
var resolver = SecuredDataRuntime.current().resolver();
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, "hash"));
|
||||
}
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.MATCH, "hash"));
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, "hash"));
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs()).allMatch(row -> !row.getValueBoolean("anomaly_flag"));
|
||||
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("exact_match_per_hour", 0)
|
||||
.withValue("miss_alert_consecutive", 0).withValue("distinct_value_alert", 0).withValue("min_entropy_enabled", false));
|
||||
for(int i = 0; i < 25; i++)
|
||||
{
|
||||
var event = event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, resolver.fingerprintSearchValue(i));
|
||||
resolver.checkExactMatch(event);
|
||||
resolver.onAccess(event);
|
||||
}
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs()).allMatch(row -> !row.getValueBoolean("anomaly_flag"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Views only log configured classifications with fifty ids and channel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void viewsOnlyLogConfiguredClassificationsWithFiftyIdsAndChannel() throws Exception
|
||||
{
|
||||
setup();
|
||||
QContext.setQSession(channelUser("assistant/t1", "cs_admin"));
|
||||
var logger = SecuredDataRuntime.current().logger();
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null).withFieldNames(List.of("notes")));
|
||||
List<Serializable> ids = IntStream.rangeClosed(1, 80).mapToObj(i -> (Serializable) i).toList();
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null).withFieldNames(List.of("notes", "email", "ssn")).withPrimaryKeys(ids).withRecordCount(80));
|
||||
logger.onAccess(event(FieldAccessEventType.DENIED, null, null));
|
||||
logger.flush();
|
||||
assertThat(logs()).hasSize(2);
|
||||
QRecord view = logs().stream().filter(row -> "VIEW".equals(row.getValueString("event_type"))).findFirst().orElseThrow();
|
||||
assertThat(view.getValueString("field_name")).isEqualTo("email,ssn");
|
||||
assertThat(view.getValueString("client_channel")).isEqualTo("assistant");
|
||||
assertThat(JsonUtils.toObject(view.getValueString("record_id"), Integer[].class)).hasSize(50);
|
||||
assertThat(logs().stream().anyMatch(row -> "FILTER_DENIED".equals(row.getValueString("event_type")))) .isTrue();
|
||||
SecuredDataRuntime.current().config().withViewLogClassifications(Set.of(DataClassification.CONFIDENTIAL));
|
||||
classification("notes", "CONFIDENTIAL");
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null).withFieldNames(List.of("notes", "email")));
|
||||
logger.flush();
|
||||
assertThat(logs()).hasSize(3);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue overflow and shutdown are observable without failing access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queueOverflowAndShutdownAreObservableWithoutFailingAccess() throws Exception
|
||||
{
|
||||
setup(config -> config.withAccessLogQueueCapacity(1));
|
||||
var logger = SecuredDataRuntime.current().logger();
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
assertThat(logger.getDroppedCount()).isEqualTo(1);
|
||||
logger.close();
|
||||
assertThat(logs()).hasSize(1);
|
||||
logger.onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
assertThat(logger.getDroppedCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Asynchronous worker drains in system context.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void asynchronousWorkerDrainsInSystemContext() throws Exception
|
||||
{
|
||||
setup(config -> config.withAccessLogFlushIntervalMillis(10));
|
||||
SecuredDataRuntime.current().logger().onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while(logs().isEmpty() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertThat(logs()).hasSize(1);
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("u1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concurrent preflight cannot spend the same last slot.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void concurrentPreflightCannotSpendTheSameLastSlot() throws Exception
|
||||
{
|
||||
QInstance instance = setup();
|
||||
var resolver = SecuredDataRuntime.current().resolver();
|
||||
for(int i = 0; i < 19; i++)
|
||||
{
|
||||
resolver.checkExactMatch(event(FieldAccessEventType.EXACT_MATCH_FILTER, null, null));
|
||||
}
|
||||
AtomicInteger accepted = new AtomicInteger();
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try(var pool = Executors.newFixedThreadPool(8))
|
||||
{
|
||||
List<java.util.concurrent.Future<?>> futures = new ArrayList<>();
|
||||
for(int i = 0; i < 8; i++)
|
||||
{
|
||||
futures.add(pool.submit(() ->
|
||||
{
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, user("u1", "cs_admin")), () ->
|
||||
{
|
||||
start.await();
|
||||
try
|
||||
{
|
||||
resolver.checkExactMatch(event(FieldAccessEventType.EXACT_MATCH_FILTER, null, null));
|
||||
accepted.incrementAndGet();
|
||||
}
|
||||
catch(QPermissionDeniedException expected)
|
||||
{
|
||||
}
|
||||
});
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for(var future : futures)
|
||||
{
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
assertThat(accepted).hasValue(1);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consecutive misses span an hour but distinct values do not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void consecutiveMissesSpanAnHourButDistinctValuesDoNot() throws Exception
|
||||
{
|
||||
QInstance instance = setup();
|
||||
AlertsSink.signals.clear();
|
||||
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(AlertsSink.class));
|
||||
var resolver = SecuredDataRuntime.current().resolver();
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, resolver.fingerprintSearchValue("same")));
|
||||
}
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
CLOCK.now = NOW.plusSeconds(3601);
|
||||
resolver.onAccess(event(FieldAccessEventType.EXACT_MATCH_FILTER, FieldAccessOutcome.NO_MATCH, resolver.fingerprintSearchValue("same")));
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(AlertsSink.signals).hasSize(1);
|
||||
assertThat(logs().stream().filter(row -> Boolean.TRUE.equals(row.getValueBoolean("anomaly_flag")))) .hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Break glass identity is captured before worker switches to system.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void breakGlassIdentityIsCapturedBeforeWorkerSwitchesToSystem() throws Exception
|
||||
{
|
||||
setup();
|
||||
QRecord grant = insert(SecuredDataTables.BREAK_GLASS, new QRecord().withValue("user_id", "u1").withValue("status", "ACTIVE")
|
||||
.withValue("classification_ceiling", "PHI").withValue("started_at", NOW).withValue("expires_at", NOW.plusSeconds(60)));
|
||||
SecuredDataRuntime.current().logger().onAccess(event(FieldAccessEventType.VIEW, null, null));
|
||||
SecuredDataRuntime.current().logger().flush();
|
||||
assertThat(logs().getFirst().getValue("break_glass_id")).isEqualTo(grant.getValue("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Alerts sink.
|
||||
*******************************************************************************/
|
||||
public static class AlertsSink implements AlertSinkInterface
|
||||
{
|
||||
static final List<AlertSignal> signals = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise.
|
||||
*******************************************************************************/
|
||||
public void raise(AlertSignal signal)
|
||||
{
|
||||
signals.add(signal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve.
|
||||
*******************************************************************************/
|
||||
public void resolve(AlertResolution resolution)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Heartbeat.
|
||||
*******************************************************************************/
|
||||
public void heartbeat(String key, Instant at)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.secureddata;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
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;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.logs;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.person;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policy;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured data ddl test.
|
||||
*******************************************************************************/
|
||||
class SecuredDataDdlTest
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Postgres ddl runs in h2and supports actual qqq writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void postgresDdlRunsInH2AndSupportsActualQqqWrites() throws Exception
|
||||
{
|
||||
String url = "jdbc:h2:mem:secured_data;MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
|
||||
try(Connection connection = DriverManager.getConnection(url, "sa", ""))
|
||||
{
|
||||
String ddl = Files.readString(Path.of("docs/ddl/secured-data-postgres.sql"));
|
||||
for(String sql : ddl.split(";"))
|
||||
{
|
||||
if(!sql.isBlank())
|
||||
{
|
||||
connection.createStatement().execute(sql);
|
||||
}
|
||||
}
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName("rdbms").withVendor("h2").withHostName("mem")
|
||||
.withDatabaseName("secured_data").withUsername("sa");
|
||||
backend.setJdbcUrl(url);
|
||||
QInstance instance = base();
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(person("rdbms").withBackendDetails(new RDBMSTableBackendDetails().withTableName("person")));
|
||||
connection.createStatement().execute("CREATE TABLE person (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email VARCHAR(255), ssn VARCHAR(255), notes TEXT)");
|
||||
new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig().withBackendName("rdbms").withClock(CLOCK)
|
||||
.withAccessLogFlushIntervalMillis(60000).withTableMetaDataCustomizer((i, t) -> t.withBackendDetails(new RDBMSTableBackendDetails().withTableName(t.getName())))).produce(instance);
|
||||
QContext.init(instance, user("u1", "cs_admin"));
|
||||
RUNTIMES.add(SecuredDataRuntime.current());
|
||||
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().flush();
|
||||
assertThat(logs()).hasSize(1);
|
||||
assertThatThrownBy(() -> connection.createStatement().execute("INSERT INTO secured_data_classification (table_name, classification) VALUES ('person', 'PHI')"))
|
||||
.isInstanceOf(java.sql.SQLException.class);
|
||||
assertThatThrownBy(() -> connection.createStatement().execute("INSERT INTO secured_data_policy (name, permission_name, classification, tier, reveal_minutes) VALUES ('x','x','PII','FULL',61)"))
|
||||
.isInstanceOf(java.sql.SQLException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.List;
|
||||
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.qbits.secureddata.standin.ClientChannelsMetaData;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
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;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.channelUser;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
|
||||
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.logs;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.person;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured data qbit producer test.
|
||||
*******************************************************************************/
|
||||
class SecuredDataQBitProducerTest
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces complete metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producesCompleteMetadata() throws Exception
|
||||
{
|
||||
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");
|
||||
for(String name : SecuredDataTables.ALL)
|
||||
{
|
||||
QTableMetaData table = instance.getTable(name);
|
||||
assertThat(table.getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
assertThat(table.getSections()).isNotEmpty();
|
||||
assertThat(CoreMetadataBridge.classification(table, null)).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
table.getFields().values().forEach(field -> assertThat(field.getBackendName()).isEqualTo(field.getName()));
|
||||
}
|
||||
assertThat(SecuredDataTables.esbTableNames()).containsExactlyElementsOf(SecuredDataTables.MAIN);
|
||||
assertThat(SecuredDataEnums.sources()).hasSize(6);
|
||||
assertThat(ClientChannelsMetaData.ofOrWithNew(instance).getEntries()).containsEntry("assistant", "secured_data_*");
|
||||
assertThat(TableFeaturePermissionsMetaData.ofOrWithNew(instance).getEntries()).containsKeys("secured_data_full", "secured_data_reveal");
|
||||
assertThat(instance.getTable(SecuredDataTables.ACCESS_LOG).getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
assertThat(instance.getJoin(SecuredDataTables.REVEALS_JOIN)).isNotNull();
|
||||
assertThat(instance.getWidget(SecuredDataTables.REVEALS_WIDGET)).isNotNull();
|
||||
assertThat(instance.getApp("secured_data").getChildren()).hasSize(4);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Config and producer contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configAndProducerContract() throws Exception
|
||||
{
|
||||
SecuredDataQBitConfig config = new SecuredDataQBitConfig().withBackendName("memory")
|
||||
.withDefaultTier(DataClassification.PHI, FieldAccessTier.HIDDEN).withDefaultTierByClassification(Map.of("PHI", "FULL"))
|
||||
.withNameHeuristics(true).withTableMetaDataCustomizer((instance, table) -> table);
|
||||
assertThat(config.defaultTier(DataClassification.PHI)).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(config.defaultTier(DataClassification.PII)).isEqualTo(FieldAccessTier.MASKED);
|
||||
SecuredDataQBitProducer producer = new SecuredDataQBitProducer().withQBitConfig(config).withNamespace("test");
|
||||
assertThat(producer.getNamespace()).isEqualTo("test");
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(new SecuredDataQBitProducer().isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> new SecuredDataQBitConfig().withAccessLogQueueCapacity(0).validate()).isInstanceOf(IllegalArgumentException.class);
|
||||
setup();
|
||||
assertThatThrownBy(() -> new SecuredDataQBitProducer().withQBitConfig(config).produce(QContext.getQInstance())).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin can read but cannot forge activity and assistant cannot read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminCanReadButCannotForgeActivityAndAssistantCannotRead() throws Exception
|
||||
{
|
||||
QInstance instance = setup();
|
||||
QContext.setQSession(user("admin", "secured_data.admin"));
|
||||
assertThat(new QueryAction().execute(new QueryInput(SecuredDataTables.POLICY)).getRecords()).isEmpty();
|
||||
for(String table : SecuredDataTables.SYSTEM_WRITTEN)
|
||||
{
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(table).withRecord(new QRecord()))).hasMessageContaining("Permission denied");
|
||||
}
|
||||
QContext.setQSession(channelUser("assistant/t1", "secured_data.admin"));
|
||||
for(String table : SecuredDataTables.ALL)
|
||||
{
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(table))).hasMessageContaining("Permission denied");
|
||||
}
|
||||
QContext.setQSession(user("ordinary"));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SecuredDataTables.CLASSIFICATION))).hasMessageContaining("Permission denied");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fields picker and classification uniqueness.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fieldsPickerAndClassificationUniqueness() throws Exception
|
||||
{
|
||||
setup();
|
||||
FieldsOfTablePossibleValueSource picker = new FieldsOfTablePossibleValueSource();
|
||||
assertThat(picker.search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("table_name", "person")))).hasSize(4);
|
||||
assertThat(picker.search(new SearchPossibleValueSourceInput())).isEmpty();
|
||||
assertThat(picker.getPossibleValue("email").getId()).isEqualTo("email");
|
||||
assertThat(picker.getPossibleValue(null)).isNull();
|
||||
classification(null, "CONFIDENTIAL");
|
||||
assertThatThrownBy(() -> classification(null, "PHI")).hasMessageContaining("system insert failed");
|
||||
assertThatThrownBy(() -> classification("no_such_field", "PHI")).hasMessageContaining("system insert failed");
|
||||
assertThatThrownBy(() -> insert(SecuredDataTables.POLICY, policyRow("x", "PII", "FULL").withValue("reveal_minutes", 61))).hasMessageContaining("system insert failed");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** System cannot update or delete an access log.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void systemCannotUpdateOrDeleteAnAccessLog() throws Exception
|
||||
{
|
||||
setup();
|
||||
QRecord row = insert(SecuredDataTables.ACCESS_LOG, new QRecord().withValue("event_type", "VIEW").withValue("table_name", "person").withValue("created_at", NOW));
|
||||
assertThatThrownBy(() -> update(SecuredDataTables.ACCESS_LOG, new QRecord().withValue("id", row.getValue("id")).withValue("outcome", "DENIED"))).isInstanceOf(Exception.class);
|
||||
assertThatThrownBy(() -> delete(SecuredDataTables.ACCESS_LOG, row)).isInstanceOf(Exception.class);
|
||||
assertThat(logs()).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versions invalidate after commit and never for rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void versionsInvalidateAfterCommitAndNeverForRollback() throws Exception
|
||||
{
|
||||
setup();
|
||||
var runtime = SecuredDataRuntime.current();
|
||||
var customizer = new com.kingsrook.qbits.secureddata.metadata.SecuredDataTableCustomizers();
|
||||
var transaction = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction();
|
||||
var input = new InsertInput(SecuredDataTables.CLASSIFICATION).withTransaction(transaction);
|
||||
long before = runtime.classificationVersion();
|
||||
customizer.postInsert(input, List.of(new QRecord().withValue("id", 42)));
|
||||
assertThat(runtime.classificationVersion()).isEqualTo(before);
|
||||
transaction.rollback();
|
||||
assertThat(runtime.classificationVersion()).isEqualTo(before);
|
||||
customizer.postInsert(input, List.of(new QRecord().withValue("id", 43)));
|
||||
transaction.commit();
|
||||
assertThat(runtime.classificationVersion()).isGreaterThan(before);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Code heuristics are opt in and preserve explicit classifications.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void codeHeuristicsAreOptInAndPreserveExplicitClassifications() throws Exception
|
||||
{
|
||||
QInstance instance = 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));
|
||||
QTableMetaData table = person("memory");
|
||||
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("diagnosis_code", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING));
|
||||
instance.addTable(table);
|
||||
new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig().withNameHeuristics(true)).produce(instance);
|
||||
QContext.init(instance, user("u1"));
|
||||
RUNTIMES.add(SecuredDataRuntime.current());
|
||||
assertThat(SecuredDataClassifications.effective(table, "diagnosis_code")).isEqualTo(DataClassification.PHI);
|
||||
assertThat(SecuredDataClassifications.effective(table, "email")).isEqualTo(DataClassification.PII);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.standin.AiTableMetaData;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldAccessTier;
|
||||
import com.kingsrook.qbits.secureddata.standin.FieldMaskStyle;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
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.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;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
|
||||
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.person;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policy;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
|
||||
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured data resolver test.
|
||||
*******************************************************************************/
|
||||
class SecuredDataResolverTest
|
||||
{
|
||||
QTableMetaData person;
|
||||
SecuredDataResolver resolver;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Init.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void init() throws Exception
|
||||
{
|
||||
person = setup().getTable("person");
|
||||
resolver = SecuredDataRuntime.current().resolver();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tier.
|
||||
*******************************************************************************/
|
||||
FieldAccessTier tier(String field)
|
||||
{
|
||||
return (resolver.resolve(person, person.getField(field)).tier());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification order and highest.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void classificationOrderAndHighest() throws Exception
|
||||
{
|
||||
assertThat(SecuredDataClassifications.effective(person)).isNull();
|
||||
assertThat(SecuredDataClassifications.effective(person, "email")).isEqualTo(DataClassification.PII);
|
||||
person.withSupplementalMetaData(new AiTableMetaData().withClassification(DataClassification.CONFIDENTIAL));
|
||||
assertThat(SecuredDataClassifications.effective(person, "notes")).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
assertThat(SecuredDataClassifications.effective(person, "email")).isEqualTo(DataClassification.PII);
|
||||
QRecord tableRow = classification(null, "PHI");
|
||||
assertThat(SecuredDataClassifications.effective(person, "email")).isEqualTo(DataClassification.PHI);
|
||||
assertThat(SecuredDataClassifications.effective(person)).isEqualTo(DataClassification.PHI);
|
||||
QRecord fieldRow = classification("email", "PUBLIC");
|
||||
assertThat(SecuredDataClassifications.effective(person, "email")).isEqualTo(DataClassification.PUBLIC);
|
||||
assertThat(SecuredDataClassifications.highest(person)).isEqualTo(DataClassification.PHI);
|
||||
assertThat(SecuredDataClassifications.highest(person, List.of("email"))).isEqualTo(DataClassification.PUBLIC);
|
||||
assertThat(SecuredDataClassifications.highest(person, List.of())).isNull();
|
||||
delete(SecuredDataTables.CLASSIFICATION, fieldRow);
|
||||
delete(SecuredDataTables.CLASSIFICATION, tableRow);
|
||||
assertThat(SecuredDataClassifications.effective(person, "email")).isEqualTo(DataClassification.PII);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Defaults public primary key and admin separation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultsPublicPrimaryKeyAndAdminSeparation() throws Exception
|
||||
{
|
||||
assertThat(tier("id")).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(tier("notes")).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.MASKED);
|
||||
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.MASKED);
|
||||
classification("notes", "CONFIDENTIAL");
|
||||
assertThat(tier("notes")).isEqualTo(FieldAccessTier.FULL);
|
||||
QContext.setQSession(user("admin", "secured_data.admin"));
|
||||
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.MASKED);
|
||||
QTableMetaData own = QContext.getQInstance().getTable(SecuredDataTables.POLICY);
|
||||
assertThat(resolver.resolve(own, own.getField("name")).tier()).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(SystemWrites.call(() -> tier("ssn"))).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.MASKED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Least restrictive active matching policy and table scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void leastRestrictiveActiveMatchingPolicyAndTableScope() throws Exception
|
||||
{
|
||||
policy("cs_admin", "PII", "REVEALABLE");
|
||||
policy("other_role", "PII", "FULL");
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
|
||||
QRecord full = policy("cs_admin", "PII", "FULL");
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
|
||||
update(SecuredDataTables.POLICY, new QRecord().withValue("id", full.getValue("id")).withValue("is_active", false));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
|
||||
QTableMetaData another = new QTableMetaData().withName("another").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER));
|
||||
QContext.getQInstance().addTable(another);
|
||||
update(SecuredDataTables.POLICY, new QRecord().withValue("id", full.getValue("id")).withValue("is_active", true).withValue("table_name", "another"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Channel rows only cap including features and break glass.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void channelRowsOnlyCapIncludingFeaturesAndBreakGlass() throws Exception
|
||||
{
|
||||
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "FULL").withValue("client_channel", "assistant"));
|
||||
QContext.setQSession(channelUser("assistant/t1", "cs_admin"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.MASKED);
|
||||
policy("cs_admin", "PII", "FULL");
|
||||
QRecord cap = insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "HIDDEN").withValue("client_channel", "assistant"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.HIDDEN);
|
||||
QContext.setQSession(user("u1", "cs_admin"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
|
||||
QContext.setQSession(channelUser("assistant/t2", "cs_admin", "person.secured_data_full"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.HIDDEN);
|
||||
breakGlass("PHI", null);
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.HIDDEN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Feature grants have no admin shortcut.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void featureGrantsHaveNoAdminShortcut() throws Exception
|
||||
{
|
||||
policy("cs_admin", "PII", "HIDDEN");
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.HIDDEN);
|
||||
QContext.setQSession(user("u1", "cs_admin", "person.secured_data_reveal"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.REVEALABLE);
|
||||
QContext.setQSession(user("u1", "person.secured_data_full"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Break glass.
|
||||
*******************************************************************************/
|
||||
QRecord breakGlass(String ceiling, String scope) throws Exception
|
||||
{
|
||||
return (insert(SecuredDataTables.BREAK_GLASS, new QRecord().withValue("user_id", "u1").withValue("classification_ceiling", ceiling)
|
||||
.withValue("table_name", scope).withValue("status", "ACTIVE").withValue("started_at", NOW).withValue("expires_at", NOW.plusSeconds(60))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Break glass ceiling user and exact expiry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void breakGlassCeilingUserAndExactExpiry() throws Exception
|
||||
{
|
||||
QRecord grant = breakGlass("PII", "person");
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.FULL);
|
||||
assertThat(tier("ssn")).isEqualTo(FieldAccessTier.MASKED);
|
||||
QContext.setQSession(user("other"));
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.MASKED);
|
||||
QContext.setQSession(user("u1"));
|
||||
String before = resolver.getAccessFingerprint();
|
||||
CLOCK.now = NOW.plusSeconds(60);
|
||||
assertThat(tier("email")).isEqualTo(FieldAccessTier.MASKED);
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(before);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fingerprint tracks only relevant state.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fingerprintTracksOnlyRelevantState() throws Exception
|
||||
{
|
||||
String initial = resolver.getAccessFingerprint();
|
||||
assertThat(resolver.getAccessFingerprint()).isEqualTo(initial);
|
||||
QContext.setQSession(user("u1", "cs_admin", "unrelated.permission"));
|
||||
assertThat(resolver.getAccessFingerprint()).isEqualTo(initial);
|
||||
insert("person", new QRecord().withValue("notes", "unrelated"));
|
||||
assertThat(resolver.getAccessFingerprint()).isEqualTo(initial);
|
||||
QRecord policy = policy("cs_admin", "PII", "REVEALABLE");
|
||||
String afterPolicy = resolver.getAccessFingerprint();
|
||||
assertThat(afterPolicy).isNotEqualTo(initial);
|
||||
QRecord classification = classification("email", "PHI");
|
||||
String afterClassification = resolver.getAccessFingerprint();
|
||||
assertThat(afterClassification).isNotEqualTo(afterPolicy);
|
||||
update(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("id", classification.getValue("id")).withValue("classification", "PII"));
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(afterClassification);
|
||||
String beforeBreak = resolver.getAccessFingerprint();
|
||||
QRecord grant = breakGlass("PHI", null);
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(beforeBreak);
|
||||
update(SecuredDataTables.BREAK_GLASS, new QRecord().withValue("id", grant.getValue("id")).withValue("status", "ENDED"));
|
||||
String ended = resolver.getAccessFingerprint();
|
||||
delete(SecuredDataTables.BREAK_GLASS, grant);
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(ended);
|
||||
String beforeDelete = resolver.getAccessFingerprint();
|
||||
delete(SecuredDataTables.POLICY, policy);
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(beforeDelete);
|
||||
String beforeChannel = resolver.getAccessFingerprint();
|
||||
QContext.setQSession(channelUser("assistant/t1", "cs_admin"));
|
||||
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(beforeChannel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entropy auto and both overrides.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({"BOOLEAN, flag", "DATE, dob", "DATE_TIME, timestamp", "INTEGER, birthYear", "STRING, state", "STRING, region", "STRING, province", "STRING, zip3"})
|
||||
void entropyAutoAndBothOverrides(String type, String name) throws Exception
|
||||
{
|
||||
QFieldMetaData field = new QFieldMetaData(name, QFieldType.valueOf(type)).withMaxLength(255);
|
||||
person.addField(field);
|
||||
CoreMetadataBridge.classifyField(field, DataClassification.PII);
|
||||
assertThat(resolver.resolve(person, field).exactMatchAllowed()).isFalse();
|
||||
QRecord row = insert(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("table_name", "person").withValue("field_name", name)
|
||||
.withValue("classification", "PII").withValue("low_cardinality", false).withValue("mask_style", "FULL"));
|
||||
assertThat(resolver.resolve(person, field).exactMatchAllowed()).isTrue();
|
||||
assertThat(resolver.resolve(person, field).maskStyle()).isEqualTo(FieldMaskStyle.FULL);
|
||||
update(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("id", row.getValue("id")).withValue("low_cardinality", true));
|
||||
assertThat(resolver.resolve(person, field).exactMatchAllowed()).isFalse();
|
||||
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("min_entropy_enabled", false));
|
||||
assertThat(resolver.resolve(person, field).exactMatchAllowed()).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enum short string and year label auto rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void enumShortStringAndYearLabelAutoRules()
|
||||
{
|
||||
assertThat(SecuredDataResolver.lowCardinality(new QFieldMetaData("kind", QFieldType.STRING).withPossibleValueSourceName("enum"), null)).isTrue();
|
||||
assertThat(SecuredDataResolver.lowCardinality(new QFieldMetaData("code", QFieldType.STRING).withMaxLength(5), null)).isTrue();
|
||||
assertThat(SecuredDataResolver.lowCardinality(new QFieldMetaData("y", QFieldType.INTEGER).withLabel("Birth year"), null)).isTrue();
|
||||
assertThat(SecuredDataResolver.lowCardinality(new QFieldMetaData("code", QFieldType.STRING).withMaxLength(6), null)).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Most restrictive guardrails and hmac.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mostRestrictiveGuardrailsAndHmac() throws Exception
|
||||
{
|
||||
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "FULL").withValue("exact_match_per_hour", 0).withValue("min_entropy_enabled", false));
|
||||
insert(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE").withValue("exact_match_per_hour", 7).withValue("miss_alert_consecutive", 3));
|
||||
var guardrails = resolver.guardrails(person, "email");
|
||||
assertThat(guardrails.exactMatchPerHour()).isEqualTo(7);
|
||||
assertThat(guardrails.missAlertConsecutive()).isEqualTo(3);
|
||||
assertThat(guardrails.minEntropyEnabled()).isTrue();
|
||||
String hash = resolver.fingerprintSearchValue("CANARY-SEARCH");
|
||||
assertThat(hash).isEqualTo(resolver.fingerprintSearchValue("CANARY-SEARCH")).doesNotContain("CANARY");
|
||||
assertThat(hash).isNotEqualTo(resolver.fingerprintSearchValue("different"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reveal grant scope and expiration.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void revealGrantScopeAndExpiration() throws Exception
|
||||
{
|
||||
insert(SecuredDataTables.REVEAL_GRANT, new QRecord().withValue("user_id", "u1").withValue("table_name", "person")
|
||||
.withValue("record_id", "7").withValue("field_name", "email").withValue("granted_at", NOW).withValue("expires_at", NOW.plusSeconds(10))
|
||||
.withValue("access_log_id", 1));
|
||||
assertThat(resolver.isRevealActive("person", "email", 7)).isTrue();
|
||||
assertThat(resolver.isRevealActive("person", "ssn", 7)).isFalse();
|
||||
assertThat(resolver.isRevealActive("person", "email", 8)).isFalse();
|
||||
CLOCK.now = NOW.plusSeconds(10);
|
||||
assertThat(resolver.isRevealActive("person", "email", 7)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
|
||||
import com.kingsrook.qbits.secureddata.standin.ClientChannelSession;
|
||||
import com.kingsrook.qbits.secureddata.standin.DataClassification;
|
||||
import com.kingsrook.qbits.secureddata.support.CoreMetadataBridge;
|
||||
import com.kingsrook.qbits.secureddata.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
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.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
|
||||
/*******************************************************************************
|
||||
** Memory-backed, permission-enforced fixtures shared by direct and bridge tests.
|
||||
*******************************************************************************/
|
||||
final class SecuredDataTestSupport
|
||||
{
|
||||
static final Instant NOW = Instant.parse("2026-10-13T12:00:00Z");
|
||||
static final MutableClock CLOCK = new MutableClock();
|
||||
static final List<SecuredDataRuntime> RUNTIMES = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
static QInstance setup() throws QException
|
||||
{
|
||||
return (setup(config ->
|
||||
{
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
static QInstance setup(Consumer<SecuredDataQBitConfig> tweak) throws QException
|
||||
{
|
||||
close();
|
||||
MemoryRecordStore.fullReset();
|
||||
CLOCK.now = NOW;
|
||||
QInstance instance = base();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(person("memory"));
|
||||
SecuredDataQBitConfig config = new SecuredDataQBitConfig().withClock(CLOCK).withAccessLogFlushIntervalMillis(60000);
|
||||
tweak.accept(config);
|
||||
new SecuredDataQBitProducer().withQBitConfig(config).produce(instance);
|
||||
QContext.init(instance, user("u1", "cs_admin"));
|
||||
RUNTIMES.add(SecuredDataRuntime.current());
|
||||
new QInstanceValidator().validate(instance);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Base.
|
||||
*******************************************************************************/
|
||||
static QInstance base()
|
||||
{
|
||||
return (new QInstance().withEnforcePermissionsInCoreActions(true)
|
||||
.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Person.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData person(String backend)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName("person").withBackendName(backend).withPrimaryKeyField("id")
|
||||
.withLabel("Person").withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("email", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(new QFieldMetaData("ssn", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(new QFieldMetaData("notes", QFieldType.TEXT));
|
||||
CoreMetadataBridge.classifyField(table.getField("email"), DataClassification.PII);
|
||||
CoreMetadataBridge.classifyField(table.getField("ssn"), DataClassification.PHI);
|
||||
table.getFields().values().forEach(field -> field.setBackendName(field.getName()));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User.
|
||||
*******************************************************************************/
|
||||
static QSession user(String id, String... permissions)
|
||||
{
|
||||
List<String> grants = new ArrayList<>(Arrays.asList(permissions));
|
||||
grants.add("person.read");
|
||||
return (new QSession().withUser(new QUser().withIdReference(id).withFullName(id)).withPermissions(grants));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Channel user.
|
||||
*******************************************************************************/
|
||||
static QSession channelUser(String channel, String... permissions) throws Exception
|
||||
{
|
||||
QSession session;
|
||||
try
|
||||
{
|
||||
session = new QSession();
|
||||
QSession.class.getMethod("withClientChannel", String.class).invoke(session, channel);
|
||||
}
|
||||
catch(NoSuchMethodException expected)
|
||||
{
|
||||
session = new ClientChannelSession().withClientChannel(channel);
|
||||
}
|
||||
List<String> grants = new ArrayList<>(Arrays.asList(permissions));
|
||||
grants.add("person.read");
|
||||
return (session.withUser(new QUser().withIdReference("u1").withFullName("User One")).withPermissions(grants));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy.
|
||||
*******************************************************************************/
|
||||
static QRecord policy(String permission, String classification, String tier) throws QException
|
||||
{
|
||||
return (insert(SecuredDataTables.POLICY, policyRow(permission, classification, tier)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy row.
|
||||
*******************************************************************************/
|
||||
static QRecord policyRow(String permission, String classification, String tier)
|
||||
{
|
||||
return (new QRecord().withValue("name", permission + " " + classification).withValue("permission_name", permission)
|
||||
.withValue("classification", classification).withValue("tier", tier).withValue("is_active", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification.
|
||||
*******************************************************************************/
|
||||
static QRecord classification(String field, String classification) throws QException
|
||||
{
|
||||
return (insert(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("table_name", "person")
|
||||
.withValue("field_name", field).withValue("classification", classification)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert.
|
||||
*******************************************************************************/
|
||||
static QRecord insert(String table, QRecord row) throws QException
|
||||
{
|
||||
return (SystemWrites.insertOne(table, row));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update.
|
||||
*******************************************************************************/
|
||||
static void update(String table, QRecord row) throws QException
|
||||
{
|
||||
List<QRecord> records = SystemWrites.call(() -> new UpdateAction().execute(new UpdateInput(table).withRecord(row)).getRecords());
|
||||
org.assertj.core.api.Assertions.assertThat(records.getFirst().getErrors()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete.
|
||||
*******************************************************************************/
|
||||
static void delete(String table, QRecord row) throws QException
|
||||
{
|
||||
SystemWrites.call(() -> new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(List.of(row.getValue("id")))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Logs.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> logs()
|
||||
{
|
||||
return (SystemWrites.query(SecuredDataTables.ACCESS_LOG, new QQueryFilter()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close.
|
||||
*******************************************************************************/
|
||||
static void close()
|
||||
{
|
||||
RUNTIMES.forEach(SecuredDataRuntime::close);
|
||||
RUNTIMES.clear();
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mutable clock.
|
||||
*******************************************************************************/
|
||||
static class MutableClock extends Clock
|
||||
{
|
||||
Instant now = NOW;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get zone.
|
||||
*******************************************************************************/
|
||||
public ZoneId getZone()
|
||||
{
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With zone.
|
||||
*******************************************************************************/
|
||||
public Clock withZone(ZoneId zone)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instant.
|
||||
*******************************************************************************/
|
||||
public Instant instant()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user