Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 1e0a3341b9 feat(enterprise): store mock sign-ins in the session store
The TUI's "Online" tile counts stored sessions, but storedSession had no
rows on the enterprise server: only the OAuth2 module writes to the
session store, and MOCK authentication builds a fresh session per request
and stores nothing, so two terminals signed in as Admin showed "1 online".

The smallest hook: the enterprise profile's mock session customizer
(DemoUsers) calls EnterpriseSessions.record. The first time the JVM sees a
session uuid it stores one row through qbit-session-store (user id, a lean
identity-only copy of the session, expiresAt; create and modify dates come
from the table), or touches the row when it already exists (after a
restart); after that it touches the row at most once a minute, so expiry
slides while a terminal is in use. qbit-session-store needs no change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:41 -06:00
14 changed files with 222 additions and 158 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /.*/
-33
View File
@@ -1,33 +0,0 @@
name: build
on:
push:
jobs:
container:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/container.yaml@v1
secrets: inherit
with:
# Same gates as the old orb build-and-test + core smoke: script unit tests,
# the full reactor (BOM tests, packaged integration tests), then the packaged
# core jar smoked over HTTP. The Dockerfile copies that verified jar.
prebuild: |
set -euo pipefail
# The smoke scripts need Python 3 (stdlib only); the job image has none and no root for apt.
mkdir -p /tmp/python
curl -fsSLo /tmp/python.tgz https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.12.15%2B20261013-x86_64-unknown-linux-gnu-install_only.tar.gz
echo "5d546fc004fc2435fa142eb8f42ed8de5eb4725963dc22f7e6671014a80eb560 /tmp/python.tgz" | sha256sum -c -
tar -xzf /tmp/python.tgz -C /tmp/python --strip-components=1 && rm /tmp/python.tgz
# The release-bundle and full-smoke config tests render compose.yaml with `docker compose config`
# (no daemon). Provide the Compose binary behind a `docker compose`-only shim.
curl -fsSLo /tmp/python/bin/docker-compose https://github.com/docker/compose/releases/download/v5.6.1/docker-compose-linux-x86_64
echo "47737f4236e6d3ccea7291995203f8f018058783b4c3a9469119811a0b5db8c0 /tmp/python/bin/docker-compose" | sha256sum -c -
printf '%s\n' '#!/bin/sh' '[ "$1" = compose ] || { echo "only docker compose is installed" >&2; exit 1; }' 'shift' 'exec docker-compose "$@"' > /tmp/python/bin/docker
chmod +x /tmp/python/bin/docker-compose /tmp/python/bin/docker
export PATH=/tmp/python/bin:$PATH
python3 -m unittest discover -s scripts -p 'test_*.py' -v
mvn verify
python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar
images: |
qqq-all|Dockerfile|.
# develop pushes git.kof22.com/qrun-io/qqq-all:ci-<sha>-run-<n> plus the moving develop tag.
publish-branch: develop
extra-tags: develop
+53
View File
@@ -0,0 +1,53 @@
name: Release 4.1.0
'on':
workflow_dispatch:
inputs:
version:
description: Reviewed source version (GA or exact coordinated RC)
required: true
type: choice
default: 4.1.0
options:
- 4.1.0
- 4.1.0-RC.1
release_sha:
description: Full develop commit SHA already signed and tagged with the selected
version
required: true
type: string
smoke_run_id:
description: Successful Smoke workflow run ID for that exact develop SHA
required: false
type: string
default: ''
smoke_evidence:
description: Evidence provider; github preserves the original Smoke workflow
validation
type: choice
default: circle
options:
- github
- circle
required: true
circle_workflow_id:
description: Exact successful Circle workflow UUID when circle evidence is
selected
type: string
required: false
default: ''
permissions:
actions: read
contents: write
packages: write
checks: read
jobs:
release:
uses: QRun-IO/qqq-orb/.github/workflows/qqq-all-release.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version }}
release_sha: ${{ inputs.release_sha }}
smoke_run_id: ${{ inputs.smoke_run_id }}
smoke_evidence: ${{ inputs.smoke_evidence }}
circle_workflow_id: ${{ inputs.circle_workflow_id }}
secrets:
CIRCLECI_TOKEN: ${{ secrets.CIRCLECI_TOKEN }}
+6
View File
@@ -0,0 +1,6 @@
profile: all
orb_version: '0.8'
default_branch: develop
publish:
snapshot: true
publish_context: qqq-maven-registry-credentials
-1
View File
@@ -1,7 +1,6 @@
FROM eclipse-temurin:21-jre-jammy
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 qqq \
+2 -2
View File
@@ -41,9 +41,9 @@ Full adds sample tables on PostgreSQL, MySQL, MongoDB, MinIO S3, and SFTP. It in
To stop the full stack, run `docker compose --profile full down`. Add `--volumes` only when you intend to erase demo data. For a fresh Artemis volume, the configured login is required; old volumes created for anonymous access should be recreated. This reference stack uses demo credentials and is not a production deployment.
CI runs [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) through the shared `KofTwentyTwo/ci-workflows` container workflow: script unit tests, `mvn verify`, and the packaged core HTTP smoke check on every push, then the image build and Trivy scan. Pushes to `develop` publish `git.kof22.com/qrun-io/qqq-all` (`ci-<sha>-run-<n>` and `develop`). The full Compose smoke check runs locally with `python3 scripts/run_full_smoke.py`.
The [canonical QQQ pipeline](.circleci/config.yml), configured by [.qqq-ci.yml](.qqq-ci.yml), builds once and runs core/full smoke checks through the shared orb. Its develop-image job publishes to GHCR only after the container package exists and its visibility is public. Until then, image publication is skipped and local Compose remains available.
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path. The GitHub release workflow that called the retired qqq-orb was removed; a versioned release path on Gitea is not defined yet.
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path and the manually gated, exact-commit [release workflow](.github/workflows/release.yml). The workflow will not release the current snapshot build.
## Contributing and license
+1 -1
View File
@@ -1,6 +1,6 @@
# 4.1.0 release preparation
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The GitHub manual release workflow (GHCR image plus Compose bundle, via the retired qqq-orb) was removed with the move to Gitea CI; `develop` images now publish to `git.kof22.com/qrun-io/qqq-all`. A versioned release path on Gitea is not defined yet.
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The [manual release workflow](../.github/workflows/release.yml) publishes the versioned GHCR image and a GitHub release containing an image-based Compose bundle plus `compose.yaml`. It does **not** deploy Maven artifacts or create a tag. Publication is authorized once the release gates pass. Keep the workflow disabled until Apache-2.0 alignment, public artifact verification and GHCR visibility are complete.
## Exact coordinated RC under QQQ #921
@@ -51,8 +51,6 @@ import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataPro
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTableBackendDetails;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/** Registers the locally usable qbits against the demo's relational backend. */
@@ -175,12 +173,6 @@ final class DemoQBits
new SavedBulkLoadProfileMetaDataProvider().defineAll(instance, "postgres", null);
new SavedReportsMetaDataProvider().defineAll(instance, "postgres", "sftp", null);
///////////////////////////////////////////////////////////////////////////
// rendered reports are files, one per record, on the SFTP server: core //
// now refuses a filesystem table without backend details (qqq#1364) //
///////////////////////////////////////////////////////////////////////////
instance.getTable("reportStorage").setBackendDetails(new SFTPTableBackendDetails().withBasePath("reportStorage")
.withCardinality(Cardinality.ONE).withFileNameFieldName("reference").withContentsFieldName("contents"));
new SFTPDataIntegrationQBitProducer().withSftpDataIntegrationQBitConfig(new SFTPDataIntegrationQBitConfig()
.withSourceFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
.withDestinationFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
@@ -88,7 +88,27 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
}
if(EnterpriseFeatures.isEnabled())
{
grantEnterpriseAccess(instance, session, userId);
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
}
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
if(EnterpriseFeatures.isEnabled())
@@ -96,40 +116,4 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
EnterpriseSessions.record(session);
}
}
/**
* The enterprise test server: every session is an all-access admin, so the
* terminal clients can reach every qbit surface.
*/
static void grantEnterpriseAccess(QInstance instance, QSession session, String userId)
{
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
//////////////////////////////////////////////////////////////////////
// qbit-collaboration's custom checkers (Custom Fields, tags, the //
// admin tables) test these names; without them the Custom Fields //
// table answered 403 "This needs collaboration.view." (#47) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("collaboration.view", "collaboration.admin", "collaboration.fields.manage", "collaboration.tags.manage");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
}
}
@@ -88,6 +88,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
@@ -427,8 +431,8 @@ final class EnterpriseFeatures
** the web UI and the terminal clients expect; filesystem and API backends
** cannot aggregate and stay without it. Tables of those backends that list
** QUERY_STATS as disabled keep it off. The columnStats process that serves
** the statistics is added when the instance lacks it; core guards it by field
** access (QRun-IO/qqq#1367: only fully readable fields, results tagged).
** the statistics is added when the instance lacks it, and runs through
** GuardedColumnStatsStep (only fully readable fields).
*******************************************************************************/
static void enableColumnStatistics(QInstance instance)
{
@@ -440,6 +444,20 @@ final class EnterpriseFeatures
instance.addProcess(ColumnStatsStep.getProcessMetaData());
}
////////////////////////////////////////////////////////////////////////
// run it through the guarded step: fields the caller cannot read in //
// full are refused, and the results carry the analysed table so its //
// field-access policy applies (QRun-IO/qqq#1367) //
////////////////////////////////////////////////////////////////////////
QProcessMetaData process = instance.getProcess(ColumnStatsStep.getProcessMetaData().getName());
for(QStepMetaData step : process.getStepList())
{
if(step instanceof QBackendStepMetaData backendStep && backendStep.getCode() != null
&& ColumnStatsStep.class.getName().equals(backendStep.getCode().getName()))
{
backendStep.setCode(new QCodeReference(GuardedColumnStatsStep.class));
}
}
for(QBackendMetaData backend : instance.getBackends().values())
{
if(backend instanceof RDBMSBackendMetaData || new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
@@ -0,0 +1,98 @@
/*
* Copyright 2026 Kingsrook, LLC
* 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
*/
package com.kingsrook.qqq.all;
import java.io.Serializable;
import java.util.Collection;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
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.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
/**
* Column statistics only for fields the caller may read in full.
*
* Statistics expose values (min, max, the most common values), so a field
* whose access tier for the caller is REVEALABLE, MASKED or HIDDEN is refused
* with a permission error before anything is computed. The result records are
* then tagged with the analysed table, so the UI API applies that table's
* field-access policy to them instead of withholding table-less records.
* The process shape belongs in qqq core (QRun-IO/qqq#1367); this is the host's
* safe check until then.
*/
public class GuardedColumnStatsStep extends ColumnStatsStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String tableName = input.getValueString("tableName");
String fieldName = input.getValueString("fieldName");
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName);
assertFullAccess(table, fieldName);
super.run(input, output);
if(table != null)
{
for(Serializable value : output.getValues().values())
{
tag(table.getName(), value);
}
}
}
/**
* Refuse statistics for a field the caller cannot read in full; a joined
* field ("otherTable.field") is checked on its own table.
*/
static void assertFullAccess(QTableMetaData table, String fieldName) throws QPermissionDeniedException
{
if(table == null || fieldName == null)
{
return;
}
QTableMetaData fieldTable = table;
String name = fieldName;
int dot = fieldName.indexOf('.');
if(dot > 0 && QContext.getQInstance().getTable(fieldName.substring(0, dot)) != null)
{
fieldTable = QContext.getQInstance().getTable(fieldName.substring(0, dot));
name = fieldName.substring(dot + 1);
}
FieldAccessTier tier = FieldAccessHelper.getTier(fieldTable, name);
if(tier != FieldAccessTier.FULL)
{
String label = fieldTable.getField(name) == null ? name : fieldTable.getField(name).getLabel();
throw new QPermissionDeniedException("Column statistics are not available for " + label
+ ": you do not have full access to this field.");
}
}
private static void tag(String tableName, Serializable value)
{
if(value instanceof QRecord record && record.getTableName() == null)
{
record.setTableName(tableName);
}
else if(value instanceof Collection<?> collection)
{
for(Object element : collection)
{
if(element instanceof QRecord record && record.getTableName() == null)
{
record.setTableName(tableName);
}
}
}
}
}
@@ -1,63 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
* 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
*/
package com.kingsrook.qqq.all;
import java.nio.file.Path;
import java.util.Map;
import com.kingsrook.qbits.collaboration.customfields.FieldSecurity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The enterprise admin passes qbit-collaboration's Custom Fields checker (qqq-all#47). */
class EnterpriseAccessTest
{
@TempDir Path dataDirectory;
@AfterEach
void tearDown()
{
QContext.clear();
}
@Test
void adminCanReadAndManageCustomFields() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
QSession plain = new QSession();
QContext.init(instance, plain);
assertThrows(QPermissionDeniedException.class,
() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
QSession admin = new QSession();
DemoUsers.grantEnterpriseAccess(instance, admin, "core:admin");
QContext.init(instance, admin);
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new InsertInput("collaboration_field_definition"), null));
}
@Test
void customizedSessionsStillCarryTheUserForTheFrontend() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
QSession session = new QSession();
session.setUuid(DemoUsers.ADMIN_SESSION);
session.setUser(new QUser());
new DemoUsers().customizeSession(instance, session, Map.of());
assertEquals("Admin", ((Map<?, ?>) session.getValuesForFrontend().get("user")).get("name"));
}
}
@@ -38,12 +38,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The enterprise profile's columnStats process (core's step, QRun-IO/qqq#1367)
* refuses statistics for a field the caller cannot read in full and tags its
* results with the analysed table.
*/
class EnterpriseColumnStatsAccessTest
/** Column statistics are refused for a field the caller cannot read in full. */
class GuardedColumnStatsStepTest
{
private static final String KEY = "salaryViewer";
@@ -83,7 +79,7 @@ class EnterpriseColumnStatsAccessTest
{
QContext.init(instance, new QSession());
QPermissionDeniedException denied = assertThrows(QPermissionDeniedException.class, () -> run("salary"));
assertTrue(denied.getMessage() != null && !denied.getMessage().isBlank());
assertTrue(denied.getMessage().contains("Column statistics are not available for Salary"), denied.getMessage());
}
@Test
+4 -4
View File
@@ -148,11 +148,11 @@ class SmokeTest(unittest.TestCase):
pom = ET.parse(ROOT / "qqq-all-app" / "pom.xml")
final_name = pom.findtext(".//{http://maven.apache.org/POM/4.0.0}build/"
"{http://maven.apache.org/POM/4.0.0}finalName")
# CI smokes the exact jar Maven builds; keep the build output and the
# CI smoke command on the same artifact path.
# The shared all profile owns the smoke command; this repository owns
# the Maven artifact it consumes. Keep that cross-repository contract.
self.assertEqual("qqq-all-app", final_name)
workflow = (ROOT / ".gitea" / "workflows" / "build.yaml").read_text()
self.assertIn("python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar", workflow)
manifest = (ROOT / ".qqq-ci.yml").read_text()
self.assertRegex(manifest, r"(?m)^profile: all$")
def test_standalone_full_smoke_requires_explicit_search_endpoint(self):