Compare commits

..
17 Commits
Author SHA1 Message Date
CircleCI ab1918b3cc Bump version to 0.1.0-RC.9 [skip ci] 2026-10-16 16:04:32 +00:00
github-actions[bot] 13ac85cc14 Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 15:57:47 +00:00
CircleCI 0abedf482b Bump version to 0.1.0-RC.8 [skip ci] 2026-10-15 22:22:19 +00:00
james.maesandClaude Opus 5.5 2f94e04fec Merge develop into release/0.1 for 0.1.0-RC.8 (#27 cluster caches, #28 QQQ RC.15 S3 core)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:13:02 -05:00
CircleCI dec9d063a6 Bump version to 0.1.0-RC.7 [skip ci] 2026-10-15 02:45:16 +00:00
james.maesandClaude Opus 5.5 89e0e5915b Merge develop into release/0.1 for 0.1.0-RC.7 (#24 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:47 -05:00
CircleCI cf2e9fca77 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-14 23:07:52 +00:00
james.maesandClaude Opus 5.5 cd26342cfd Merge develop into release/0.1 for 0.1.0-RC.6 (#18 ConfigSync session fix, #19, #20, #23)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:58:53 -05:00
CircleCI 93c116ff01 Bump version to 0.1.0-RC.5 [skip ci] 2026-10-14 22:34:39 +00:00
james.maesandClaude Opus 5.5 9f2d7fc202 Merge develop into release/0.1 for 0.1.0-RC.5 (#21 setup card, seeds, help)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:25:24 -05:00
CircleCI 5c8f2f595b Bump version to 0.1.0-RC.4 [skip ci] 2026-10-14 12:37:00 +00:00
james.maesandClaude Opus 5.5 1ea40241f7 Merge develop into release/0.1 for 0.1.0-RC.4 (#13 maxUploadBytes, #14 QQQ RC.6 and guardrails)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:28:10 -05:00
CircleCI 8692857891 Bump version to 0.1.0-RC.3 [skip ci] 2026-10-14 01:02:56 +00:00
james.maesandClaude Opus 5.5 b6db9aff8b Merge develop into release/0.1 for 0.1.0-RC.3 (ConnectionRuntime/S3ClientFactory consolidation, #11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:55:34 -05:00
CircleCI 07275bce3f Bump version to 0.1.0-RC.2 [skip ci] 2026-10-13 15:51:56 +00:00
james.maesandClaude Opus 5.5 9eefc0b071 Merge develop into release/0.1 for 0.1.0-RC.2
AWS SDK for Java v2 2.41.10 -> 2.41.34 (GHSA-443w-3rq3-5m5h, #8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:44:08 -05:00
CircleCI f1fb2462f9 Bump version to 0.1.0-RC.1 [skip ci] 2026-10-12 23:02:00 +00:00
14 changed files with 82 additions and 546 deletions

No files matched your search

+57 -11
View File
@@ -1,14 +1,60 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.6.9
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
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
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+$/]
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-23
View File
@@ -1,23 +0,0 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+14 -62
View File
@@ -1,74 +1,26 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
'on':
on:
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
description: Print the plan without merging or creating branches
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
types: [closed]
branches: [develop]
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
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
secrets: inherit
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
-16
View File
@@ -1,16 +0,0 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
-4
View File
@@ -26,10 +26,6 @@ All notable changes to this project are documented here. The format follows Keep
- Foreign keys on every relation between the qbit tables (`fk_<table>_<column>`), generated by `ObjectStorageSchema`: owned observed state (capabilities, folders, objects, versions, usage) cascades from its bucket or object, `object.folder_id` and `object_reference.object_id` are set null, and a bucket's connection, header and protection rules and operation log restrict. Each key is added by an idempotent block that first records rows whose parent is gone in `qqq_fk_quarantine` and clears (nullable column) or deletes (required column) them, so it also upgrades an existing schema. New indexes `ix_object_storage_bucket_1` (connection_id), `ix_object_storage_header_rule_1` and `ix_object_storage_protection_rule_1` (bucket_id) back the keys. Upgrade: run the generated statements again (or the host's copy of them).
- Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL.
### Fixed
- The service's own writes are no longer refused in a host that enforces permissions inside the core actions (`QInstance.enforcePermissionsInCoreActions`). `Db.asSystem` starts a new context, so each of its writes is an outermost action there and core asks `ObjectStoragePermissionChecker`, which refuses every write of a table nobody may write directly, to every session. The `object_storage_operation` row of every stored object was lost (logged as "Could not write a storage operation log row", the object itself was stored), and so was each other system write of those tables. `Db.insert`, `Db.update` and `Db.delete` now mark their input with core's per-call permission bypass (`withPermissionBypass`, reason `Db.OWN_WRITE_REASON`), so core does not ask about the service's own writes. The checker is unchanged in what it refuses: a write of those tables that does not carry a bypass is refused to a person, to `object_storage.admin` and to the core system user alike, so server code running as the system user cannot edit or delete operation log rows through an enforced action.
- An operation log row that could not be written is no longer only a warning. It is logged at ERROR, raises the `storage.operation_log.failed` alert (`StorageAlertSink`, severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and is counted in `OperationLog.failedWriteCount()` (per node, since start). A row the insert action hands back with a record error (a refused value, a record lock) is now detected as well; before, it was dropped without any log line from the qbit. The operation the row describes still succeeds.
### Removed
- The QRun-IO/qqq#1165 stand-ins: `SessionCredentialsClient` (session tokens now go through core `S3BackendMetaData.sessionToken` / `S3ClientFactory`, including for the presigner) and the file reading in `SecretRefs` (core `QMetaDataVariableInterpreter` resolves `${file.PATH}`). **Behavior change:** core reads `${file.PATH}` only inside its allowed roots, which are `/var/run/secrets` and `/run/secrets` by default. A host that mounts storage secrets elsewhere must set `qqq.fileReference.roots` or `QQQ_FILE_REFERENCE_ROOTS`. The qbit's default allow-list (`file./var/run/secrets/storage/*`) is already inside the default roots.
- None for the health migration; both Test connection record actions, including on-demand write tests, are retained.
-1
View File
@@ -125,7 +125,6 @@ It is insert-only and inserts a **disabled** generic header rule, `default-mutab
- **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.
- **A missing audit row.** A storage operation succeeds even when its `object_storage_operation` row cannot be written, and that row is then the one condition no table shows. It is logged at ERROR ("Could not write a storage operation log row"), raised as the `storage.operation_log.failed` alert (severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and counted in `OperationLog.failedWriteCount()`, per node since it started. A host without an alert sink should export that count (a gauge, or a health check of its own) and alert when it is above zero.
## Conformance
+3 -3
View File
@@ -20,7 +20,7 @@
<properties>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.2.0-SNAPSHOT</revision>
<revision>0.1.0-RC.9</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -29,7 +29,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -290,7 +290,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -48,21 +48,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
** Small helpers for reading and writing the qbit's own tables as the system
** user (the service writes its audit log and index regardless of the calling
** user's per-bucket scope; the calling user's permissions were checked first).
**
** Every write made here carries core's per-call permission bypass
** (AbstractActionInput.withPermissionBypass). That mark, not the session, is
** what lets the service write tables nobody may write directly: a host that
** enforces permissions inside the core actions asks ObjectStoragePermissionChecker
** about every outermost write that does not carry a bypass, and the checker
** refuses those tables to every session, the system user included.
*******************************************************************************/
public final class Db
{
/** The reason recorded on (and logged by core for) each write the service makes of its own tables. */
public static final String OWN_WRITE_REASON = "The object storage service writes its own tables (operation log, index, capabilities, usage); the caller's storage permission was checked before the storage call.";
/*******************************************************************************
** Something that may throw.
*******************************************************************************/
@@ -249,9 +237,7 @@ public final class Db
*******************************************************************************/
public static List<QRecord> insert(String table, List<QRecord> records) throws QException
{
InsertInput input = new InsertInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new InsertAction().execute(input).getRecords());
return (new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords());
}
@@ -273,9 +259,7 @@ public final class Db
*******************************************************************************/
public static List<QRecord> update(String table, List<QRecord> records) throws QException
{
UpdateInput input = new UpdateInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new UpdateAction().execute(input).getRecords());
return (new UpdateAction().execute(new UpdateInput(table).withRecords(records)).getRecords());
}
@@ -287,9 +271,7 @@ public final class Db
{
if(!ids.isEmpty())
{
DeleteInput input = new DeleteInput(table).withPrimaryKeys(ids);
input.withPermissionBypass(OWN_WRITE_REASON);
new DeleteAction().execute(input);
new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(ids));
}
}
@@ -99,7 +99,6 @@ public final class ObjectStorageRuntime
clock = Clock.systemUTC();
indexHook = null;
CapabilityStore.clearCache();
OperationLog.reset();
}
@@ -21,17 +21,10 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -40,22 +33,12 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** every caller (UI action, app code, scheduled job) lands here. Presigned URLs
** are logged without the signature (key, method, expiry and issuer only),
** because the URL itself is a bearer token. A logging failure never fails the
** operation it describes, so it is reported three ways instead: an ERROR log
** line, the storage.operation_log.failed alert (StorageAlertSink, resolved by
** the next row written for that bucket), and failedWriteCount().
** operation it describes.
*******************************************************************************/
public final class OperationLog
{
private static final QLogger LOG = QLogger.getLogger(OperationLog.class);
/** The alert raised (severity error, dedupe key "bucket:" + alias) when a row could not be written. */
public static final String ALERT_TYPE = "storage.operation_log.failed";
private static final AtomicLong FAILED_WRITES = new AtomicLong();
private static final Set<String> UNRESOLVED = ClusterCaches.nodeLocal(ConcurrentHashMap.newKeySet(),
"the buckets whose operation log alert this node raised and has not resolved (alert state, not cached data)");
/*******************************************************************************
@@ -200,31 +183,6 @@ public final class OperationLog
/*******************************************************************************
** How many operation log rows this node could not write since it started (or
** since the runtime was reset). The operations themselves were carried out, so
** anything above zero means storage changes that the audit log does not show.
** A host that has no alert sink reads this (a metrics gauge, a health check of
** its own) to alert on a missing audit row.
*******************************************************************************/
public static long failedWriteCount()
{
return (FAILED_WRITES.get());
}
/*******************************************************************************
** Forget the failures counted and the alerts raised (ObjectStorageRuntime.reset).
*******************************************************************************/
static void reset()
{
FAILED_WRITES.set(0);
UNRESOLVED.clear();
}
/*******************************************************************************
** Insert the row (as the system user), never throwing.
*******************************************************************************/
@@ -238,7 +196,7 @@ public final class OperationLog
try
{
Integer bucketId = ConfigSync.bucketId(e.bucketAlias);
QRecord written = Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
.withValue("occurredAt", ObjectStorageRuntime.clock().instant())
.withValue("actor", e.caller.describe())
.withValue("action", e.action)
@@ -252,66 +210,10 @@ public final class OperationLog
.withValue("providerRequestId", e.requestId)
.withValue("processRunId", e.processRunId)
.withValue("detail", e.detail)));
////////////////////////////////////////////////////////////////////////////////
// the insert action does not throw for a record it refused (a missing value, //
// a value too long, a record lock): it hands the record back with its errors //
////////////////////////////////////////////////////////////////////////////////
if(CollectionUtils.nullSafeHasContents(written.getErrors()))
{
throw (new QException("The operation log row was refused: " + written.getErrors()));
}
written(e);
}
catch(Exception ex)
{
failed(e, ex);
}
}
/*******************************************************************************
** A row was written: resolve the alert of an earlier failure for its bucket.
*******************************************************************************/
private static void written(Entry e)
{
String dedupeKey = "bucket:" + e.bucketAlias;
if(UNRESOLVED.remove(dedupeKey))
{
try
{
ObjectStorageRuntime.alertSink().resolve(ALERT_TYPE, dedupeKey);
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not resolve the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
}
}
}
/*******************************************************************************
** A row could not be written: the operation happened and the audit log does
** not show it. Say so where an operator sees it, without failing the operation.
*******************************************************************************/
private static void failed(Entry e, Exception cause)
{
long count = FAILED_WRITES.incrementAndGet();
LOG.error("Could not write a storage operation log row: the operation is missing from the audit log", cause,
logPair("action", e.action), logPair("bucket", e.bucketAlias), logPair("key", e.key), logPair("result", e.result), logPair("failedWriteCount", count));
String dedupeKey = "bucket:" + e.bucketAlias;
UNRESOLVED.add(dedupeKey);
try
{
ObjectStorageRuntime.alertSink().raise(ALERT_TYPE, dedupeKey, "error", "A storage operation on " + e.bucketAlias + " was not recorded in the operation log",
Map.of("bucket", String.valueOf(e.bucketAlias), "action", String.valueOf(e.action), "count", count));
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not raise the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
LOG.warn("Could not write a storage operation log row", ex, logPair("action", e.action), logPair("key", e.key));
}
}
}
@@ -46,9 +46,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
** - writes to the editable config tables (connection operational fields, header
** rules, protection rules, bucket thresholds) need object_storage.admin;
** observed and history tables are never written through the UI or API (they
** change only through actions and the service, whose own writes carry
** core's per-call permission bypass, see internal.Db; no session is let
** through, the core system user included);
** change only through actions and the service);
** - each process needs the permission registered for it (default admin).
*******************************************************************************/
public class ObjectStoragePermissionChecker implements CustomPermissionChecker
@@ -67,12 +65,6 @@ public class ObjectStoragePermissionChecker implements CustomPermissionChecker
String needed = required(actionInput, metaData);
if(needed == null)
{
////////////////////////////////////////////////////////////////////////////////////////
// nobody writes these tables directly, whatever the session: a person, the storage //
// administrator and the core system user are all refused. the service's own writes //
// (internal.Db) carry core's per-call permission bypass, so core does not ask about //
// them; a write that reaches this point did not come from the service //
////////////////////////////////////////////////////////////////////////////////////////
throw (new QPermissionDeniedException("This storage table changes only through storage actions."));
}
if(!has(needed))
@@ -21,50 +21,25 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
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.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;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** 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. The system session is not what lets the service write its own
** tables, though: each Db write carries core's per-call permission bypass, and
** a write that does not is refused to every session.
** permissions.
*******************************************************************************/
class DbSystemSessionTest
{
private static final String REFUSAL = "This storage table changes only through storage actions.";
/*******************************************************************************
** Reset the runtime and context after each test.
*******************************************************************************/
@@ -94,140 +69,4 @@ class DbSystemSessionTest
assertThat(inside.hasSecurityKeyValue("tenantAllAccess", true)).isTrue();
assertThat(QContext.getQSession()).isSameAs(caller);
}
/*******************************************************************************
** A host that enforces permissions inside the core actions
** (QInstance.enforcePermissionsInCoreActions) still gets its operation log
** row, and the row names the person who acted: the caller is captured before
** the write moves to the system user. The other tables the service keeps
** (here the object index) are written, changed and removed the same way.
*******************************************************************************/
@Test
void testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions() throws Exception
{
enforcingInstance();
QContext.setQSession(new QSession().withUser(new QUser().withIdReference("pat@example.com").withFullName("Pat Example")).withPermissions(ObjectStoragePermissions.WRITE));
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("stored/by/the/service").write();
assertThat(operations()).as("the operation log row of a stored object").singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("stored/by/the/service");
assertThat(row.getValueString("actor")).as("the person who acted, not the system user that wrote the row").isEqualTo("pat@example.com");
});
assertThat(OperationLog.failedWriteCount()).isZero();
Serializable id = Db.asSystem(() -> Db.insert(ObjectStorageTables.OBJECT, new QRecord().withValue("objectKey", "a/b.txt").withValue("size", 1L))).getValue("id");
Db.asSystem(() -> Db.update(ObjectStorageTables.OBJECT, List.of(new QRecord().withValue("id", id).withValue("size", 2L))));
assertThat(Db.asSystem(() -> Db.first(ObjectStorageTables.OBJECT, "id", id)).getValueLong("size")).isEqualTo(2L);
Db.asSystem(() ->
{
Db.delete(ObjectStorageTables.OBJECT, List.of(id));
return (null);
});
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OBJECT))).isEmpty();
}
/*******************************************************************************
** Being the system user is not enough to write the operation log. Server code
** outside the service's Db (a host, another qbit) that runs as the core system
** user is refused the insert, the update and the delete, also when it borrows
** Db.asSystem for its session.
*******************************************************************************/
@Test
void testSystemSessionOutsideTheServiceCannotWriteTheOperationLog() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
QContext.setQSession(new QSystemUserSession(instance));
assertRefused("insert as the system user", () -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT")))));
assertRefused("update as the system user", () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody")))));
assertRefused("delete as the system user", () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
QContext.setQSession(new QSession());
assertRefused("insert inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT"))))));
assertRefused("update inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody"))))));
assertRefused("delete inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id))));
assertUnchanged();
}
/*******************************************************************************
** An operation row is not updated or deleted by an update or delete action
** that does not carry the bypass, whoever asks: a person, the storage
** administrator or the system user, by key or by filter.
*******************************************************************************/
@Test
void testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
List<QSession> sessions = List.of(new QSession(), new QSession().withPermissions(ObjectStoragePermissions.ADMIN), new QSystemUserSession(instance));
for(QSession session : sessions)
{
QContext.setQSession(session);
String who = session.getClass().getSimpleName() + (session.hasPermission(ObjectStoragePermissions.ADMIN) ? " (admin)" : "");
assertRefused("update by " + who, () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("result", "FAILED")))));
assertRefused("delete by key by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
assertRefused("delete by filter by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withQueryFilter(new QQueryFilter(Db.eq("action", "PUT")))));
}
assertUnchanged();
}
/*******************************************************************************
** An instance that enforces permissions inside the core actions.
*******************************************************************************/
private static QInstance enforcingInstance() throws QException
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false);
return (StorageTestSupport.setUp(config, instance -> instance.setEnforcePermissionsInCoreActions(true)));
}
/*******************************************************************************
** The operation log, read by the service.
*******************************************************************************/
private static List<QRecord> operations() throws QException
{
return (Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION)));
}
/*******************************************************************************
** The qbit's checker refused the write.
*******************************************************************************/
private static void assertRefused(String what, ThrowingCallable write)
{
assertThatThrownBy(write).as(what).isInstanceOf(QPermissionDeniedException.class).hasMessage(REFUSAL);
}
/*******************************************************************************
** The one row the service wrote is still there, as it was written.
*******************************************************************************/
private static void assertUnchanged() throws QException
{
assertThat(operations()).singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("the/audited/key");
assertThat(row.getValueString("result")).isEqualTo("OK");
assertThat(row.getValueString("actor")).isEqualTo("system (no user)");
});
}
}
@@ -1,125 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.objectstorage.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageAlertSink;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.logging.CollectedLogMessage;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.apache.logging.log4j.Level;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** An operation log row that cannot be written does not fail the operation it
** describes, so an operator has to be able to see it: an ERROR log line, the
** storage.operation_log.failed alert and OperationLog.failedWriteCount().
*******************************************************************************/
class OperationLogTest
{
private final List<String> alerts = new ArrayList<>();
/*******************************************************************************
** Reset the runtime, the context and the logger after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
QLogger.deactivateCollectingLoggerForClass(OperationLog.class);
StorageTestSupport.tearDown();
}
/*******************************************************************************
** A row the insert action refuses (it hands the record back with an error, it
** does not throw) and a row whose insert throws are both counted, logged at
** ERROR and raised as an alert; the caller is not failed. The next row written
** for the bucket resolves the alert and the count stays.
*******************************************************************************/
@Test
void testFailedRowIsAnErrorAnAlertAndACount() throws Exception
{
QInstance instance = StorageTestSupport.setUp(new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false));
ObjectStorageRuntime.setAlertSink(new StorageAlertSink()
{
/*******************************************************************************
** Record the alert.
*******************************************************************************/
@Override
public void raise(String alertType, String dedupeKey, String severity, String message, Map<String, Object> details)
{
alerts.add("raise " + alertType + " " + dedupeKey + " " + severity + " count=" + details.get("count") + " action=" + details.get("action"));
}
/*******************************************************************************
** Record the resolution.
*******************************************************************************/
@Override
public void resolve(String alertType, String dedupeKey)
{
alerts.add("resolve " + alertType + " " + dedupeKey);
}
});
QCollectingLogger logger = QLogger.activateCollectingLoggerForClass(OperationLog.class);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("a").write();
assertThat(OperationLog.failedWriteCount()).isZero();
assertThat(alerts).isEmpty();
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(true);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("refused").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(1);
assertThat(alerts).containsExactly("raise storage.operation_log.failed bucket:files error count=1 action=PUT");
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(false);
String backendName = instance.getTable(ObjectStorageTables.OPERATION).getBackendName();
instance.getTable(ObjectStorageTables.OPERATION).setBackendName("no-such-backend");
new OperationLog.Entry(StorageCaller.user(), "DELETE", "files").key("thrown").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(2);
assertThat(alerts).last().isEqualTo("raise storage.operation_log.failed bucket:files error count=2 action=DELETE");
List<CollectedLogMessage> errors = logger.getCollectedMessages().stream().filter(message -> Level.ERROR.equals(message.getLevel())).toList();
assertThat(errors).hasSize(2).allSatisfy(message -> assertThat(message.getMessage()).contains("Could not write a storage operation log row"));
instance.getTable(ObjectStorageTables.OPERATION).setBackendName(backendName);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("b").write();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("c").write();
assertThat(alerts).hasSize(3).last().isEqualTo("resolve storage.operation_log.failed bucket:files");
assertThat(OperationLog.failedWriteCount()).as("the count is of this node since it started, a later row does not lower it").isEqualTo(2);
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION))).extracting(row -> row.getValueString("objectKey")).containsExactly("a", "b", "c");
}
}