Compare commits

..
7 Commits
Author SHA1 Message Date
james.maes fbce2e5d6e Merge pull request 'fix(targets): ask whether a table is readable as a query, not with an untyped input' (#69) from fix/target-read-asked-as-query into develop
maven / build (push) Successful in 6m55s
build / maven (push) Successful in 6m55s
2026-11-12 17:20:43 +00:00
james.maesandClaude Fable 5.2 70f6184a7a fix(targets): ask whether a table is readable as a query, not with an untyped input
maven / build (push) Successful in 8m41s
build / maven (push) Successful in 8m46s
TargetResolver.canReadTable asked PermissionsHelper.hasTablePermission with a
bare AbstractActionInput. Core hands a table's custom permission checker the
action input and not the permission asked, so a checker that tells a read
from a write by the input's type (a query, a get or a count reads; anything
else is refused) refused the question. For such a table every recents and
favorites call answered "Not found" for people the checker lets read it:
the UI sends POST /recents on every list and record screen, so each of them
showed "Resource not found POST /qqq/v1/qbit/collaboration/recents (HTTP
404)".

The read is now asked with a QueryInput for the table, the read it stands
for; ParentRecordAccess already asks with a GetInput. Who may read a table
does not change: the checker still decides, and a person it refuses is
still answered "Not found" (RecentsServiceTest
.tableWithAnInputTypedPermissionCheckerIsATarget, which failed before with
QNotFoundException on the first call).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:53:22 -06:00
james.maes 16bd4f768f Merge pull request 'perf: skip tag lookups for tables with no tag assignments (forward-port of #67)' (#68) from fix/read-path-statements-develop into develop
maven / build (push) Successful in 7m34s
build / maven (push) Successful in 7m34s
2026-11-10 22:11:02 +00:00
james.maesandClaude Fable 5.2 04d04adc6e perf(security): authorize each distinct parent once per child result page
maven / build (push) Successful in 7m15s
build / maven (push) Successful in 7m15s
ParentRowCustomizer and AttachmentPreviewCustomizer read the parent of
every child row to decide whether the row is visible, so rows that share a
parent repeated the same read: 61 tasks on two parents ran 61 parent reads
(TagLookupStatementBudgetTest), now 2. The answer is kept only for the one
postQuery call, where the session and transaction cannot change.

Also correct the README: the Tags field is selectable.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:58:11 -06:00
james.maesandClaude Fable 5.2 de922d7e86 perf(tags): no assignment lookup for tables that have no tags
Core selects the Tags virtual field for every query or get that names no
fields, so every such read of a TAGS-enabled table ran one
collaboration_record_tag statement, including reads made one record at a
time and tables that were never tagged. Measured with 600 added parents
(TagLookupStatementBudgetTest): a 602-row list ran 2 statements, 600 gets
ran 600 and 100 single-record updates ran 100; now 1 probe, then 0.

TaggedTables remembers, per node and per table, whether any assignment
exists (core Memoization registered with ClusterCaches, 30 s by default,
withTagLookupCacheSeconds, 0 disables). Writing an assignment clears it at
once and after commit and publishes memo-invalidate. The probe ignores row
locks and returns no data, so the answer is the same for every session.
Tables with assignments are read as before: one lookup per 500 parents.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:58:11 -06:00
james.maes c6b077321c Merge pull request 'fix(upload-journal): accept Amazon Aurora PostgreSQL (full_page_writes=off)' (#65) from fix/aurora-full-page-writes-develop into develop
maven / build (push) Successful in 7m35s
build / maven (push) Successful in 7m35s
2026-11-10 15:27:45 +00:00
james.maesandClaude Fable 5.2 2837f4152a fix(upload-journal): accept Amazon Aurora PostgreSQL (full_page_writes=off)
maven / build (push) Successful in 9m8s
build / maven (push) Successful in 9m8s
The PostgreSQL 17 journal qualification refused every call on Amazon Aurora
PostgreSQL, which reports full_page_writes=off and does not expose the
parameter in its cluster parameter group.

- The settings query gains an eleventh column,
  to_regprocedure('aurora_version()') IS NOT NULL, which is true on Aurora,
  false on stock PostgreSQL 17 and never raises.
- full_page_writes=off is accepted only when that column is true. Stock
  PostgreSQL with full_page_writes=off is still refused, and synchronous_commit,
  fsync, recovery, isolation, time zone and lock timeout are unchanged on Aurora.
- The decision is the pure function tornPagesPrevented, covered by an always-on
  unit test. The opt-in real PostgreSQL 17 suite simulates Aurora with a
  function named aurora_version() and full_page_writes=off on the server.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 20:26:14 -06:00
4 changed files with 83 additions and 3 deletions

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- Favorites and recents accept a table whose custom permission checker tells a read from a write by the type of the action input. `TargetResolver` asked whether a table is readable with an untyped `AbstractActionInput`; such a checker refuses an input it does not recognise, so `POST /recents` (which the UI sends on every list and record screen) and starring answered `404 Not found` for people the checker lets read the table. The read is now asked as the query it stands for, as `ParentRecordAccess` already asks with a get. Who may read a table is unchanged.
- Reads of a table that has no tag assignment no longer query `collaboration_record_tag`. Core selects the Tags field for every query or get that names no fields, so each of them (including per-record gets and the stored-record read before an update) ran one assignment statement whether or not the table had ever been tagged: 600 gets of an untagged table ran 600 statements, now none. Each node remembers which tables have no assignment (`TaggedTables`, core `Memoization` registered with `ClusterCaches`) for `CollaborationQBitConfig.withTagLookupCacheSeconds` (default 30, 0 disables); writing an assignment clears it on the node and through the ClusterBus. Tables with assignments are read as before, one lookup per 500 parents. No API or schema change. See "What the Tags field costs a read" in the README.
- Browsing one of the qbit's child tables (comments, tasks, record tags, attachments and the other parent-referencing tables) reads each distinct parent once per result page to authorize its rows, instead of once per row: 61 tasks on two parents ran 61 parent reads, now 2. The check itself and its outcome are unchanged.
- The retained upload journal's PostgreSQL 17 qualification accepts Amazon Aurora PostgreSQL, which reports `full_page_writes=off` and does not let a deployment change it. `off` is accepted only when the settings query detects Aurora (`to_regprocedure('aurora_version()') IS NOT NULL`); stock PostgreSQL with `full_page_writes=off` is still refused, and every other requirement is unchanged on Aurora. See `docs/RETAINED-UPLOAD-JOURNAL.md`.
+1 -1
View File
@@ -21,7 +21,7 @@
<!-- Only the published Redis API package is a permitted cross-qbit boundary. -->
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.redis.api</qbit.guardrails.allowedApiPackages>
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-redis</qbit.guardrails.allowedQbitArtifacts>
<revision>1.2.0-RC.3</revision>
<revision>1.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -288,12 +288,16 @@ public final class TargetResolver
/*******************************************************************************
** Guard existence before invoking the core table permission helper.
** Guard existence before invoking the core table permission helper. The read
** is asked as the query it stands for: core hands a table's custom permission
** checker the action input and not the permission asked, so a checker that
** tells a read from a write by the input's type refuses an untyped input, and
** its table was then no target for the people it lets read it.
*******************************************************************************/
private static boolean canReadTable(String table)
{
return (table != null && QContext.getQInstance().getTable(table) != null
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ));
&& PermissionsHelper.hasTablePermission(new QueryInput(table), table, TablePermissionSubType.READ));
}
@@ -35,14 +35,19 @@ import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
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.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
@@ -51,11 +56,17 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
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.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
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;
@@ -158,6 +169,70 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** A table whose permission checker goes by the action input it is handed (a
** query, a get or a count reads; anything else is refused as a write) is a
** target for whoever that checker lets read it, and for nobody else: the
** visibility check asks as the query it stands for, never with an untyped
** input the checker cannot recognise.
*******************************************************************************/
@Test
void tableWithAnInputTypedPermissionCheckerIsATarget() throws Exception
{
instance.addTable(new QTableMetaData().withName("ledger").withLabel("Ledger").withBackendName("memory").withPrimaryKeyField("id")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withCustomPermissionChecker(new QCodeReference(ReadsByInputType.class)))
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("title", QFieldType.STRING))
.withRecordLabelFormatAndFields("%s", "title"));
InsertInput seed = new InsertInput("ledger").withRecords(List.of(new QRecord().withValue("id", 7).withValue("title", "Seventh entry")));
seed.withPermissionBypass("Test fixture seeds a row of a table nobody may write.");
assertThat(new InsertAction().execute(seed).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
login("alice", ReadsByInputType.PERMISSION);
at(START);
RecentsService.recordView("TABLE", "ledger", null);
at(START.plusSeconds(1));
RecentsService.recordView("RECORD", "ledger", "7");
assertThat(RecentsService.list(50)).extracting(FavoriteEntry::label).containsExactly("Seventh entry", "Ledger");
login("bob");
assertThatThrownBy(() -> RecentsService.recordView("TABLE", "ledger", null)).hasMessage("Not found");
assertThatThrownBy(() -> RecentsService.recordView("RECORD", "ledger", "7")).hasMessage("Not found");
}
/*******************************************************************************
** A checker of the kind hosts and other qbits write for read-only tables: QQQ
** hands it the action input and not the permission asked, so it reads the
** input's type. A query, a get and a count need its permission; every other
** input is refused to everyone.
*******************************************************************************/
public static class ReadsByInputType implements CustomPermissionChecker
{
static final String PERMISSION = "ledger.view";
/*******************************************************************************
** Refuse whatever is not a recognised read, then require the permission.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
if(!(actionInput instanceof QueryInput || actionInput instanceof GetInput || actionInput instanceof CountInput))
{
throw (new QPermissionDeniedException("This table is read-only."));
}
if(!QContext.getQSession().hasPermission(PERMISSION))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
}
/*******************************************************************************
** Newest-first ordering is stable; locked rows are dropped before the limit.
*******************************************************************************/