Compare commits
17
Commits
No files matched your search
@@ -1,14 +0,0 @@
|
||||
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: /.*/
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -1,53 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,6 +0,0 @@
|
||||
profile: all
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
publish:
|
||||
snapshot: true
|
||||
publish_context: qqq-maven-registry-credentials
|
||||
@@ -1,6 +1,7 @@
|
||||
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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
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 [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.
|
||||
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.
|
||||
|
||||
## Contributing and license
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 4.1.0 release preparation
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Exact coordinated RC under QQQ #921
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ 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. */
|
||||
@@ -173,6 +175,12 @@ 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,28 +88,48 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
|
||||
}
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// 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);
|
||||
}
|
||||
grantEnterpriseAccess(instance, session, userId);
|
||||
}
|
||||
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
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,10 +88,6 @@ 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;
|
||||
@@ -431,8 +427,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, and runs through
|
||||
** GuardedColumnStatsStep (only fully readable fields).
|
||||
** 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).
|
||||
*******************************************************************************/
|
||||
static void enableColumnStatistics(QInstance instance)
|
||||
{
|
||||
@@ -444,20 +440,6 @@ 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,83 @@
|
||||
/*
|
||||
* 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.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
|
||||
/**
|
||||
* Records mock sign-ins in the session store (qbit-session-store) for the
|
||||
* enterprise profile.
|
||||
*
|
||||
* Only the OAuth2 module writes to the session store; MOCK authentication
|
||||
* builds a fresh session on every request and stores nothing, so the
|
||||
* storedSession table stayed empty and "who is online" could not count
|
||||
* terminals. This is the smallest hook: the mock module's session customizer
|
||||
* calls {@link #record(QSession)}, which stores one row per session uuid
|
||||
* (user id, a lean copy of the session, expiresAt) the first time this JVM
|
||||
* sees it and touches it (sliding expiry) at most once a minute after that.
|
||||
*/
|
||||
final class EnterpriseSessions
|
||||
{
|
||||
static final Duration TOUCH_INTERVAL = Duration.ofMinutes(1);
|
||||
|
||||
private static final Map<String, Long> LAST_SEEN = new ConcurrentHashMap<>();
|
||||
|
||||
private EnterpriseSessions()
|
||||
{
|
||||
}
|
||||
|
||||
static void record(QSession session)
|
||||
{
|
||||
String uuid = session == null ? null : session.getUuid();
|
||||
if(uuid == null || !QSessionStoreHelper.isSessionStoreAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = LAST_SEEN.get(uuid);
|
||||
if(last != null && now - last < TOUCH_INTERVAL.toMillis())
|
||||
{
|
||||
return;
|
||||
}
|
||||
LAST_SEEN.put(uuid, now);
|
||||
if(last == null && QSessionStoreHelper.loadSession(uuid).isEmpty())
|
||||
{
|
||||
QSessionStoreHelper.storeSession(uuid, lean(session), QSessionStoreHelper.getDefaultTtl());
|
||||
}
|
||||
else
|
||||
{
|
||||
QSessionStoreHelper.touchSession(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/** The identity only: the admin session's thousand permissions do not belong in a row. */
|
||||
static QSession lean(QSession session)
|
||||
{
|
||||
QSession lean = new QSession();
|
||||
lean.setUuid(session.getUuid());
|
||||
lean.setIdReference(session.getUuid());
|
||||
if(session.getUser() != null)
|
||||
{
|
||||
QUser user = new QUser();
|
||||
user.setIdReference(session.getUser().getIdReference());
|
||||
user.setFullName(session.getUser().getFullName());
|
||||
lean.setUser(user);
|
||||
}
|
||||
return (lean);
|
||||
}
|
||||
|
||||
/** Tests start from a clean slate. */
|
||||
static void forget()
|
||||
{
|
||||
LAST_SEEN.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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"));
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -38,8 +38,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Column statistics are refused for a field the caller cannot read in full. */
|
||||
class GuardedColumnStatsStepTest
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
private static final String KEY = "salaryViewer";
|
||||
|
||||
@@ -79,7 +83,7 @@ class GuardedColumnStatsStepTest
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
QPermissionDeniedException denied = assertThrows(QPermissionDeniedException.class, () -> run("salary"));
|
||||
assertTrue(denied.getMessage().contains("Column statistics are not available for Salary"), denied.getMessage());
|
||||
assertTrue(denied.getMessage() != null && !denied.getMessage().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/** Mock sign-ins become one stored session per uuid, touched rather than re-stored. */
|
||||
class EnterpriseSessionsTest
|
||||
{
|
||||
private final RecordingStore store = new RecordingStore();
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
EnterpriseSessions.forget();
|
||||
QSessionStoreRegistry.getInstance().clear();
|
||||
QSessionStoreRegistry.getInstance().register(store);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QSessionStoreRegistry.getInstance().clear();
|
||||
EnterpriseSessions.forget();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneRowPerSessionUuid()
|
||||
{
|
||||
EnterpriseSessions.record(session("aaaa", "core:admin"));
|
||||
EnterpriseSessions.record(session("aaaa", "core:admin"));
|
||||
EnterpriseSessions.record(session("bbbb", "core:admin"));
|
||||
|
||||
assertEquals(List.of("aaaa", "bbbb"), new ArrayList<>(store.sessions.keySet()));
|
||||
assertEquals("core:admin", store.sessions.get("bbbb").getUser().getIdReference());
|
||||
assertNull(store.sessions.get("aaaa").getPermissions(), "only the identity is stored");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownSessionIsTouchedNotStoredAgain()
|
||||
{
|
||||
store.sessions.put("cccc", session("cccc", "core:admin"));
|
||||
EnterpriseSessions.record(session("cccc", "core:admin"));
|
||||
assertEquals(0, store.stores);
|
||||
assertEquals(1, store.touches);
|
||||
}
|
||||
|
||||
private static QSession session(String uuid, String userId)
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setUuid(uuid);
|
||||
QUser user = new QUser();
|
||||
user.setIdReference(userId);
|
||||
session.setUser(user);
|
||||
session.withPermissions("customer.read", "customer.edit");
|
||||
return (session);
|
||||
}
|
||||
|
||||
/** A session store that keeps what it is given and counts calls. */
|
||||
private static class RecordingStore implements QSessionStoreProviderInterface
|
||||
{
|
||||
private final Map<String, QSession> sessions = new LinkedHashMap<>();
|
||||
private int stores;
|
||||
private int touches;
|
||||
|
||||
@Override
|
||||
public void store(String sessionUuid, QSession session, Duration ttl)
|
||||
{
|
||||
stores++;
|
||||
sessions.put(sessionUuid, session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<QSession> load(String sessionUuid)
|
||||
{
|
||||
return (Optional.ofNullable(sessions.get(sessionUuid)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String sessionUuid)
|
||||
{
|
||||
sessions.remove(sessionUuid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touch(String sessionUuid)
|
||||
{
|
||||
touches++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getDefaultTtl()
|
||||
{
|
||||
return (Duration.ofHours(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
# The shared all profile owns the smoke command; this repository owns
|
||||
# the Maven artifact it consumes. Keep that cross-repository contract.
|
||||
# CI smokes the exact jar Maven builds; keep the build output and the
|
||||
# CI smoke command on the same artifact path.
|
||||
self.assertEqual("qqq-all-app", final_name)
|
||||
manifest = (ROOT / ".qqq-ci.yml").read_text()
|
||||
self.assertRegex(manifest, r"(?m)^profile: all$")
|
||||
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)
|
||||
|
||||
|
||||
def test_standalone_full_smoke_requires_explicit_search_endpoint(self):
|
||||
|
||||
Reference in new issue
Block a user