Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 fe91118908 fix(enterprise): guard column statistics by field access, drop the owner table
8979964 gave the columnStats process a hidden, unrestricted owner table
so its table-less result records would not be withheld. That also
bypassed the field-access filter on those results: a caller could read
min, max and the most common values of a field whose access tier they do
not hold in full. The owner table is gone.

The process now runs GuardedColumnStatsStep instead:
- before anything is computed, it refuses (QPermissionDeniedException) a
  field the caller cannot read in full (REVEALABLE, MASKED or HIDDEN),
  including joined fields, which are checked on their own table
- its result records are tagged with the analysed table, so the UI API
  applies that table's field-access policy to them; table-less process
  records are still withheld as before

GuardedColumnStatsStepTest covers a field behind a field security lock:
refused without the key, allowed with it, and an unrestricted field
allowed. The process shape itself belongs in qqq core (QRun-IO/qqq#1367).

Refs QRun-IO/qqq#1367

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:50:58 -06:00
james.maesandClaude Fable 5.1 897996401a fix(enterprise): let column statistics reach the caller
With QUERY_STATS on and the columnStats process registered, the
statistics still came back empty: the process's result records belong to
no table, and the UI API withholds such process records once any field in
the instance has a restricted access tier (qbit-secured-data, the
sample's secret fields). The process now has a hidden, unrestricted owner
table, so `qqq table stats person firstName` returns counts, min, max and
the distribution.

Refs QRun-IO/qqq#1367

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:37:15 -06:00
james.maesandClaude Fable 5.1 3233a885f7 feat(enterprise): back collaboration attachments with object storage on MinIO
Uploading an attachment failed with "The Object Storage qbit is not
installed": qbit-collaboration keeps attachments in object storage and
the enterprise profile had none. When S3_ENDPOINT is set, the profile now
installs qbit-object-storage with one MinIO connection and the private
"collaboration" bucket alias that collaboration expects, plus its Storage
admin app. Credentials are ${env.S3_*} references only (the qbit's default
allowed patterns); qqq-cli's scripts/enterprise-server.sh starts the MinIO
container, creates the bucket and generates the credentials outside git.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:33:06 -06:00
james.maesandClaude Fable 5.1 468c7c639c feat(enterprise): offer column statistics and audits on the data tables
QQQ turns QUERY_STATS off on every backend by default, so no table of the
enterprise profile offered column statistics: `qqq table stats person
firstName` answered "Person does not offer column statistics (no
QUERY_STATS capability)" and the web UI hid its column statistics.

The enterprise profile now enables QUERY_STATS on the backends whose
module can aggregate (RDBMS: H2, SQLite and the merged sample app's
in-memory H2; and the memory backend). Filesystem and API backends cannot
aggregate and stay without it. The columnStats process that serves the
statistics over the UI API is registered too (the instance had none).
TABLE_COUNT and TABLE_EXPORT were already
on. Field-level audits now also cover orderLine and the sample's person,
pet, petNote and carrier tables, besides customer, order and address.

The qqq sample app's own backend has the same default (its person table on
the 8077 sample server lacks QUERY_STATS too); that belongs in qqq's
sample project, not here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:31:11 -06:00
4 changed files with 396 additions and 0 deletions

No files matched your search

