fix(startup): read startup settings as the system user, not a tokenless session #13

Merged
KofTwentyTwo merged 1 commits from feature/no-tokenless-session-at-produce into develop 2026-10-14 23:18:22 +00:00
4 changed files with 237 additions and 2 deletions

No files matched your search

+4
View File
@@ -4,6 +4,10 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Fixed
- Startup on hosts whose default authentication requires a token (OAuth2, Auth0): `produce()` read the settings singleton under a plain tokenless `QSession`, which failed session validation (`JWTDecodeException: The token is null`) and stopped the host. The startup read now runs as the core `QSystemUserSession`, still honoring the persisted schedules. `TokenRequiredHostTest` runs production under a token-required test authentication module.
### Added
- Foreign keys (`worm-audit:11-12`), `ON DELETE RESTRICT` only: chain link -> core audit header, sealed export -> its first and last checkpoints, export verification -> its export, with their indexes. Link references (ranges, break link, purge anchor) stay unconstrained because the retention purge deletes the links they outlive. Pre-existing orphans are copied to `qqq_fk_quarantine` (`KEPT`), never modified, and leave that constraint `NOT VALID`. Re-apply the rendered DDL (idempotent).
@@ -44,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
@@ -192,9 +193,14 @@ public class WormAuditQBitProducer implements QBitMetaDataProducer<WormAuditQBit
var previousTransaction = QContext.getQBackendTransaction();
try
{
// Production is still assembling the host metadata. Do not validate it mid-build.
////////////////////////////////////////////////////////////////////////////
// Production is still assembling the host metadata. Do not validate it //
// mid-build. The startup settings read is system work: run it as the //
// core system user. A plain tokenless QSession fails session validation //
// under token-based host authentication (OAuth2, Auth0). //
////////////////////////////////////////////////////////////////////////////
QContext.setQInstance(qInstance);
QContext.setQSession(new com.kingsrook.qqq.backend.core.model.session.QSession());
QContext.setQSession(new QSystemUserSession(qInstance));
QContext.setTransaction(null);
addProcesses(output, qBit);
}
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.wormaudit;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
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.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
/*******************************************************************************
** Test authentication module that behaves like the token-based core modules
** (OAuth2, Auth0): a QSystemUserSession is always valid, a session it created
** is valid, and any other session has no token and fails validation with the
** same message the OAuth2 module produces (JWTDecodeException: The token is
** null).
*******************************************************************************/
public class TokenRequiredAuthenticationModule implements QAuthenticationModuleInterface
{
private static final Set<String> ISSUED = ConcurrentHashMap.newKeySet();
/*******************************************************************************
** Issue a session that carries a token.
*******************************************************************************/
@Override
public QSession createSession(QInstance qInstance, Map<String, String> context) throws QAuthenticationException
{
QSession session = new QSession();
ISSUED.add(session.getUuid());
return (session);
}
/*******************************************************************************
** Accept the system user and issued sessions; reject tokenless sessions.
*******************************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
if(session instanceof QSystemUserSession || ISSUED.contains(session.getUuid()))
{
return (true);
}
throw (new IllegalStateException("The token is null"));
}
}
@@ -0,0 +1,153 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.wormaudit;
import java.util.List;
import com.kingsrook.qbits.wormaudit.chain.AuditStore;
import com.kingsrook.qbits.wormaudit.model.WormAuditTables;
import com.kingsrook.qbits.wormaudit.processes.ExportProcesses;
import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses;
import com.kingsrook.qqq.backend.core.context.QContext;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
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.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Regression: production must work on a host whose default authentication
** rejects tokenless sessions (kof22 uses OAuth2). produce() reads the settings
** singleton for the startup schedules; under a plain new QSession() that read
** failed with "The token is null" and the host could not start.
*******************************************************************************/
class TokenRequiredHostTest
{
/*******************************************************************************
** Clear the context and the memory store.
*******************************************************************************/
@AfterEach
void tearDown()
{
MemoryRecordStore.fullReset();
QContext.clear();
}
/*******************************************************************************
** The test module really rejects a tokenless session, so the tests below
** prove the qbit does not use one.
*******************************************************************************/
@Test
void moduleRejectsTokenlessSession() throws Exception
{
MemoryRecordStore.fullReset();
QInstance instance = tokenRequiredHost(new WormAuditQBitConfig());
QContext.init(instance, new QSession());
assertThatThrownBy(() -> new AuditStore(null).get(WormAuditTables.SETTINGS, 1))
.hasStackTraceContaining("The token is null");
}
/*******************************************************************************
** produce() succeeds and still honors the persisted settings row for the
** startup schedules, read as the system user.
*******************************************************************************/
@Test
void produceReadsPersistedSettingsAsSystemUser() throws Exception
{
////////////////////////////////////////////////////////////////////
// first start: an anonymous host stores an edited settings row //
////////////////////////////////////////////////////////////////////
MemoryRecordStore.fullReset();
WormAuditQBitConfig seedConfig = new WormAuditQBitConfig();
QInstance seedHost = WormAuditTestSupport.core(memory());
new WormAuditQBitProducer().withQBitConfig(seedConfig.withBackendName("memory")).produce(seedHost);
QContext.init(seedHost, new QSession());
QRecord row = WormAuditSettings.defaults(seedConfig)
.withValue("chain_interval_seconds", 45)
.withValue("verify_cron", "0 15 4 * * ?")
.withValue("export_cron", "0 45 1 * * ?");
new AuditStore(null).insert(WormAuditTables.SETTINGS, List.of(row));
QContext.clear();
//////////////////////////////////////////////////////////////////////
// restart on a token-required host, with no session in context, as //
// when a host builds its instance before any request arrives //
//////////////////////////////////////////////////////////////////////
QInstance instance = tokenRequiredHost(new WormAuditQBitConfig());
assertThat(instance.getProcess(WormAuditProcesses.CHAIN).getSchedule().getRepeatSeconds()).isEqualTo(45);
assertThat(instance.getProcess(WormAuditProcesses.DAILY).getSchedule().getCronExpression()).isEqualTo("0 15 4 * * ?");
assertThat(instance.getProcess(ExportProcesses.DAILY).getSchedule().getCronExpression()).isEqualTo("0 45 1 * * ?");
assertThat(QContext.getQSession()).isNull();
}
/*******************************************************************************
** With no settings row, production falls back to the configured values.
*******************************************************************************/
@Test
void produceFallsBackToConfigWithoutRow() throws Exception
{
MemoryRecordStore.fullReset();
QInstance instance = tokenRequiredHost(new WormAuditQBitConfig().withChainIntervalSeconds(17));
assertThat(instance.getProcess(WormAuditProcesses.CHAIN).getSchedule().getRepeatSeconds()).isEqualTo(17);
}
/*******************************************************************************
** Build a host whose default authentication requires a token, and produce.
*******************************************************************************/
private static QInstance tokenRequiredHost(WormAuditQBitConfig config) throws Exception
{
QInstance instance = WormAuditTestSupport.core(memory());
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("token-required")
.withType(QAuthenticationType.MOCK)
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
new WormAuditQBitProducer().withQBitConfig(config.withBackendName("memory")).produce(instance);
return (instance);
}
/*******************************************************************************
** The memory backend.
*******************************************************************************/
private static QBackendMetaData memory()
{
return (new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
}
}