Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 7f8e926f0c fix(internal): run system work as a QSystemUserSession
Db.asSystem built an anonymous QSession subclass with every permission
but no token. Token-based authentication modules (OAuth2, Auth0) accept a
QSystemUserSession without a token and reject any other tokenless session,
so on such hosts every system read and write failed in session validation
(JWTDecodeException: The token is null). ConfigSync never mirrored the
code-defined connections and buckets, and the operation log, object index,
capabilities and protection/header rule scoping silently did nothing.

Use core QSystemUserSession(instance), which carries the same permissions
and all-access keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:12:32 -05:00
james.maes 4741285e96 Merge pull request #15 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:46:16 -05:00
james.maesandClaude Opus 5.5 4cf6ed9d83 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:07:30 -05:00
james.maes 21d5172fc4 Merge pull request #14 from QRun-IO/feature/guardrails-rc6
build: QQQ 4.1.0-RC.6 + guardrails (parent 2.1.0, enforced)
2026-10-14 07:27:51 -05:00
james.maesandClaude Opus 5.5 5871f32b77 build: QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.0 with guardrails enforced
Move the core baseline to the released 4.1.0-RC.6 (architecture review
finding F19, guardrail G7). Adopt qbit-build-parent 2.1.0 with the
architecture guardrails enforced (G1, G3, G4, G7) and tests in strict
raw-route mode (G2). Everything passes with no source changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:18:42 -05:00
james.maes 97a79e031d Merge pull request #13 from QRun-IO/feature/bucket-max-upload-bytes
feat(api): StorageBucket.maxUploadBytes()
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 6ffcd32f99 feat(api): StorageBucket.maxUploadBytes()
Expose a bucket's upload limit in the api package (null means no limit),
so consumers such as qbit-collaboration need not read the internal
StorageBucketConfig through config(). Found by the qbit-guardrails G1-arch
check (qbit-bom#10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:03 -05:00
james.maes 64d23b03af Merge pull request #11 from QRun-IO/feature/arch-s3factory
refactor(connections): core S3ClientFactory on QQQ RC.5 (#10)
2026-10-13 19:46:14 -05:00
james.maesandClaude Opus 5.5 f56f40b8b7 docs: point the stand-in marker at QRun-IO/qqq#1165
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:36:15 -05:00
james.maesandClaude Opus 5.5 a58b179334 refactor(connections): build S3 clients with core S3ClientFactory (QQQ 4.1.0-RC.5)
ConnectionRuntime maps StorageConnectionConfig to S3BackendMetaData and
builds clients with core S3ClientFactory.buildClient, which is uncached.
The per-connection cache keyed by the settings hash is unchanged.
The duplicated HTTP, TLS and trust-manager code is deleted.

Still local:
- the presigner;
- a session-credentials bridge, because core has no session-token or
  credentials-provider hook;
- ${file.PATH} secret refs.
The last two are tagged STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7).

Retries now follow core and SDK defaults instead of a forced ADAPTIVE_V2.
Unused SFTP/SSH transitive dependencies of the filesystem module are
excluded.

Closes #10 (architecture review QRun-IO/qqq#1150, finding F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:36:05 -05:00
james.maes 4411376a9e Merge pull request #9 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:48:20 -05:00
james.maesandClaude Opus 5.5 b5857dd26e ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:37:07 -05:00
james.maes 780cfa7742 Merge pull request #8 from QRun-IO/feature/awssdk-2.41.34
build(deps): AWS SDK for Java v2 2.41.34 (GHSA-443w-3rq3-5m5h)
2026-10-13 10:40:40 -05:00
james.maesandClaude Opus 5.5 2e95c64a78 build(deps): AWS SDK for Java v2 2.41.10 -> 2.41.34 (GHSA-443w-3rq3-5m5h)
2.41.10 carries the HIGH advisory GHSA-443w-3rq3-5m5h (flagged by a Trivy scan); 2.41.30+ fixes it. Same minor line, so no API change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:23:36 -05:00
james.maes 767f237622 Merge pull request #6 from QRun-IO/feature/storage-polish
feat: standalone mode, key-folder references, docs
2026-10-12 17:43:55 -05:00
james.maesandClaude Sonnet 5.5 bda6758b36 feat: standalone mode, key-folder references, child-list columns, full docs
- ObjectStorage.configureStandalone(config) for CLI tools and tests (no QInstance)
- FieldReferenceProvider.keyFolder(...) for key-per-folder references
- child lists on record screens show a curated column set
- README and CHANGELOG rewritten (usage, deployment, owner steps)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:36:58 -05:00
james.maes c909e21f13 Merge pull request #4 from QRun-IO/feature/p1-storage-service
feat: P1 storage service, tables, probe and conformance suite
2026-10-12 17:25:38 -05:00
18 changed files with 850 additions and 225 deletions

No files matched your search

+2 -13
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -58,14 +58,3 @@ workflows:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
+25
View File
@@ -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 }}
+1 -1
View File
@@ -35,7 +35,7 @@ This file provides guidance to Codex when working with code in this repository.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+19 -1
View File
@@ -5,4 +5,22 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Repository scaffold: `ObjectStorageQBitConfig` and `ObjectStorageQBitProducer` (table prefix `object_storage_`, permissions `object_storage.*`), build, CI and repository standard files.
- `StorageBucket.maxUploadBytes()` in the `api` package: the bucket's upload limit in bytes (null when none), so other qbits need not call `config()`, which returns an internal type.
- **The service (P1).** `ObjectStorage.bucket(alias)` and `ObjectStorage.asSystem(caller, runId)`: put (multipart above the part size), get with ranges, head, copy (in parts for very large objects), header and metadata changes, delete (delete marker, private trash, or permanent), restore, list, presigned GET, PUT and POST, public URLs, tags. Key building and URL encoding, the header policy (`object_storage_header_rule`), `PublicUrlResolver`, `StorageChangeListener`, `StorageReferenceProvider`, `StorageAlertSink`.
- Configuration in code, upserted into visible `object_storage_*` tables; secret references with an allow-list; per-connection rate limits; per-bucket and per-connection record security locks.
- The capability model and the Test connection probe (read-only by default, write tests on request, clock skew, presign host), with seeded provider defaults.
- The `object_storage_operation` audit log (presigned URLs are logged without their signature) and protection rules (`READ_ONLY`, `NO_DELETE`, `WARN`, with allowed system callers).
- Backups follow-ups SF1 to SF5: `BucketRole.BACKUP`, object lock on put, system-caller permanent delete, probes that skip locked backup buckets, and the system-caller context.
- **The index (P2).** `object_storage_object` and `object_storage_folder` kept in step with each indexed bucket: resumable full scan with a mass-removal safety stop, HEAD backlog, write-through on every service write, soft-deleted rows, folder rollups. Orphaned and missing objects from `FieldReferenceProvider`, daily usage snapshots, quota and freshness alerts, the nightly job.
- **The Storage app (P3, P4).** Admin -> Storage with a responsive home; record screens with sections, child lists and a thumbnail preview; actions Rescan bucket, Check configuration, Refresh from bucket, Apply header policy (replaces app-specific backfills), Change headers and metadata, Edit tags, Copy, Move or rename, Delete, Restore, Create download link, Create upload link, folder rename and delete, Upload files, Test connection; every one a standard Review and Result with a bulk cap.
- `ObjectStorageSchema`: PostgreSQL DDL generated from the table metadata.
- Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL.
### Changed
- Raised the QQQ BOM baseline to released `4.1.0-RC.5` and delegated S3 client construction to core `S3ClientFactory.buildClient`. Removed local HTTP client and trust-manager implementations; existing endpoint, region, addressing, checksum, TLS/CA, credentials and timeout settings remain supported.
- Clients remain owned by each connection runtime and are rebuilt on reset/reinstall. Retry behavior now follows core/SDK defaults instead of forcing `ADAPTIVE_V2`; hosts can select the SDK retry mode through `AWS_RETRY_MODE` or `aws.retryMode`.
- Retained local presigning (including a separate endpoint/key). A request-credentials bridge preserves static session tokens because RC.5 core metadata/factory has no session-token field or credentials-provider hook. This bridge and the existing `${file.PATH}` resolver are marked for removal by RC.7 under issue #10.
- Added the filesystem module with its unused SFTP/SSH stack excluded. Resolved dependencies otherwise only change QQQ RC.4 to RC.5 and jsoup 1.23.1 to 1.23.2; see [dependency details](docs/S3-FACTORY-MIGRATION.md).
### Notes
- Deliberate differences from the design text: Test connection is two actions (read-only, and with write tests) so the write variant can require `object_storage.admin`; a folder record has no subfolder child list (core does not allow a self join as an exposed join); there is no `withTableNamePrefix` (the `object_storage_` prefix is fixed by the naming rule).
+77 -10
View File
@@ -5,38 +5,105 @@
> QQQ QBit: S3-compatible object storage service and Admin -> Storage app (browse, upload, presigned links, permission-checked and logged writes).
**Status:** design: [QRun-IO/qqq#1089](https://github.com/QRun-IO/qqq/pull/1089); implementation in progress. Nothing is published yet.
S3-compatible storage as a capability for any QQQ app, and an admin app to run it. Design: [QRun-IO/qqq#1089](https://github.com/QRun-IO/qqq/pull/1089).
1. **The service.** One Java API (`ObjectStorage.bucket("files")`) for put, get, head, copy, delete, restore, list, header and metadata changes, presigned upload and download URLs, public URLs and key building, over configured connections and buckets. Works against AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces, Ceph RGW and Garage. A capability model, filled in by a **Test connection** probe, says what each provider can do.
2. **The Storage app** (`Admin -> Storage`, optional). Buckets, objects, folders, connections, operations, header rules and protection rules as standard QQQ tables, an index of every indexed bucket, a home with real numbers, and standard Review/Result actions for every write.
**Status:** P1 (service), P2 (index, references, orphaned and missing objects, audit log) and P3/P4 (the app, read and write) are implemented. Not yet released: see [CHANGELOG.md](CHANGELOG.md).
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ `4.1.0-RC.5` (released BOM), including `qqq-backend-module-filesystem`. `ConnectionRuntime` maps connection settings to core's `S3BackendMetaData` and uses `S3ClientFactory.buildClient`; core owns HTTP, TLS and checksum setup. The unused SFTP/SSH dependency is excluded. See the [dependency diff](docs/S3-FACTORY-MIGRATION.md).
- A QQQ backend for the qbit's tables (PostgreSQL is the intended store). `ObjectStorageSchema.postgresScript(config)` generates the DDL from the same metadata the app runs on, for your Liquibase or Flyway migration.
## Install
Not yet published. Once released, add `com.kingsrook.qbits:qbit-object-storage` and produce the qbit into your `QInstance`:
```java
new ObjectStorageQBitProducer()
.withQBitConfig(new ObjectStorageQBitConfig().withBackendName("rdbms"))
.withQBitConfig(new ObjectStorageQBitConfig()
.withBackendName("rdbms")
.withConnection(new StorageConnectionConfig("primary")
.withProviderType(ProviderType.MINIO)
.withEndpoint("${env.S3_ENDPOINT}")
.withPresignEndpoint("${env.S3_PRESIGN_ENDPOINT}") // optional: the host clients reach the store by
.withRegion("${env.S3_REGION}")
.withAddressingStyle(AddressingStyle.PATH)
.withAccessKeyRef("${env.S3_ACCESS_KEY}")
.withSecretKeyRef("${env.S3_SECRET_KEY}")
.withPresignAccessKeyRef("${env.S3_PRESIGN_ACCESS_KEY}") // a least-privilege key for presigned URLs
.withPresignSecretKeyRef("${env.S3_PRESIGN_SECRET_KEY}"))
.withBucket(new StorageBucketConfig("files").withConnection("primary").withBucketName("${env.S3_BUCKET_NAME}")
.withRole(BucketRole.PUBLIC).withPublicBaseUrl("${env.PUBLIC_BASE_URL}").withIndexed(true).withReferenceTracked(true))
.withBucket(new StorageBucketConfig("incoming").withConnection("primary").withBucketName("${env.INCOMING_BUCKET}")
.withRole(BucketRole.INCOMING).withIndexed(false))
.withReferenceProvider(FieldReferenceProvider.keyPrefix("files", "media", "storageKey"))
.withAdminApp(true).withParentAppName("admin"))
.produce(qInstance);
```
Connections and buckets come from this code only. Secrets are **references** (`${env.X}`, `${prop.X}`, `${file.PATH}`) that must match `allowedSecretRefPatterns` (default `env.S3_*`, `env.STORAGE_*`, `file./var/run/secrets/storage/*`); no table stores a secret value. At startup the qbit upserts one `object_storage_connection` and one `object_storage_bucket` row per entry, so the tables always show the real configuration.
Client settings take effect when the qbit configuration is reinstalled or the runtime is reset. Presigning keeps its separate public endpoint and credentials. Retry behavior now follows core/SDK defaults; hosts requiring adaptive retries can set `AWS_RETRY_MODE=adaptive` or `aws.retryMode=adaptive`. Static session tokens remain supported through a small local bridge pending core support.
## Use
```java
StorageBucket files = ObjectStorage.bucket("files");
files.put(PutRequest.of("media/42/hero.jpg", bytes).withContentType("image/jpeg")); // headers come from the header policy
URL link = files.presignGet(PresignGet.of("media/42/hero.jpg").ttl(Duration.ofMinutes(5)));
String publicUrl = files.publicUrl("media/42/hero.jpg");
files.delete(DeleteRequest.of("media/42/hero.jpg")); // soft where the bucket allows it
// a calling qbit that checked its own permission (for example backups):
ObjectStorage.asSystem("backups", runId).bucket("backups").put(PutRequest.of(key, stream).withObjectLock(LockMode.COMPLIANCE, until));
```
Every call is synchronous, rate limited per connection, throws `StorageException` with a stable reason (`NOT_FOUND`, `ACCESS_DENIED`, `UNSUPPORTED`, `PRECONDITION_FAILED`, `THROTTLED`, `PROVIDER_ERROR`) and the provider's request id, and every write lands in `object_storage_operation`. Other qbits plug in with `ObjectHeaderPolicy`, `PublicUrlResolver`, `StorageChangeListener`, `StorageReferenceProvider` and `StorageAlertSink` (a no-op unless the Alerts qbit is installed).
## What is in the app
| Where | What |
|---|---|
| Home | Buckets, data health (orphaned and missing objects), recent changes, cache-header coverage, usage trend, connections. Each section says what is missing when it is empty. |
| Tables | `object_storage_bucket`, `_object`, `_folder`, `_connection`, `_operation`, `_header_rule`, `_protection_rule` in the nav; capabilities, versions, references and daily usage appear as child lists on their parents. |
| Bucket actions | Rescan bucket, Check configuration, Apply header policy, Create upload link |
| Object actions | Refresh from bucket, Apply header policy, Change headers and metadata, Edit tags, Copy objects, Move or rename objects, Delete objects, Restore objects, Create download link |
| Folder actions | Move or rename folder, Delete folder contents, Upload files to this folder |
| Table actions | Upload files |
| Connection actions | Test connection, Test connection with write tests |
Every write is a standard streamed ETL: **Review** (the dry run, with summary lines) then **Result**. Deletes are soft wherever the bucket has versioning or a private trash (Restore undoes them); referenced, protected and object-locked objects are skipped and the Review says why; bulk runs are capped at 10,000 objects unless the user has `object_storage.admin`.
Permissions: `object_storage.view`, `.write`, `.delete`, `.admin` (admin implies the rest). Per-bucket scoping uses record security locks on the `object_storage_bucket` and `object_storage_connection` security key types (switch off with `withBucketScoping(false)` for a single-administrator app).
## Deployment notes
- **Uploads** go through the app server (100 MB per file by default, per bucket `maxUploadBytes`), using QQQ's standard file-upload field, so the app needs `uploadedFileArchiveTableName` set in its Javalin metadata, as for any file upload.
- **Previews** are short-lived (5 minute) presigned GETs, so the Next dashboard's `img-src` must allow the storage host (it allows `https:` by default). Video, audio and PDF previews need same-origin media (the dashboard's `media-src` and `frame-src` are `'self'`); a same-origin download route is the planned follow-up. Until then those files show the file card with download and open links.
- **Scheduler.** Set `withSchedulerName(...)` to run the nightly scan, HEAD backlog, reference scan, usage snapshot and trash purge. Without it, run Rescan bucket by hand.
- **Alerts.** With the Alerts qbit installed, bind a `StorageAlertSink` adapter (`ObjectStorageRuntime.setAlertSink`); without it every condition is still visible on the home and in the tables.
## Conformance
`ObjectStorageConformanceSuite` runs the service against a store through Testcontainers on every PR: **MinIO** (the digest-pinned Chainguard build the QQQ tests use, since Docker Hub's `minio/minio` is gone) and **Garage** (digest-pinned). The index and the actions also run on **PostgreSQL**. AWS S3, R2 and B2 run from an owner-run workflow with scratch buckets; Wasabi, Spaces and Ceph results are community reported. See the design for the provider matrix.
## Demo server
`src/test/java/.../devserver/StorageDevServer` starts MinIO and an in-memory QQQ instance with generated images, header rules, references, an index and some history, and serves the Next dashboard, for looking at the app in a browser.
## Naming
Tables are `object_storage_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `object_storage.<action>`.
## Adopting in another app
Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) once the first release is published.
## Build
```bash
./mvnw verify
```
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM. The tests need Docker (Testcontainers); in CI a missing Docker fails the build instead of skipping.
## License
+86
View File
@@ -0,0 +1,86 @@
# Core S3 factory migration (issue #10, F3)
Core source was inspected at local QRun-IO/qqq tag `v4.1.0-RC.5`:
`qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/{utils/S3ClientFactory,model/metadata/S3BackendMetaData}.java`.
## Ownership and host behavior
`ConnectionRuntime` resolves secret/setting references and maps endpoint, region,
path style, checksum mode, TLS verification, extra CA PEM, static keys, and both
HTTP timeouts into `S3BackendMetaData`. Default-chain and web-identity modes leave
keys unset so core uses the SDK default credentials chain. Provider-specific qbit
defaults and the existing 5-second connect / 60-second read timeouts are preserved.
Core handles custom CA trust in addition to the JVM trust store.
The qbit calls uncached `buildClient`, because it owns and closes each client.
`ObjectStorageRuntime` caches by connection name and resets clients on
configuration install/reset; `settingsHash` is an informational table value,
not the cache key. This migration keeps that lifecycle and avoids sharing a
core-cached client that the qbit would later close. Configuration changes take
effect on reinstall/reset, not through mutation of an already cached client.
Core does not expose retry configuration. Requests now use core/SDK retry
defaults instead of the former forced `ADAPTIVE_V2`. Hosts needing adaptive
retries can set the SDK's `AWS_RETRY_MODE=adaptive` or `aws.retryMode=adaptive`.
The public qbit configuration API is unchanged.
## Remaining local code
- Core RC.5 has no session-token field or credentials-provider customization
in `S3BackendMetaData` / `S3ClientFactory.buildClient`. The narrow
`SessionCredentialsClient` bridge adds session credentials to requests while
retaining their overrides; core still creates the client and HTTP transport.
Core needs session-token metadata (including enrichment and cache-key support)
and `AwsSessionCredentials` construction, or a caller-provided credentials hook.
Marker: `STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7)`.
- Core has no presigner factory. The local presigner retains its separate public
endpoint and least-privilege keys, along with existing session-token handling.
- `SecretRefs` file resolution is unchanged and carries the same RC.7 marker.
## Resolved dependency diff
Captured using the Maven 3.9.11 wrapper's `-B dependency:tree` before and after
this change. Coordinates below are normalized to omit tree indentation and
parent ordering. Full raw trees are in `target/dependency-tree-{before,after}.txt`
for this working session (generated build files, not committed).
```diff
- com.kingsrook.qqq:qqq-backend-core:jar:4.1.0-RC.4:compile
- com.kingsrook.qqq:qqq-backend-module-postgres:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-backend-module-rdbms:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-middleware-javalin:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-openapi:jar:4.1.0-RC.4:test
- org.jsoup:jsoup:jar:1.23.1:compile
+ com.kingsrook.qqq:qqq-backend-core:jar:4.1.0-RC.5:compile
+ com.kingsrook.qqq:qqq-backend-module-filesystem:jar:4.1.0-RC.5:compile
+ com.kingsrook.qqq:qqq-backend-module-postgres:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-backend-module-rdbms:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-middleware-javalin:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-openapi:jar:4.1.0-RC.5:test
+ org.jsoup:jsoup:jar:1.23.2:compile
```
The only new resolved artifact is `qqq-backend-module-filesystem`. It declares
`org.apache.sshd:sshd-sftp:2.16.0`, which would bring `sshd-core` and `sshd-common`;
that unused SFTP stack is excluded at the qbit dependency. No SSH libraries or
AWS SDK v1 artifacts are present in the resulting tree. AWS SDK v2 remains
`2.41.34`; `commons-lang3:3.20.0` was already present and is unchanged. The
filesystem module's test dependencies do not become runtime dependencies.
## Verification in this worktree
Java 21.0.12.1, Maven wrapper 3.9.11; `./mvnw.cmd -B verify`:
- Checkstyle: zero violations; production and test compilation succeeded.
- Surefire: 119 tests reported, 32 passed, 87 skipped, zero failures/errors.
All eight new `ConnectionRuntimeTest` tests passed, including actual HTTP
signing with a session token on GET, PUT and pagination.
- Docker Desktop access is denied at `npipe:////./pipe/dockerDesktopLinuxEngine`.
MinIO, Garage, standalone-service and PostgreSQL integration coverage could
not run. Testcontainers also reported its default `docker_engine` pipe unavailable.
- The full gate **failed** at JaCoCo: instructions 33.68% (required 70%), classes
70.75% (required 90%). Coverage thresholds and Docker test policy are unchanged.
- No unrelated RC.5 compatibility fixes were needed. `git diff --check` passed.
Rerun the full verification gate in an environment with Docker access before
merging. No changes were committed or pushed.
+17 -4
View File
@@ -18,6 +18,8 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -27,7 +29,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -72,7 +74,7 @@
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.41.10</version>
<version>2.41.34</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -80,6 +82,17 @@
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-filesystem</artifactId>
<exclusions>
<!-- Only the S3 factory is used; do not ship the SFTP backend's SSH stack. -->
<exclusion>
<groupId>org.apache.sshd</groupId>
<artifactId>sshd-sftp</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
@@ -228,14 +241,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -53,6 +53,7 @@ public class FieldReferenceProvider implements StorageReferenceProvider
{
KEY,
KEY_PREFIX,
KEY_FOLDER,
URL
}
@@ -97,6 +98,18 @@ public class FieldReferenceProvider implements StorageReferenceProvider
/*******************************************************************************
** The field holds the key of one object of a set that lives together in a
** folder (an original and its renditions): every object in that key's folder
** is referenced.
*******************************************************************************/
public static FieldReferenceProvider keyFolder(String bucketAlias, String tableName, String fieldName)
{
return (new FieldReferenceProvider(bucketAlias, tableName, fieldName, Holds.KEY_FOLDER));
}
/*******************************************************************************
** The field holds a public URL.
*******************************************************************************/
@@ -150,6 +163,15 @@ public class FieldReferenceProvider implements StorageReferenceProvider
.withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label));
}
}
else if(holds == Holds.KEY_FOLDER)
{
int slash = value.lastIndexOf('/');
if(slash > 0)
{
references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(StorageKeys.normalizePrefix(value.substring(0, slash + 1))).withMatchMode(MatchMode.PREFIX)
.withTableName(tableName).withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label));
}
}
else
{
references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(StorageKeys.normalizePrefix(value)).withMatchMode(holds == Holds.KEY_PREFIX ? MatchMode.PREFIX : MatchMode.KEY)
@@ -49,6 +49,27 @@ public final class ObjectStorage
/*******************************************************************************
** Install a configuration without a QQQ instance, for command-line tools and
** tests that use the service but not the tables or the app. Connections and
** buckets work as usual; nothing is written to the object_storage_* tables
** (there are none in this mode), so writes are logged to the application log
** only. Inside a QQQ application, produce the qbit instead.
*******************************************************************************/
public static void configureStandalone(com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig config)
{
java.util.List<String> errors = new java.util.ArrayList<>();
config.validate(null, errors);
if(!errors.isEmpty())
{
throw (new IllegalArgumentException("Object storage configuration is invalid: " + String.join("; ", errors)));
}
ObjectStorageRuntime.install(config);
ConfigSync.reset();
}
/*******************************************************************************
** The bucket behind an alias from config, called as the current session user.
*******************************************************************************/
@@ -128,6 +128,18 @@ public final class StorageBucket
/*******************************************************************************
** The largest object this bucket accepts on upload, in bytes, or null when
** the bucket sets no limit. Callers outside this qbit use this instead of
** config(), which returns an internal type.
*******************************************************************************/
public Long maxUploadBytes()
{
return (bucket.getMaxUploadBytes());
}
/*******************************************************************************
** The bucket configuration.
*******************************************************************************/
@@ -21,57 +21,38 @@
package com.kingsrook.qbits.objectstorage.internal;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Collection;
import java.util.function.Function;
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.http.SdkHttpConfigurationOption;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.DelegatingS3Client;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.S3Request;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.utils.AttributeMap;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** One connection's live state: the settings, one cached SDK v2 S3Client and
** S3Presigner, and the rate limiter.
**
** STAND-IN: this is the small internal client factory used until QQQ core's
** S3ClientFactory (qqq #1095, RC.5) is released; replace it then. Nothing
** outside this package depends on how the client is built.
** Core owns S3 client construction. This runtime owns its uncached client and
** closes it on reset/reinstall; the presigner remains local.
*******************************************************************************/
public class ConnectionRuntime
{
private static final QLogger LOG = QLogger.getLogger(ConnectionRuntime.class);
private final StorageConnectionConfig config;
private final RateLimiter rateLimiter;
private final String settingsHash;
@@ -119,7 +100,7 @@ public class ConnectionRuntime
/*******************************************************************************
** Hash of the settings that shape the client (clients rebuild when it changes).
** Display hash of connection settings; runtime reset/reinstall rebuilds clients.
*******************************************************************************/
public String getSettingsHash()
{
@@ -217,36 +198,41 @@ public class ConnectionRuntime
*******************************************************************************/
private S3Client buildClient()
{
S3ClientBuilder builder = S3Client.builder()
.credentialsProvider(credentials(config.getAccessKeyRef(), config.getSecretKeyRef()))
.httpClient(httpClient())
.forcePathStyle(pathStyle())
.overrideConfiguration(o -> o.retryStrategy(RetryMode.ADAPTIVE_V2));
String endpoint = SecretRefs.resolveSetting(config.getEndpoint());
if(StringUtils.hasContent(endpoint))
S3BackendMetaData backend = backendMetaData();
String token = config.getCredentialsMode() == CredentialsMode.STATIC_REFS ? SecretRefs.resolve(config.getSessionTokenRef()) : null;
AwsCredentialsProvider sessionCredentials = null;
if(token != null && backend.getAccessKey() != null && backend.getSecretKey() != null)
{
builder.endpointOverride(URI.create(endpoint));
}
String region = effectiveRegion();
if(region != null)
{
builder.region(Region.of(region));
sessionCredentials = StaticCredentialsProvider.create(AwsSessionCredentials.create(backend.getAccessKey(), backend.getSecretKey(), token));
}
if(config.effectiveChecksumMode() == ChecksumMode.WHEN_REQUIRED)
{
builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED);
builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED);
}
else
{
builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_SUPPORTED);
builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_SUPPORTED);
}
S3Client coreClient = S3ClientFactory.buildClient(backend);
return (sessionCredentials == null ? coreClient : new SessionCredentialsClient(coreClient, sessionCredentials));
}
LOG.debug("Building S3 client", logPair("connection", config.getName()), logPair("endpoint", endpoint), logPair("checksumMode", config.effectiveChecksumMode()));
return (builder.build());
/*******************************************************************************
** Resolve references before passing the connection settings to core. No bucket
** is mapped: one connection serves multiple buckets. Presigning stays local.
*******************************************************************************/
S3BackendMetaData backendMetaData()
{
S3BackendMetaData backend = new S3BackendMetaData()
.withEndpoint(SecretRefs.resolveSetting(config.getEndpoint()))
.withRegion(effectiveRegion())
.withPathStyleAccess(pathStyle())
.withChecksumMode(S3ChecksumMode.valueOf(config.effectiveChecksumMode().name()))
.withTlsVerify(config.getTlsVerify())
.withCaCertificatesPem(SecretRefs.resolve(config.getCaBundleRef()))
.withConnectTimeoutMillis(config.getConnectTimeoutMs())
.withSocketTimeoutMillis(config.getReadTimeoutMs());
if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS)
{
backend.withAccessKey(SecretRefs.resolve(config.getAccessKeyRef()))
.withSecretKey(SecretRefs.resolve(config.getSecretKeyRef()));
}
return (backend);
}
@@ -305,139 +291,41 @@ public class ConnectionRuntime
/*******************************************************************************
** Apache HTTP client with the connection's timeouts and TLS settings.
** STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7)
** RC.5 metadata/factory lacks a session token or credentials-provider hook.
** Override request credentials only; core still owns the client and transport.
*******************************************************************************/
private SdkHttpClient httpClient()
private static class SessionCredentialsClient extends DelegatingS3Client
{
ApacheHttpClient.Builder builder = ApacheHttpClient.builder()
.connectionTimeout(Duration.ofMillis(config.getConnectTimeoutMs()))
.socketTimeout(Duration.ofMillis(config.getReadTimeoutMs()));
if(Boolean.FALSE.equals(config.getTlsVerify()))
{
LOG.warn("TLS verification is disabled for a storage connection; use only for development", logPair("connection", config.getName()));
return (builder.buildWithDefaults(AttributeMap.builder().put(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES, Boolean.TRUE).build()));
}
String caPem = SecretRefs.resolve(config.getCaBundleRef());
if(StringUtils.hasContent(caPem))
{
TrustManager[] trustManagers = trustManagers(caPem);
builder.tlsTrustManagersProvider(() -> trustManagers);
}
return (builder.build());
}
private final AwsCredentialsProvider credentialsProvider;
/*******************************************************************************
** Trust the given PEM bundle in addition to the JVM default trust store.
*******************************************************************************/
static TrustManager[] trustManagers(String pem)
{
try
{
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
if(certificates.isEmpty())
{
throw (new IllegalArgumentException("The CA bundle contains no certificates"));
}
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
int index = 0;
for(Certificate certificate : certificates)
{
keyStore.setCertificateEntry("object-storage-ca-" + index++, certificate);
}
X509TrustManager custom = x509(keyStore);
X509TrustManager fallback = x509(null);
return (new TrustManager[] { new Composite(custom, fallback) });
}
catch(IllegalArgumentException e)
{
throw (e);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Could not load the CA bundle for a storage connection", e));
}
}
/*******************************************************************************
** The X509 trust manager of a key store (null: the JVM default).
*******************************************************************************/
private static X509TrustManager x509(KeyStore keyStore) throws Exception
{
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(keyStore);
for(TrustManager trustManager : factory.getTrustManagers())
{
if(trustManager instanceof X509TrustManager x)
{
return (x);
}
}
throw (new IllegalStateException("No X509TrustManager available"));
}
/*******************************************************************************
** Trusts a chain if either the private-CA or the default trust manager does.
*******************************************************************************/
private record Composite(X509TrustManager custom, X509TrustManager fallback) implements X509TrustManager
{
/*******************************************************************************
** Client certs are not used.
** Wrap a core-built client with the connection's session credentials.
*******************************************************************************/
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
private SessionCredentialsClient(S3Client delegate, AwsCredentialsProvider credentialsProvider)
{
try
{
custom.checkClientTrusted(chain, authType);
}
catch(CertificateException e)
{
fallback.checkClientTrusted(chain, authType);
}
super(delegate);
this.credentialsProvider = credentialsProvider;
}
/*******************************************************************************
** Server certs.
** Preserve request overrides and the concrete request type while adding the
** session token to signing, including streaming and paginated operations.
*******************************************************************************/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
@SuppressWarnings("unchecked") // toBuilder().build() retains the concrete request type T.
protected <T extends S3Request, ReturnT> ReturnT invokeOperation(T request, Function<T, ReturnT> operation)
{
try
{
custom.checkServerTrusted(chain, authType);
}
catch(CertificateException e)
{
fallback.checkServerTrusted(chain, authType);
}
}
/*******************************************************************************
** Accepted issuers of both.
*******************************************************************************/
@Override
public X509Certificate[] getAcceptedIssuers()
{
X509Certificate[] a = custom.getAcceptedIssuers();
X509Certificate[] b = fallback.getAcceptedIssuers();
X509Certificate[] all = new X509Certificate[a.length + b.length];
System.arraycopy(a, 0, all, 0, a.length);
System.arraycopy(b, 0, all, a.length, b.length);
return (all);
AwsRequestOverrideConfiguration.Builder overrides = request.overrideConfiguration()
.map(AwsRequestOverrideConfiguration::toBuilder).orElseGet(AwsRequestOverrideConfiguration::builder);
T signedRequest = (T) request.toBuilder()
.overrideConfiguration(overrides.credentialsProvider(credentialsProvider).build())
.build();
return (operation.apply(signedRequest));
}
}
}
@@ -40,8 +40,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
@@ -131,30 +131,15 @@ public final class Db
/*******************************************************************************
** A session that holds every permission and every all-access security key of
** the given instance (computed per instance, never cached across instances).
** The core system-user session (every permission and every all-access
** security key of the given instance, read per instance). It must be a
** QSystemUserSession: token-based authentication modules (OAuth2, Auth0)
** accept that class without a token and reject any other tokenless session,
** which made every system read and write fail on such hosts.
*******************************************************************************/
private static QSession systemSession(QInstance instance)
{
QSession session = new QSession()
{
/*******************************************************************************
** The system user holds every permission.
*******************************************************************************/
@Override
public boolean hasPermission(String permissionName)
{
return (true);
}
};
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
}
return (session);
return (new QSystemUserSession(instance));
}
@@ -36,7 +36,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** the allow-list. Resolved values live only in memory, never in a record, a log
** line or a process value.
**
** STAND-IN: ${file.PATH} moves to QMetaDataVariableInterpreter in QQQ core
** STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7)
** ${file.PATH} moves to QMetaDataVariableInterpreter in QQQ core
** (spec section 8.2); then this class delegates entirely.
*******************************************************************************/
public final class SecretRefs
@@ -206,6 +206,20 @@ public final class ObjectStorageTables
new String[] { OBJECT, OBJECT_VERSION, "objectId", "Versions" },
new String[] { OBJECT, OBJECT_REFERENCE, "objectId", "Referenced by" });
/** The columns a child list shows for each child table (the rest is one click away on the record). */
private static final Map<String, List<String>> CHILD_LIST_COLUMNS = Map.ofEntries(
Map.entry(OBJECT, List.of("objectKey", "size", "contentType", "lastModified", "referenceCount")),
Map.entry(FOLDER, List.of("path", "objectCount", "totalBytes")),
Map.entry(OBJECT_REFERENCE, List.of("label", "keyOrPrefix", "missing", "sourceTable")),
Map.entry(OPERATION, List.of("occurredAt", "action", "objectKey", "result", "actor")),
Map.entry(USAGE_DAILY, List.of("snapshotDate", "objectCount", "totalBytes")),
Map.entry(CONNECTION_CAPABILITY, List.of("capability", "status", "detail")),
Map.entry(BUCKET_CAPABILITY, List.of("capability", "status", "detail")),
Map.entry(BUCKET, List.of("alias", "role", "indexed", "objectCount")),
Map.entry(HEADER_RULE, List.of("name", "keyGlob", "cacheControl", "priority", "enabled")),
Map.entry(PROTECTION_RULE, List.of("prefixGlob", "mode", "enabled", "reason")),
Map.entry(OBJECT_VERSION, List.of("versionId", "isDeleteMarker", "isLatest", "lastModified")));
/** Tables that are nav entries (the rest appear as relations and child lists on their parents). */
public static final List<String> NAV_TABLES = List.of(BUCKET, OBJECT, FOLDER, CONNECTION, OPERATION, HEADER_RULE, PROTECTION_RULE);
@@ -367,7 +381,12 @@ public final class ObjectStorageTables
{
QJoinMetaData join = new QJoinMetaData().withName(joinName(relation)).withLeftTable(relation[0]).withRightTable(relation[1]).withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", relation[2]));
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(joinName(relation)).withLabel(relation[3]).withMaxRows(10).getWidgetMetaData());
ChildRecordListRenderer.Builder builder = ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(joinName(relation)).withLabel(relation[3]).withMaxRows(10);
if(CHILD_LIST_COLUMNS.containsKey(relation[1]))
{
builder.withOnlyIncludeFieldNames(CHILD_LIST_COLUMNS.get(relation[1]));
}
widgets.add(builder.getWidgetMetaData());
}
return (widgets);
}
@@ -0,0 +1,65 @@
package com.kingsrook.qbits.objectstorage.conformance;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qbits.objectstorage.api.Range;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The service without a QQQ instance (command-line tools, tests): connections
** and buckets work, and nothing needs the object_storage_* tables.
*******************************************************************************/
class StandaloneTest
{
/*******************************************************************************
** Reset the runtime.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
@Test
void theServiceWorksWithoutAnInstanceOrTables() throws Exception
{
Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available");
ConformanceTarget target = Targets.minio();
ObjectStorageQBitConfig config = StorageTestSupport.config(target, ChecksumMode.WHEN_REQUIRED);
QContext.clear();
ObjectStorage.configureStandalone(config);
String key = "standalone/" + System.nanoTime() + ".txt";
ObjectStorage.bucket("files").put(PutRequest.of(key, "no tables needed".getBytes(StandardCharsets.UTF_8)).withContentType("text/plain"));
try(var in = ObjectStorage.bucket("files").get(key, Range.all()))
{
assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("no tables needed");
}
ObjectStorage.bucket("files").delete(DeleteRequest.of(key));
assertThat(ObjectStorage.bucket("files").head(key)).isEmpty();
assertThat(ObjectStorage.bucket("files").maxUploadBytes()).isEqualTo(ObjectStorage.bucket("files").config().getMaxUploadBytes());
}
@Test
void aBadConfigIsRejectedWithEveryProblem()
{
ObjectStorageQBitConfig bad = new ObjectStorageQBitConfig();
assertThatThrownBy(() -> ObjectStorage.configureStandalone(bad)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("backendName");
}
}
@@ -473,6 +473,28 @@ class MinioIndexTest
@Test
void keyFolderReferencesCoverEveryObjectOfTheSet() throws Exception
{
rawPut(target.indexBucket, "set/one/original.jpg", 10);
rawPut(target.indexBucket, "set/one/thumbnail.jpg", 2);
rawPut(target.indexBucket, "set/two/original.jpg", 10);
ObjectIndex.scan("indexed");
StorageTestSupport.setUp(StorageTestSupport.config(target, com.kingsrook.qbits.objectstorage.config.ChecksumMode.WHEN_REQUIRED)
.withReferenceProvider(FieldReferenceProvider.keyFolder("indexed", "media", "storageKey")), instance ->
instance.addTable(new QTableMetaData().withName("media").withBackendName(StorageTestSupport.MEMORY).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("storageKey", QFieldType.STRING))));
ObjectIndex.scan("indexed");
StorageTestSupport.insert("media", new QRecord().withValue("storageKey", "set/one/original.jpg"), new QRecord().withValue("storageKey", "bare-name.jpg"));
ReferenceScan.Result result = ReferenceScan.run("indexed");
assertThat(result.references()).as("a key with no folder is not a reference").isEqualTo(1);
assertThat(rows("indexed").get("set/one/original.jpg").getValueInteger("referenceCount")).isEqualTo(1);
assertThat(rows("indexed").get("set/one/thumbnail.jpg").getValueInteger("referenceCount")).as("the rendition is covered").isEqualTo(1);
assertThat(rows("indexed").get("set/two/original.jpg").getValueInteger("referenceCount")).isZero();
}
@Test
void backupBucketsAreNeverReferenceScanned() throws Exception
{
@@ -0,0 +1,320 @@
/*
* 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.objectstorage.internal;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Mapping and lifecycle checks plus real SDK signing against a loopback server.
*******************************************************************************/
class ConnectionRuntimeTest
{
/*******************************************************************************
** Every core-supported setting is mapped after resolving references. Session
** tokens and presign settings have no core fields and are exercised below.
*******************************************************************************/
@Test
void testBackendMetaDataCarriesEverySetting(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = new StorageConnectionConfig("mapping")
.withEndpoint(reference(dir, "endpoint", "https://storage.example.test"))
.withRegion(reference(dir, "region", "eu-west-1"))
.withAddressingStyle(AddressingStyle.VIRTUAL_HOST)
.withChecksumMode(ChecksumMode.WHEN_SUPPORTED)
.withTlsVerify(false)
.withCaBundleRef(reference(dir, "ca", "test-pem-bundle"))
.withCredentialsMode(CredentialsMode.STATIC_REFS)
.withAccessKeyRef(reference(dir, "access", "test-access"))
.withSecretKeyRef(reference(dir, "secret", "test-secret"))
.withConnectTimeoutMs(1234)
.withReadTimeoutMs(5678);
S3BackendMetaData backend = new ConnectionRuntime(config).backendMetaData();
assertThat(backend.getEndpoint()).isEqualTo("https://storage.example.test");
assertThat(backend.getRegion()).isEqualTo("eu-west-1");
assertThat(backend.getPathStyleAccess()).isFalse();
assertThat(backend.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_SUPPORTED);
assertThat(backend.getTlsVerify()).isFalse();
assertThat(backend.getCaCertificatesPem()).isEqualTo("test-pem-bundle");
assertThat(backend.getAccessKey()).isEqualTo("test-access");
assertThat(backend.getSecretKey()).isEqualTo("test-secret");
assertThat(backend.getConnectTimeoutMillis()).isEqualTo(1234);
assertThat(backend.getSocketTimeoutMillis()).isEqualTo(5678);
assertThat(backend.getBucketName()).isNull();
}
/*******************************************************************************
** Provider defaults remain the qbit's defaults, including its 5s/60s timeouts.
*******************************************************************************/
@Test
void testBackendDefaults()
{
S3BackendMetaData compatible = new ConnectionRuntime(new StorageConnectionConfig("compatible")
.withEndpoint("http://localhost:9000")).backendMetaData();
assertThat(compatible.getRegion()).isEqualTo("us-east-1");
assertThat(compatible.getPathStyleAccess()).isTrue();
assertThat(compatible.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_REQUIRED);
assertThat(compatible.getTlsVerify()).isTrue();
assertThat(compatible.getCaCertificatesPem()).isNull();
assertThat(compatible.getConnectTimeoutMillis()).isEqualTo(5000);
assertThat(compatible.getSocketTimeoutMillis()).isEqualTo(60000);
S3BackendMetaData aws = new ConnectionRuntime(new StorageConnectionConfig("aws").withProviderType(ProviderType.AWS_S3)).backendMetaData();
assertThat(aws.getEndpoint()).isNull();
assertThat(aws.getRegion()).isNull();
assertThat(aws.getPathStyleAccess()).isFalse();
assertThat(aws.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_SUPPORTED);
}
/*******************************************************************************
** Default-chain and web-identity modes must ignore configured static refs.
*******************************************************************************/
@Test
void testChainCredentialsIgnoreStaticRefs(@TempDir Path dir) throws Exception
{
for(CredentialsMode mode : List.of(CredentialsMode.DEFAULT_CHAIN, CredentialsMode.WEB_IDENTITY))
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir).withCredentialsMode(mode)
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
assertThat(runtime.backendMetaData().getAccessKey()).isNull();
assertThat(runtime.backendMetaData().getSecretKey()).isNull();
assertThat(runtime.client().serviceClientConfiguration().credentialsProvider()).isInstanceOf(DefaultCredentialsProvider.class);
}
finally
{
runtime.close();
}
}
}
/*******************************************************************************
** Missing static refs preserve the prior fallback to the SDK default chain.
*******************************************************************************/
@Test
void testMissingStaticCredentialsFallBack(@TempDir Path dir) throws Exception
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir).withSecretKeyRef(null));
try
{
assertThat(runtime.client().serviceClientConfiguration().credentialsProvider()).isInstanceOf(DefaultCredentialsProvider.class);
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Clients belong to a runtime, never core's shared cache; close rebuilds from
** changed settings and does not affect another connection with equal settings.
*******************************************************************************/
@Test
void testClientOwnershipAndRebuild(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = config(dir);
ConnectionRuntime runtime = new ConnectionRuntime(config);
ConnectionRuntime sibling = new ConnectionRuntime(config);
try
{
S3Client first = runtime.client();
assertThat(runtime.client()).isSameAs(first);
assertThat(sibling.client()).isNotSameAs(first);
assertThat(first.serviceClientConfiguration().endpointOverride()).contains(URI.create("http://localhost:9000"));
assertThat(first.serviceClientConfiguration().region()).isEqualTo(Region.US_EAST_1);
assertThat(((AwsCredentialsProvider) first.serviceClientConfiguration().credentialsProvider()).resolveCredentials().accessKeyId()).isEqualTo("test-access");
assertThat(first.serviceClientConfiguration().requestChecksumCalculation()).isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED);
assertThat(first.serviceClientConfiguration().responseChecksumValidation()).isEqualTo(ResponseChecksumValidation.WHEN_REQUIRED);
runtime.close();
config.withRegion("eu-west-1").withChecksumMode(ChecksumMode.WHEN_SUPPORTED);
assertThat(runtime.client()).isNotSameAs(first);
assertThat(runtime.client().serviceClientConfiguration().region()).isEqualTo(Region.EU_WEST_1);
assertThat(runtime.client().serviceClientConfiguration().requestChecksumCalculation()).isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
assertThat(sibling.client().serviceClientConfiguration().region()).isEqualTo(Region.US_EAST_1);
}
finally
{
runtime.close();
sibling.close();
}
}
/*******************************************************************************
** Session credentials sign streaming reads/writes and paginator requests.
** Existing request headers survive the bridge's credentials override.
*******************************************************************************/
@Test
void testSessionSigningAndRequestOverrides(@TempDir Path dir) throws Exception
{
List<Headers> requests = new ArrayList<>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange ->
{
requests.add(exchange.getRequestHeaders());
exchange.getRequestBody().readAllBytes();
byte[] response = (exchange.getRequestURI().getQuery() != null && exchange.getRequestURI().getQuery().contains("list-type")
? "<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>" : "data").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();
ConnectionRuntime runtime = new ConnectionRuntime(config(dir)
.withEndpoint("http://127.0.0.1:" + server.getAddress().getPort())
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
GetObjectRequest request = GetObjectRequest.builder().bucket("bucket").key("key")
.overrideConfiguration(o -> o.putHeader("x-test-preserved", "yes")).build();
assertThat(runtime.client().getObjectAsBytes(request).asUtf8String()).isEqualTo("data");
runtime.client().putObject(o -> o.bucket("bucket").key("key"), RequestBody.fromString("upload"));
assertThat(runtime.client().listObjectsV2Paginator(ListObjectsV2Request.builder().bucket("bucket").build()).stream().count()).isEqualTo(1);
assertThat(requests).hasSize(3);
for(Headers headers : requests)
{
assertThat(headers.getFirst("x-amz-security-token")).isEqualTo("test-session");
assertThat(headers.getFirst("Authorization")).contains("Credential=test-access/");
}
assertThat(requests.getFirst().getFirst("x-test-preserved")).isEqualTo("yes");
assertThat(request.overrideConfiguration().orElseThrow().credentialsProvider()).isEmpty();
}
finally
{
runtime.close();
server.stop(0);
}
}
/*******************************************************************************
** Presigning keeps its separate public endpoint/key and session-token support.
*******************************************************************************/
@Test
void testPresignerRemainsLocal(@TempDir Path dir) throws Exception
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir)
.withPresignEndpoint("https://public.example.test")
.withPresignAccessKeyRef(reference(dir, "presign-access", "presign-access"))
.withPresignSecretKeyRef(reference(dir, "presign-secret", "presign-secret"))
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
assertThat(runtime.presigner()).isSameAs(runtime.presigner());
String url = runtime.presigner().presignGetObject(p -> p.signatureDuration(Duration.ofMinutes(5))
.getObjectRequest(g -> g.bucket("bucket").key("key"))).url().toString();
assertThat(url).startsWith("https://public.example.test/bucket/key?")
.contains("X-Amz-Credential=presign-access%2F", "X-Amz-Security-Token=test-session");
assertThat(runtime.presignCredentials()).containsExactly("presign-access", "presign-secret", "us-east-1", "https://public.example.test");
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Core handles invalid CA bundles and the explicit TLS-verification opt-out.
*******************************************************************************/
@Test
void testTlsSettingsReachCore(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = config(dir).withCaBundleRef(reference(dir, "ca", "invalid-pem"));
ConnectionRuntime runtime = new ConnectionRuntime(config);
assertThatThrownBy(runtime::client).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("caCertificatesPem");
config.withTlsVerify(false);
try
{
assertThat(runtime.client()).isNotNull();
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Deterministic test connection without any external credentials or service.
*******************************************************************************/
private static StorageConnectionConfig config(Path dir) throws Exception
{
return (new StorageConnectionConfig("test").withEndpoint("http://localhost:9000")
.withAccessKeyRef(reference(dir, "access", "test-access"))
.withSecretKeyRef(reference(dir, "secret", "test-secret")));
}
/*******************************************************************************
** Mounted-file reference for test data, scoped to JUnit's temporary directory.
*******************************************************************************/
private static String reference(Path dir, String name, String value) throws Exception
{
Path file = dir.resolve(name);
Files.writeString(file, value);
return ("${file." + file + "}");
}
}
@@ -0,0 +1,72 @@
/*
* 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.objectstorage.internal;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Db.asSystem runs work as the core system user. Token-based authentication
** modules (OAuth2, Auth0) accept a QSystemUserSession without a token and
** reject any other tokenless session, so the class matters, not just the
** permissions.
*******************************************************************************/
class DbSystemSessionTest
{
/*******************************************************************************
** Reset the runtime and context after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** The work sees a QSystemUserSession with every all-access key, and the
** caller's session is restored afterwards.
*******************************************************************************/
@Test
void testAsSystemUsesCoreSystemUserSession() throws Exception
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false);
StorageTestSupport.setUp(config, instance -> instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant").withAllAccessKeyName("tenantAllAccess")));
QSession caller = QContext.getQSession();
QSession inside = Db.asSystem(QContext::getQSession);
assertThat(inside).isInstanceOf(QSystemUserSession.class);
assertThat(inside.hasPermission("anything")).isTrue();
assertThat(inside.hasSecurityKeyValue("tenantAllAccess", true)).isTrue();
assertThat(QContext.getQSession()).isSameAs(caller);
}
}