@@ -48,6 +48,13 @@ import com.kingsrook.qbits.help.HelpQBitConfig;
import com.kingsrook.qbits.help.HelpQBitProducer;
import com.kingsrook.qbits.jobs.JobsQBitConfig;
import com.kingsrook.qbits.jobs.JobsQBitProducer;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer;
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
import com.kingsrook.qbits.qacapture.QaCaptureQBitProducer;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
@@ -76,7 +83,16 @@ import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
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.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;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
@@ -246,6 +262,24 @@ final class EnterpriseFeatures
/////////////////////////
// qbits //
/////////////////////////
//////////////////////////////////////////////////////////////////////////
// object storage on a local MinIO (enterprise-server.sh starts it): //
// collaboration attachments live in its private "collaboration" //
// bucket. Credentials are references to the server's env only. //
//////////////////////////////////////////////////////////////////////////
String s3Endpoint = System.getenv("S3_ENDPOINT");
if(s3Endpoint != null && !s3Endpoint.isBlank())
{
features.put("objectStorage", i -> new ObjectStorageQBitProducer().withQBitConfig(new ObjectStorageQBitConfig()
.withBackendName(backendName).withSchedulerName(schedulerName).withAdminApp(true)
.withConnection(new StorageConnectionConfig("local").withProviderType(ProviderType.MINIO)
.withEndpoint("${env.S3_ENDPOINT}").withRegion("us-east-1").withAddressingStyle(AddressingStyle.PATH)
.withAccessKeyRef("${env.S3_ACCESS_KEY}").withSecretKeyRef("${env.S3_SECRET_KEY}"))
.withBucket(new StorageBucketConfig("collaboration").withConnection("local")
.withBucketName("${env.S3_ATTACHMENTS_BUCKET}").withRole(BucketRole.PRIVATE).withIndexed(false)))
.produce(i).addSelfToInstance(i));
}
features.put("jobs", i -> new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
.withSchedulerName(schedulerName)).produce(i).addSelfToInstance(i));
features.put("collaboration", i -> new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig()
@@ -376,11 +410,81 @@ final class EnterpriseFeatures
}
}
enableColumnStatistics(instance);
auditDataTables(instance);
addNavigationApps(instance);
}
/*******************************************************************************
** QQQ turns QUERY_STATS (column statistics: counts, distinct values, min/max
** and averages per field) off on every backend by default. Turn it on for the
** backends whose module can aggregate, RDBMS (H2, SQLite, the sample's
** in-memory H2) and the memory backend, so their tables offer statistics as
** 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).
*******************************************************************************/
static void enableColumnStatistics(QInstance instance)
{
////////////////////////////////////////////////////////////////////
// the UI API serves column statistics through this process //
////////////////////////////////////////////////////////////////////
if(instance.getProcess(ColumnStatsStep.getProcessMetaData().getName()) == null)
{
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()))
{
backend.withCapability(Capability.QUERY_STATS);
}
}
}
/*******************************************************************************
** Field-level audits on the demo and sample data tables (customer, order and
** address get them with the audits feature), when audits are installed.
*******************************************************************************/
static void auditDataTables(QInstance instance)
{
if(instance.getTable("audit") == null)
{
return;
}
for(String tableName : List.of("customer", "order", "orderLine", "address", "person", "pet", "petNote", "carrier"))
{
QTableMetaData table = instance.getTable(tableName);
if(table != null && table.getAuditRules() == null)
{
table.setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
}
}
}
/*******************************************************************************
** The core profile has no apps and several qbits only add tables. Put the
** demo tables in a "QQQ All" app and every visible table that no app reaches
@@ -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);
}
}
}
}
}
@@ -0,0 +1,81 @@
/*
* 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 com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The enterprise profile offers column statistics wherever the backend can aggregate. */
class EnterpriseColumnStatsTest
{
@TempDir Path dataDirectory;
@Test
void queryStatsOnAggregatingBackendsOnly() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
{
assertFalse(hasQueryStats(instance, tableName), tableName + " before");
}
EnterpriseFeatures.enableColumnStatistics(instance);
assertNotNull(instance.getProcess("columnStats"));
///////////////////////////////////////////////////////////////////
// H2 (customer, order), SQLite (orderLine) and memory (demoNote) //
///////////////////////////////////////////////////////////////////
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
{
assertTrue(hasQueryStats(instance, tableName), tableName);
assertTrue(hasCapability(instance, tableName, Capability.TABLE_COUNT), tableName);
assertTrue(hasCapability(instance, tableName, Capability.TABLE_EXPORT), tableName);
}
////////////////////////////////////////////////////////////////
// the filesystem (product) and API (apiCatalog) backends do //
// not aggregate, so they stay without statistics //
////////////////////////////////////////////////////////////////
assertFalse(hasQueryStats(instance, "product"));
assertFalse(hasQueryStats(instance, "apiCatalog"));
}
@Test
void dataTablesAreAuditedWhenAuditsAreInstalled() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
new AuditsMetaDataProvider().defineAll(instance, "h2", null);
EnterpriseFeatures.auditDataTables(instance);
for(String tableName : new String[] {"customer", "order", "orderLine", "address"})
{
assertEquals(AuditLevel.FIELD, instance.getTable(tableName).getAuditRules().getAuditLevel(), tableName);
}
}
private static boolean hasQueryStats(QInstance instance, String tableName)
{
return hasCapability(instance, tableName, Capability.QUERY_STATS);
}
private static boolean hasCapability(QInstance instance, String tableName, Capability capability)
{
QTableMetaData table = instance.getTable(tableName);
QBackendMetaData backend = instance.getBackendForTable(tableName);
return table.isCapabilityEnabled(backend, capability);
}
}
@@ -0,0 +1,113 @@
/*
* 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.math.BigDecimal;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
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.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Column statistics are refused for a field the caller cannot read in full. */
class GuardedColumnStatsStepTest
{
private static final String KEY = "salaryViewer";
private QInstance instance;
@BeforeEach
void setUp() throws QException
{
MemoryRecordStore.fullReset();
instance = new QInstance();
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.addSecurityKeyType(new QSecurityKeyType().withName(KEY));
instance.addTable(new QTableMetaData().withName("employee").withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("name", QFieldType.STRING))
.withField(new QFieldMetaData("salary", QFieldType.DECIMAL).withLabel("Salary")
.withFieldSecurityLock(new FieldSecurityLock().withSecurityKeyType(KEY)
.withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withOverrideValues(List.of(true)))));
EnterpriseFeatures.enableColumnStatistics(instance);
QContext.init(instance, new QSystemUserSession());
new InsertAction().execute(new InsertInput("employee").withRecords(List.of(
new QRecord().withValue("name", "Ada").withValue("salary", new BigDecimal("100")),
new QRecord().withValue("name", "Grace").withValue("salary", new BigDecimal("200")))));
}
@AfterEach
void tearDown()
{
QContext.clear();
MemoryRecordStore.fullReset();
}
@Test
void restrictedFieldIsRefusedWithoutTheTier() throws QException
{
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());
}
@Test
void restrictedFieldIsAllowedWithTheTier() throws QException
{
QContext.init(instance, new QSession().withSecurityKeyValue(KEY, true));
RunProcessOutput output = run("salary");
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
QRecord stats = (QRecord) output.getValues().get("statsRecord");
assertEquals(2, stats.getValueInteger("count"));
assertEquals("employee", stats.getTableName());
}
@Test
void unrestrictedFieldIsAllowed() throws QException
{
QContext.init(instance, new QSession());
RunProcessOutput output = run("name");
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
}
private RunProcessOutput run(String fieldName) throws QException
{
RunProcessInput input = new RunProcessInput();
input.setProcessName("columnStats");
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
input.addValue("tableName", "employee");
input.addValue("fieldName", fieldName);
return (new RunProcessAction().execute(input));
}
}