Compare commits

..
Author SHA1 Message Date
james.maesandGPT-6 2de0d11a10 test: verify shared sessions after Redis promotion
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 07:58:17 -05:00
james.maesandCodex 0f50ca15af fix: upgrade session-store test assertions to patched AssertJ
Use3.27.7 for GHSA-rqfh-9r24-8c9r. Verified synthetic external-entity/DTD rejection, ordinary XML control, dependency resolution,67unit and29integration tests with coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 14:44:20 -05:00
20 changed files with 258 additions and 1617 deletions

No files matched your search

+5 -3
View File
@@ -1,14 +1,16 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.7.18
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
orb_version: 0.7.18
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
only:
- /^v\d+\.\d+\.\d+$/
- /publish.*/
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+2 -1
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: '0.8'
orb_version: 0.7.18
default_branch: develop
java_version: '21'
browsers: true
@@ -10,6 +10,7 @@ release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
feature: true
release_candidate: true
release: true
asciidoc: false
+1 -25
View File
@@ -5,12 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - 0.1.0
First published release. Version numbers are assigned by the shared Gitea
Maven workflow from the branch or tag that publishes (see README "Releasing");
the `revision` property on `develop` stays `0.1.0-SNAPSHOT` until the release
merges back.
## [Unreleased] - 0.1.0-SNAPSHOT
### Added
- In-memory session store provider with LRU eviction
@@ -20,22 +15,3 @@ merges back.
- Sliding expiration (configurable per-provider)
- Scheduled cleanup process for expired sessions
- QBit producer for declarative configuration
- `SHARED_REDIS` provider: application-scoped shared sessions on the pool owned
by the optional `qbit-redis` QBit, with explicit deployment/application/
authentication-domain namespacing, bounded payloads, atomic GETEX touch, and a
runtime service that releases registrations on stop and rebuilds them on
restart (`docs/SESSION-REGISTRATION.md`)
- Explicit `QInstance` overloads on `QSessionStoreQBitContext` and
application-scoped registration through
`QSessionStoreRegistry.getInstance(qInstance)`; two applications in one JVM
no longer see each other's sessions
- Health check reports the target application selected by `HealthCheckInput`
### Changed
- Depends on published QQQ core `4.1.0-RC.22` (the first release-line core with
`QSessionStoreRegistry.getInstance(QInstance)`) and `qbit-redis 0.1.0-RC.1`;
the snapshot coordinates used while the shared-session work was in flight
(`4.1.0-collab-33464d37e-SNAPSHOT`, `0.1.0-redis-7fe5492-SNAPSHOT`) are gone
- Jedis 7.2.0, Testcontainers 2.0.3, AssertJ 3.27.7 (GHSA-rqfh-9r24-8c9r)
- Hosts that register a provider with core outside a request context must use
the explicit `QSessionStoreRegistry.getInstance(qInstance)` overload
+2 -36
View File
@@ -8,21 +8,7 @@ Every QQQ request authenticates and builds a `QSession` containing security keys
## Project Status
**0.1.0** is the first published release line (release candidates publish as
`0.1.0-RC.N`). API may change before 1.0. See [CHANGELOG.md](CHANGELOG.md).
## Compatibility
| qbit-session-store | QQQ core (`qqq-bom-pom`) | qbit-redis (optional, `SHARED_REDIS` only) |
|--------------------|--------------------------|--------------------------------------------|
| 0.1.0 line | 4.1.0-RC.22 or later 4.1 | 0.1.0-RC.1 or later 0.1 |
The `SHARED_REDIS` provider and application-scoped registration call
`QSessionStoreRegistry.getInstance(QInstance)` and `clearIfSame`, which core
added after 4.1.0-RC.21; this QBit does not compile against RC.21 or earlier.
Artifacts publish to the Gitea Maven registry
(`https://git.kof22.com/api/packages/QRun-IO/maven`), which requires an
authenticated read.
**0.1.0-SNAPSHOT** -- under active development. API may change before 1.0.
## Features
@@ -49,7 +35,7 @@ Add the dependency to your project:
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-session-store</artifactId>
<version>0.1.0</version>
<version>0.1.0-SNAPSHOT</version>
</dependency>
```
@@ -241,26 +227,6 @@ provider.remove(sessionUuid);
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Releasing
Versions are computed by the shared `KofTwentyTwo/ci-workflows` Maven workflow
(`.gitea/workflows/build.yaml`) from what is pushed; the `revision` property on
`develop` stays `0.1.0-SNAPSHOT` and is never hand-edited for a release.
| Push | Published version | Side effects |
|----------------------------|--------------------------|---------------------------------------------------------------|
| `develop` | `0.1.0-SNAPSHOT` | none |
| branch `release/0.1` | `0.1.0-RC.1`, `RC.2`, .. | commits "Bump version to X [skip ci]" to the branch, tags `vX` |
| tag `v0.1.0` | `0.1.0` | pushes the version bump to `main` |
| tag `publish-<name>` | `0.1.0-<feature>-<sha>-SNAPSHOT` | none |
To cut the first release: merge this release preparation into `develop`, create
`release/0.1` from `develop` and push it (publishes `0.1.0-RC.1`), qualify the
candidate, then tag the bumped `release/0.1` head `v0.1.0` and push the tag
(publishes `0.1.0`). Merge `release/0.1` back into `develop`; the workflow then
advances `develop` to `0.2.0-SNAPSHOT`. Record the date in `CHANGELOG.md` when
tagging. GitHub is a read-only shadow; never push there.
## License
Apache-2.0 - See [LICENSE](LICENSE)
+50
View File
@@ -0,0 +1,50 @@
# Shared sessions during controlled Redis promotion
Tracks [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301). This is a test of
the actual shared session provider, complementary to the connection and Pub/Sub
promotion fixture in Redis draft PR4. No production provider behavior changes.
`SharedRedisSessionStoreProviderIT.replicatedSessionsAndRevocationsSurviveControlledPromotion`
uses a real Redis primary/replica pair and three separately configured QQQ
instances. One writes to the primary; two use the replica endpoint, with fixed
and sliding expiry settings. The test verifies:
- Session data reaches the replica and writes to the read-only replica fail.
- A removed session is absent after a subsequent write reaches the replica.
This observation establishes that this test's deletion was replicated.
- After the old primary stops, its provider raises an infrastructure failure
rather than reporting a normal session miss.
- After promotion, the original session remains readable and the replicated
revocation remains absent. Touch/read-and-touch cannot recreate it.
- New sessions, cross-node deletion, sliding expiry and natural expiry work on
the promoted endpoint. Touch cannot recreate an expired session.
Verification passed on October 26, 2026: 67 unit tests and 30 integration tests,
no failures/errors/skips, zero Checkstyle violations, and coverage gates passed.
The first full run encountered a replica read timeout during initial loading;
the fixture now retries connection failures only inside its bounded 15-second
initial replication wait. Post-startup assertions and runtime timeouts are
unchanged. The focused test also passed before that startup adjustment.
Containers, network, provider registrations and runtime pools belong to the
fixture and are cleaned up by it. The existing Maven wrapper from the core
checkout can run this repository's POM on Windows, where this checkout has no
`mvnw.cmd`.
## Limits and remaining qualification
The test deliberately waits for replication before stopping the primary. It
does not prove that unreplicated session creation, permission changes or logout
survive failover. Redis replication is asynchronous; recovered connectivity
alone does not establish durable authentication state. See the official
[replication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/).
Before production failover support, qualify the actual host endpoint routing,
old-primary fencing, TLS/authentication, and the policy for lost or restored
authentication state. In particular, a logout lost before replication can leave
an older session copy on the promoted node; this fixture must not be cited as
proof that such a session cannot return. Presence generation rotation alone
does not invalidate session-store data.
This uses an unchanged explicit replica endpoint and controlled promotion. It
does not implement native Sentinel discovery, Cluster routing or automatic
application reconfiguration, and does not qualify a deployed Kof22 cluster.
+6 -21
View File
@@ -18,24 +18,17 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.3.0-SNAPSHOT</revision>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<argLine></argLine>
<jedis.version>7.2.0</jedis.version>
<!-- Published QQQ 4.1 core. 4.1.0-RC.22 is the first release-line core that carries
QSessionStoreRegistry.getInstance(QInstance) and clearIfSame, which the
application-scoped SHARED_REDIS provider requires (RC.21 and earlier do not compile). -->
<qqq.version>4.1.0-RC.26</qqq.version>
<!-- Published qbit-redis release candidate whose public api (RedisAccess, RedisCache,
RedisNamespace) the SHARED_REDIS provider is built against. -->
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
<qqq.snapshot.version>4.1.0-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>1.4.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -86,7 +79,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.version}</version>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -102,7 +95,7 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-redis</artifactId>
<version>${qbit-redis.version}</version>
<version>0.1.0-redis-7fe5492-SNAPSHOT</version>
<optional>true</optional>
</dependency>
@@ -118,7 +111,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<version>${qqq.version}</version>
<version>${qqq.snapshot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -126,11 +119,6 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -198,9 +186,6 @@
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
<!-- PostgreSQL instants (TableBasedSessionStorePostgresIT) are only correct under a UTC JVM.
@{argLine} keeps the JaCoCo agent that prepare-agent puts in the argLine property. -->
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
</configuration>
<executions>
<execution>
@@ -1,151 +0,0 @@
/*
* Copyright 2024 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
*
* http://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.sessionstore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONObject;
/*******************************************************************************
** Shared at-rest rules for every persistent provider.
**
** A session id is the credential that resumes the session (the cookie value of a
** native login), so it is never written to a table, Redis key, log line or the
** stored JSON. Providers address a session by {@link #key(String)} (a one-way
** digest) and store {@link #encode(String, QSession)}, an envelope whose session
** has its own uuid and id reference removed; {@link #decode(String, String)}
** restores them from the id the caller presented. A reader that does not hold
** the id therefore cannot resume anything from a copy of the store, and an
** envelope read under another id is refused.
*******************************************************************************/
public final class SessionCodec
{
private static final String KEY_DOMAIN = "qbit-session-store:v1:";
private static final int VERSION = 1;
private static final int LOG_LENGTH = 12;
/***************************************************************************
** Utility class.
***************************************************************************/
private SessionCodec()
{
}
/***************************************************************************
** The storage key for a session id: lower-case hex SHA-256 of a domain
** separated id.
***************************************************************************/
public static String key(String sessionId)
{
Objects.requireNonNull(sessionId, "Session id is required");
if(sessionId.isBlank())
{
throw new IllegalArgumentException("Session id is required");
}
try
{
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((KEY_DOMAIN + sessionId).getBytes(StandardCharsets.UTF_8)));
}
catch(NoSuchAlgorithmException e)
{
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
/***************************************************************************
** A short non-reversible tag for log lines.
***************************************************************************/
public static String logTag(String sessionId)
{
return sessionId == null || sessionId.isBlank() ? "none" : key(sessionId).substring(0, LOG_LENGTH);
}
/***************************************************************************
** Serialize a session for storage under the given id, without the id.
***************************************************************************/
public static String encode(String sessionId, QSession session)
{
Objects.requireNonNull(session, "Session is required");
JSONObject stored = new JSONObject(JsonUtils.toJson(session));
boolean uuidStripped = sessionId.equals(stored.optString("uuid", null));
boolean refStripped = sessionId.equals(stored.optString("idReference", null));
if(uuidStripped)
{
stored.remove("uuid");
}
if(refStripped)
{
stored.remove("idReference");
}
return new JSONObject()
.put("v", VERSION)
.put("keyDigest", key(sessionId))
.put("uuidIsId", uuidStripped)
.put("idReferenceIsId", refStripped)
.put("session", stored)
.toString();
}
/***************************************************************************
** Restore a stored envelope for the id the caller presented; anything that
** is not an envelope for exactly this id is refused.
***************************************************************************/
public static QSession decode(String sessionId, String json) throws IOException
{
JSONObject envelope;
try
{
envelope = new JSONObject(json);
}
catch(RuntimeException e)
{
throw new IOException("Stored session is not readable", e);
}
if(envelope.optInt("v", -1) != VERSION || !envelope.has("session") || !key(sessionId).equals(envelope.optString("keyDigest", null)))
{
throw new IOException("Stored session does not belong to the presented id");
}
JSONObject stored = envelope.getJSONObject("session");
if(envelope.optBoolean("uuidIsId"))
{
stored.put("uuid", sessionId);
}
if(envelope.optBoolean("idReferenceIsId"))
{
stored.put("idReference", sessionId);
}
return JsonUtils.toObject(stored.toString(), QSession.class);
}
}
@@ -1,175 +0,0 @@
/*
* Copyright 2024 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
*
* http://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.sessionstore;
import java.util.ArrayList;
import java.util.List;
import java.util.function.UnaryOperator;
import com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
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.tables.QTableMetaData;
/*******************************************************************************
** The PostgreSQL schema of the TABLE_BASED provider, derived from the very table
** metadata the producer installs, so the two cannot drift apart. A migration
** identity (the schema owner) applies it once; the provider itself only ever does
** DML and needs no DDL, history or ownership rights.
**
** QQQ quotes every identifier, so by default the physical names are exactly the
** metadata names (quoted). An application that maps the table onto another naming
** rule (for example snake_case through RDBMSTableBackendDetails and field backend
** names) passes that rule as the naming function, and gets the matching DDL.
*******************************************************************************/
public final class StoredSessionSchema
{
private static final UnaryOperator<String> EXACT = UnaryOperator.identity();
/***************************************************************************
** Utility class.
***************************************************************************/
private StoredSessionSchema()
{
}
/***************************************************************************
** The physical table name (exact metadata name).
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config)
{
return physicalTableName(config, EXACT);
}
/***************************************************************************
** The physical table name under a naming rule.
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
return naming.apply(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** The physical column names (exact metadata names).
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config)
{
return physicalColumnNames(config, EXACT);
}
/***************************************************************************
** The physical column names under a naming rule.
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
List<String> names = new ArrayList<>();
for(QFieldMetaData field : table(config).getFields().values())
{
names.add(naming.apply(field.getName()));
}
return names;
}
/***************************************************************************
** Idempotent DDL with exact names.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config)
{
return postgresDdl(config, EXACT);
}
/***************************************************************************
** Idempotent DDL under a naming rule: the table, its unique key on the
** session-key digest and an index serving expiry cleanup. Identifiers are
** always quoted, as QQQ quotes them in its own statements.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
QTableMetaData table = table(config);
String name = physicalTableName(config, naming);
String key = naming.apply(StoredSession.FIELD_SESSION_UUID);
String exp = naming.apply(StoredSession.FIELD_EXPIRES_AT);
List<String> lines = new ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
lines.add(" " + quote(naming.apply(field.getName())) + " " + type(table, field));
}
lines.add(" CONSTRAINT " + quote("uq_" + name + "_" + key) + " UNIQUE (" + quote(key) + ")");
return "CREATE TABLE IF NOT EXISTS " + quote(name) + " (\n" + String.join(",\n", lines) + "\n);\n"
+ "CREATE INDEX IF NOT EXISTS " + quote("ix_" + name + "_" + exp) + " ON " + quote(name) + " (" + quote(exp) + ");\n";
}
/***************************************************************************
** Quote an identifier.
***************************************************************************/
public static String quote(String identifier)
{
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
/***************************************************************************
** The producer's table, built in a scratch instance.
***************************************************************************/
private static QTableMetaData table(QSessionStoreQBitConfig config)
{
QInstance scratch = new QInstance();
StoredSessionTableMetaDataProducer.produce(scratch, config);
return scratch.getTable(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** Column type and constraints of one field.
***************************************************************************/
private static String type(QTableMetaData table, QFieldMetaData field)
{
if(field.getName().equals(table.getPrimaryKeyField()))
{
return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY";
}
String base = switch(field.getType())
{
case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")";
case TEXT, HTML -> "TEXT";
case DATE_TIME -> "TIMESTAMP WITH TIME ZONE";
default -> throw new IllegalStateException("Unsupported stored-session field type " + field.getType());
};
return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "");
}
}
@@ -22,19 +22,12 @@ import com.kingsrook.qbits.sessionstore.model.StoredSession;
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.permissions.DenyBehavior;
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.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Metadata producer for the StoredSession table.
**
** The table is internal: hidden, and protected by its own permission set that no
** role is given (the provider reads and writes it as the system user). Its rows
** are session-key digests and id-free envelopes, never session ids.
*******************************************************************************/
public class StoredSessionTableMetaDataProducer
{
@@ -49,11 +42,6 @@ public class StoredSessionTableMetaDataProducer
QTableMetaData table = new QTableMetaData()
.withName(tableName)
.withLabel("Stored Session")
.withIsHidden(true)
.withPermissionRules(new QPermissionRules()
.withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
.withPermissionBaseName(tableName)
.withDenyBehavior(DenyBehavior.HIDDEN))
.withBackendName(config.getBackendName())
.withPrimaryKeyField(StoredSession.FIELD_ID)
.withRecordLabelFormat("%s")
@@ -61,10 +49,10 @@ public class StoredSessionTableMetaDataProducer
.withUniqueKey(new UniqueKey(StoredSession.FIELD_SESSION_UUID))
.withField(new QFieldMetaData(StoredSession.FIELD_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(StoredSession.FIELD_SESSION_UUID, QFieldType.STRING)
.withLabel("Session Key (digest)")
.withLabel("Session UUID")
.withIsRequired(true)
.withIsEditable(false)
.withMaxLength(64))
.withMaxLength(36))
.withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING)
.withLabel("User ID")
.withIsEditable(false))
@@ -25,7 +25,6 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -58,24 +57,6 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
/***************************************************************************
** A request that mutates its session must not change what another request
** will load, so the store holds and returns copies.
***************************************************************************/
private static QSession copy(QSession session)
{
try
{
return session.clone();
}
catch(CloneNotSupportedException e)
{
throw new IllegalStateException("Session cannot be copied", e);
}
}
/***************************************************************************
** Configure the provider.
***************************************************************************/
@@ -118,8 +99,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
cache.put(sessionUuid, new CachedSession(copy(session), expiresAt));
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
cache.put(sessionUuid, new CachedSession(session, expiresAt));
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
}
@@ -139,11 +120,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
return Optional.of(copy(cached.session()));
return Optional.of(cached.session());
}
@@ -155,7 +136,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
public void remove(String sessionUuid)
{
cache.remove(sessionUuid);
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
}
@@ -171,7 +152,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
}
}
@@ -203,7 +184,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
@@ -212,9 +193,9 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
//////////////////////////////////////////////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Loaded and touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
return Optional.of(copy(cached.session()));
return Optional.of(cached.session());
}
@@ -20,9 +20,9 @@ import java.time.Duration;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
@@ -82,7 +82,7 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
***************************************************************************/
private String buildKey(String sessionUuid)
{
return keyPrefix + SessionCodec.key(sessionUuid);
return keyPrefix + sessionUuid;
}
@@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
String key = buildKey(sessionUuid);
String sessionJson = SessionCodec.encode(sessionUuid, session);
String sessionJson = JsonUtils.toJson(session);
jedis.setex(key, effectiveTtl.toSeconds(), sessionJson);
LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds()));
LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load session from Redis", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -148,11 +148,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.del(key);
LOG.debug("Removed session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session from Redis", logPair("sessionUuid", sessionUuid));
}
catch(Exception e)
{
LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -168,11 +168,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.expire(key, defaultTtl.toSeconds());
LOG.debug("Touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
LOG.debug("Touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -27,10 +27,10 @@ import com.kingsrook.qbits.redis.api.RedisCache;
import com.kingsrook.qbits.redis.api.RedisNamespace;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
@@ -115,7 +115,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void store(String id, QSession session, Duration ttl)
{
cache().put(SessionCodec.key(id), SessionCodec.encode(id, Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
cache().put(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
}
@@ -126,7 +126,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public Optional<QSession> load(String id)
{
return decode(id, cache().get(SessionCodec.key(id)));
return decode(cache().get(id));
}
@@ -137,7 +137,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void remove(String id)
{
cache().remove(SessionCodec.key(id));
cache().remove(id);
}
@@ -148,7 +148,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void touch(String id)
{
cache().touch(SessionCodec.key(id), defaultTtl);
cache().touch(id, defaultTtl);
}
@@ -159,8 +159,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public Optional<QSession> loadAndTouch(String id)
{
String key = SessionCodec.key(id);
return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key));
return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id));
}
@@ -168,7 +167,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
/***************************************************************************
** Bound decoded payloads and never include session contents in error messages.
***************************************************************************/
private Optional<QSession> decode(String id, Optional<byte[]> value)
private Optional<QSession> decode(Optional<byte[]> value)
{
return value.map(bytes ->
{
@@ -178,7 +177,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
}
try
{
return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8)));
return Objects.requireNonNull(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class));
}
catch(IOException | RuntimeException e)
{
@@ -16,26 +16,23 @@
package com.kingsrook.qbits.sessionstore.providers;
import java.io.IOException;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
@@ -43,22 +40,19 @@ 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.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Table-based session store provider using QQQ table actions.
** Best for: multi-instance deployments, persistence across restarts.
**
** Rows are addressed by {@link SessionCodec#key(String)} and hold an envelope that
** does not contain the session id, so the table never holds a credential that
** resumes a session. Storing a session that is already present replaces its data:
** a stale copy (old permissions, old tenant keys) is never left in place, and when
** a store cannot complete the previous copy is removed.
*******************************************************************************/
public class TableBasedSessionStoreProvider implements QSessionStoreProviderInterface
{
@@ -67,7 +61,6 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
private QSessionStoreQBitConfig config;
private String tableName;
private Duration defaultTtl;
private boolean sliding;
@@ -80,196 +73,85 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
this.config = config;
this.tableName = config.applyPrefix(config.getTableName());
this.defaultTtl = config.getDefaultTtl();
this.sliding = !Boolean.FALSE.equals(config.getEnableSlidingExpiration());
LOG.info("TableBased session store configured", logPair("tableName", tableName));
}
/***************************************************************************
** Store a session with the given TTL, replacing any existing copy.
** Store a session with the given TTL.
***************************************************************************/
@Override
public void store(String sessionUuid, QSession session, Duration ttl)
{
String key = SessionCodec.key(sessionUuid);
try
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
String stored = SessionCodec.encode(sessionUuid, session);
String userId = session.getUser() != null ? session.getUser().getIdReference() : null;
Instant expiresAt = Instant.now().plus(effectiveTtl);
String sessionJson = JsonUtils.toJson(session);
QRecord record = new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, sessionUuid)
.withValue(StoredSession.FIELD_USER_ID, session.getUser() != null ? session.getUser().getIdReference() : null)
.withValue(StoredSession.FIELD_SESSION_DATA, sessionJson)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt);
runWithSystemSession(() ->
{
try
{
upsert(key, userId, stored, expiresAt);
}
catch(QException e)
{
///////////////////////////////////////////////////////////////////////
// two instances storing the same new session race on the unique key: //
// the loser retries once, as an update //
///////////////////////////////////////////////////////////////////////
upsert(key, userId, stored, expiresAt);
}
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(record));
new InsertAction().execute(insertInput);
return null;
});
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
remove(sessionUuid);
LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e);
}
}
/***************************************************************************
** Insert or update the one row for a key.
***************************************************************************/
private void upsert(String key, String userId, String stored, Instant expiresAt) throws QException
{
QRecord existing = get(key);
if(existing == null)
{
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, key)
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new InsertAction().execute(insertInput).getRecords());
}
else
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", existing.getValue("id"))
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new UpdateAction().execute(updateInput).getRecords());
}
}
/***************************************************************************
** A record-level error (unique key, constraint) is a failed store.
***************************************************************************/
private static void requireNoErrors(List<QRecord> records) throws QException
{
for(QRecord record : records)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw new QException("Session store write was rejected");
}
}
}
/***************************************************************************
** The stored row for a storage key, or null.
***************************************************************************/
private QRecord get(String key) throws QException
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, key));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
}
/***************************************************************************
** Load a session by id.
** Load a session by UUID.
***************************************************************************/
@Override
public Optional<QSession> load(String sessionUuid)
{
return read(sessionUuid, false);
}
/***************************************************************************
** Load a session and, when sliding expiration is on, renew it.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
return read(sessionUuid, sliding);
}
/***************************************************************************
** Shared read: expired, unreadable and mismatched rows are removed and
** reported as a miss.
***************************************************************************/
private Optional<QSession> read(String sessionUuid, boolean touch)
{
String key = SessionCodec.key(sessionUuid);
try
{
return runWithSystemSession(() ->
QRecord record = runWithSystemSession(() ->
{
QRecord record = get(key);
if(record == null)
{
return Optional.<QSession>empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
deleteByKey(key);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
QSession session;
try
{
session = SessionCodec.decode(sessionUuid, record.getValueString(StoredSession.FIELD_SESSION_DATA));
String storedUser = record.getValueString(StoredSession.FIELD_USER_ID);
String sessionUser = session.getUser() != null ? session.getUser().getIdReference() : null;
if(!Objects.equals(storedUser, sessionUser))
{
throw new IOException("Stored session identity does not match its row");
}
}
catch(IOException | RuntimeException e)
{
deleteByKey(key);
LOG.warn("Discarded an unreadable stored session", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
if(touch)
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, Instant.now().plus(defaultTtl))));
new UpdateAction().execute(updateInput);
}
return Optional.of(session);
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
});
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
remove(sessionUuid);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load session", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -277,7 +159,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Remove a session by id.
** Remove a session by UUID.
***************************************************************************/
@Override
public void remove(String sessionUuid)
@@ -286,35 +168,26 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
runWithSystemSession(() ->
{
deleteByKey(SessionCodec.key(sessionUuid));
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
return null;
});
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
}
catch(Exception e)
{
LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e);
}
}
/***************************************************************************
** Delete the row for a storage key.
***************************************************************************/
private void deleteByKey(String key) throws QException
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, key)));
new DeleteAction().execute(deleteInput);
}
/***************************************************************************
** Touch a session to reset its TTL; never recreates a missing session.
** Touch a session to reset its TTL.
***************************************************************************/
@Override
public void touch(String sessionUuid)
@@ -325,24 +198,33 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
runWithSystemSession(() ->
{
QRecord record = get(SessionCodec.key(sessionUuid));
if(record != null)
/////////////////////////////////////////////////////////////////
// First get the record to find its primary key //
/////////////////////////////////////////////////////////////////
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
if(getOutput.getRecord() != null)
{
QRecord updateRecord = new QRecord()
.withValue("id", getOutput.getRecord().getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt)));
updateInput.setRecords(List.of(updateRecord));
new UpdateAction().execute(updateInput);
}
return null;
});
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -359,6 +241,83 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Load a session and touch it in a single operation.
**
** Optimized to perform both load and TTL update in one database round-trip
** by updating the expiresAt during the same get+update operation.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
try
{
return runWithSystemSession(() ->
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
QRecord record = getOutput.getRecord();
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
///////////////////////////////
// Session is expired, clean up //
///////////////////////////////
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.<QSession>empty();
}
///////////////////////////////
// Update expiresAt (touch) //
///////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
QRecord updateRecord = new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(updateRecord));
new UpdateAction().execute(updateInput);
///////////////////////////////
// Return the session //
///////////////////////////////
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
try
{
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
return Optional.of(session);
}
catch(java.io.IOException e)
{
throw new QException("Failed to deserialize session", e);
}
});
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
/***************************************************************************
** Clean up expired sessions.
***************************************************************************/
@@ -397,9 +356,11 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
long count = runWithSystemSession(() ->
{
CountInput countInput = new CountInput();
countInput.setTableName(tableName);
return new CountAction().execute(countInput).getCount().longValue();
QueryInput queryInput = new QueryInput();
queryInput.setTableName(tableName);
queryInput.setFilter(new QQueryFilter());
QueryOutput queryOutput = new QueryAction().execute(queryInput);
return (long) queryOutput.getRecords().size();
});
return "TableBased: " + count + " sessions in " + tableName;
}
@@ -1,110 +0,0 @@
/*
* Copyright 2024 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
*
* http://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.sessionstore;
import java.io.IOException;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for the at-rest rules shared by the persistent providers.
*******************************************************************************/
class SessionCodecTest
{
private static final String ID = "3f1b6d3c-6b0e-4f2d-9d52-0a7d9ad1c001";
/***************************************************************************
** The key is a stable one-way digest that does not contain the id.
***************************************************************************/
@Test
void testKeyIsStableDigestWithoutId()
{
String key = SessionCodec.key(ID);
assertThat(key).matches("[0-9a-f]{64}").doesNotContain(ID).isEqualTo(SessionCodec.key(ID)).isNotEqualTo(SessionCodec.key(ID + "x"));
assertThat(SessionCodec.logTag(ID)).hasSize(12).doesNotContain(ID);
assertThat(SessionCodec.logTag(null)).isEqualTo("none");
assertThatThrownBy(() -> SessionCodec.key(" ")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SessionCodec.key(null)).isInstanceOf(NullPointerException.class);
}
/***************************************************************************
** The stored text never contains the id, and decoding restores it.
***************************************************************************/
@Test
void testEncodeRemovesIdAndDecodeRestoresIt() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference(ID);
session.setUser(new QUser().withIdReference("alice"));
session.setPermissions(Set.of("p.read"));
session.setValue("tenant", "t-1");
String stored = SessionCodec.encode(ID, session);
assertThat(stored).doesNotContain(ID).contains("alice").contains("p.read");
QSession decoded = SessionCodec.decode(ID, stored);
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo(ID);
assertThat(decoded.getUser().getIdReference()).isEqualTo("alice");
assertThat(decoded.getPermissions()).containsExactly("p.read");
assertThat(decoded.getValue("tenant")).isEqualTo("t-1");
}
/***************************************************************************
** A session whose id reference differs from its uuid keeps its own value.
***************************************************************************/
@Test
void testDifferingIdReferenceIsKept() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference("alice@example.com");
QSession decoded = SessionCodec.decode(ID, SessionCodec.encode(ID, session));
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo("alice@example.com");
}
/***************************************************************************
** An envelope read under another id, a legacy plain session, and junk are all refused.
***************************************************************************/
@Test
void testForeignLegacyAndJunkAreRefused()
{
String stored = SessionCodec.encode(ID, new QSession().withValue("k", "v"));
assertThatThrownBy(() -> SessionCodec.decode(ID + "other", stored)).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, JsonUtils.toJson(new QSession().withValue("k", "v")))).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, "not json")).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, stored.replace("\"v\":1", "\"v\":2"))).isInstanceOf(IOException.class);
}
}
@@ -1,238 +0,0 @@
/*
* 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.sessionstore.providers;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.dockerjava.api.command.InspectContainerResponse;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
/*******************************************************************************
** Where a disposable test container is reachable FROM THIS JVM.
**
** The original behavior is Testcontainers' own: the host it reports and the
** published port. That is kept everywhere except one measured topology: a JVM
** inside a build container that shares the daemon's default bridge with the
** fixture (the full Linux verify, which talks to the host daemon through its
** socket). On the Docker Desktop runner the published port is reached through
** the host-gateway hop; measured on an idle machine that path showed round trips
** of about 1 ms with stalls of 0.3 s and about 2 s, against about 0.13 ms and
** no stalls when dialing the sibling directly on the bridge. These tests use
** 250 ms to 1 s socket timeouts, so such stalls can expire them; that is a
** plausible contributor, not shown to be the only cause of the observed
** "Read timed out" failures. In that one topology the fixture is dialed on its
** bridge address and its own port.
**
** The choice is made from the daemon's own description of the build container's
** networks (no connection probing). If the build container cannot be identified
** or is not on the default bridge (a job network, host networking, a remote
** daemon), the original published endpoint is used unchanged. Timeouts,
** scenarios, assertions and server configuration are unchanged.
*******************************************************************************/
final class FixtureEndpoint
{
private static final String DEFAULT_BRIDGE = "bridge";
private static final Pattern MOUNTINFO_CONTAINER_ID = Pattern.compile("/docker/containers/([0-9a-f]{64})/");
private static volatile Boolean buildContainerOnDefaultBridge;
/***************************************************************************
** Host and port to dial.
***************************************************************************/
record Address(String host, int port)
{
}
/***************************************************************************
** Utility class.
***************************************************************************/
private FixtureEndpoint()
{
}
/***************************************************************************
** Host this JVM must dial for the container.
***************************************************************************/
static String host(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).host());
}
/***************************************************************************
** Port matching {@link #host(GenericContainer)}.
***************************************************************************/
static int port(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).port());
}
/***************************************************************************
** A container that lives only on a custom test network is also attached to the
** default bridge when (and only when) the build container is on that bridge, so
** that the build container can reach it directly. The test network, and therefore
** every partition scenario between the fixture servers, is untouched: attach only
** the container the test client dials, never one whose reachability the test cuts
** or whose peers must stay isolated from it. No-op in every other topology.
***************************************************************************/
static void attachToDefaultBridgeWhenInsideContainer(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return;
}
var docker = container.getDockerClient();
var networks = docker.inspectContainerCmd(container.getContainerId()).exec().getNetworkSettings().getNetworks();
if(networks == null || !networks.containsKey(DEFAULT_BRIDGE))
{
docker.connectToNetworkCmd().withContainerId(container.getContainerId()).withNetworkId(DEFAULT_BRIDGE).exec();
}
}
/***************************************************************************
** Whether this JVM runs in a container that the daemon reports on the default
** bridge. Decided once per JVM from the daemon's description of this container.
***************************************************************************/
private static boolean onBridgeWithBuildContainer()
{
Boolean known = buildContainerOnDefaultBridge;
if(known == null)
{
known = decideBuildContainerOnDefaultBridge();
buildContainerOnDefaultBridge = known;
}
return (known);
}
/***************************************************************************
** Inspect the build container; anything that cannot be established means the
** original published endpoint (false).
***************************************************************************/
private static boolean decideBuildContainerOnDefaultBridge()
{
if(!Files.exists(Path.of("/.dockerenv")))
{
return (false);
}
try
{
String id = ownContainerId(Files.readString(Path.of("/proc/self/mountinfo")), System.getenv("HOSTNAME"));
if(id == null)
{
return (false);
}
var networks = DockerClientFactory.instance().client().inspectContainerCmd(id).exec().getNetworkSettings().getNetworks();
return (networks != null && networks.containsKey(DEFAULT_BRIDGE));
}
catch(Exception e)
{
return (false);
}
}
/***************************************************************************
** The id of the container this JVM runs in: from the mount table when it names
** the container directory, otherwise the host name (the container id unless
** overridden); null when neither exists.
***************************************************************************/
static String ownContainerId(String mountInfo, String hostName)
{
Matcher matcher = MOUNTINFO_CONTAINER_ID.matcher(mountInfo == null ? "" : mountInfo);
if(matcher.find())
{
return (matcher.group(1));
}
return (hostName == null || hostName.isBlank() ? null : hostName.trim());
}
/***************************************************************************
** The container's address on the default bridge, or null when it has none.
***************************************************************************/
private static String bridgeIp(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return (null);
}
InspectContainerResponse inspected = container.getDockerClient().inspectContainerCmd(container.getContainerId()).exec();
var networks = inspected.getNetworkSettings().getNetworks();
return (networks == null || networks.get(DEFAULT_BRIDGE) == null ? null : networks.get(DEFAULT_BRIDGE).getIpAddress());
}
/***************************************************************************
** Testcontainers' published port for the Redis port, or null when the build
** container dials the bridge (so the published port is not consulted).
***************************************************************************/
private static Integer publishedPort(GenericContainer<?> container)
{
return (onBridgeWithBuildContainer() ? null : container.getMappedPort(6379));
}
/***************************************************************************
** Pure decision, separately tested. With the build container on the default
** bridge: the sibling's bridge address and its own port. Otherwise the published
** endpoint exactly as Testcontainers reports it.
***************************************************************************/
static Address choose(boolean buildContainerOnDefaultBridge, String bridgeIp, int internalPort, String publishedHost, Integer publishedPort)
{
if(buildContainerOnDefaultBridge)
{
if(bridgeIp == null || bridgeIp.isBlank())
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NO_BRIDGE_ADDRESS: the build container cannot reach a sibling without its bridge address");
}
return (new Address(bridgeIp, internalPort));
}
if(publishedHost == null || publishedHost.isBlank() || publishedPort == null)
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NOT_PUBLISHED: no published endpoint to dial");
}
return (new Address(publishedHost, publishedPort));
}
}
@@ -1,92 +0,0 @@
/*
* 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.sessionstore.providers;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*******************************************************************************
** The topology decision of the sibling-container fixtures.
*******************************************************************************/
class FixtureEndpointTest
{
private static final String CONTAINER_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/***************************************************************************
** Build container on the default bridge: dial the sibling bridge IP and its own port, never the published port.
***************************************************************************/
@Test
void buildContainerOnTheBridgeUsesTheBridgeAddress()
{
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, null, null));
}
/***************************************************************************
** Every other topology (JVM on the Docker host, remote daemon, job network, host networking, unidentified
** build container) keeps the original Testcontainers host and published port exactly, whatever the host is.
***************************************************************************/
@Test
void everyOtherTopologyKeepsTheOriginalPublishedEndpoint()
{
assertEquals(new FixtureEndpoint.Address("localhost", 41111), FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("docker.example.test", 32768), FixtureEndpoint.choose(false, null, 6379, "docker.example.test", 32768));
assertEquals(new FixtureEndpoint.Address("host.docker.internal", 40000), FixtureEndpoint.choose(false, null, 6379, "host.docker.internal", 40000));
}
/***************************************************************************
** An unknown endpoint is an error, never a guessed address.
***************************************************************************/
@Test
void missingEndpointFailsClosed()
{
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, null, 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, " ", 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", null));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, " ", 41111));
}
/***************************************************************************
** The build container id comes from the mount table when present, else the host name.
***************************************************************************/
@Test
void ownContainerIdPrefersTheMountTableThenTheHostName()
{
String mountInfo = "700 699 0:50 /docker/containers/" + CONTAINER_ID + "/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw";
assertEquals(CONTAINER_ID, FixtureEndpoint.ownContainerId(mountInfo, "unrelated"));
assertEquals("abc123", FixtureEndpoint.ownContainerId("no container mounts here", " abc123 "));
assertNull(FixtureEndpoint.ownContainerId("", null));
assertNull(FixtureEndpoint.ownContainerId(null, " "));
}
}
@@ -28,7 +28,6 @@ import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitContext;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreRuntimeService;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -61,8 +60,6 @@ class SharedRedisSessionStoreProviderIT
{
primary.start();
replica.start();
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary);
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(replica);
QInstance first = host(primary, "promotion", true);
QInstance second = host(replica, "promotion", false);
QInstance third = host(replica, "promotion", true);
@@ -122,7 +119,7 @@ class SharedRedisSessionStoreProviderIT
assertEquals("carol", c.loadAndTouch("new").orElseThrow().getValue("owner"));
var namespace = new RedisNamespace("test", "promotion", "authentication", "session-store");
var client = RedisRuntimeService.connection(second, "sessions").client();
assertTrue(client.pttl(namespace.key(SessionCodec.key("new"))) > 20000);
assertTrue(client.pttl(namespace.key("new")) > 20000);
c.remove("active");
assertTrue(b.load("active").isEmpty());
b.store("expires", new QSession(), Duration.ofMillis(200));
@@ -195,11 +192,10 @@ class SharedRedisSessionStoreProviderIT
var client = RedisRuntimeService.connection(first, "sessions").client();
a.store("sliding", new QSession(), Duration.ofSeconds(1));
assertTrue(a.loadAndTouch("sliding").isPresent());
assertTrue(client.pttl(scope.key(SessionCodec.key("sliding"))) > 20000);
assertEquals(-2L, client.pttl(scope.key("sliding")), "the raw session id must never be a Redis key");
assertTrue(client.pttl(scope.key("sliding")) > 20000);
a.store("fixed", new QSession(), Duration.ofMillis(200));
assertTrue(b.loadAndTouch("fixed").isPresent());
assertTrue(client.pttl(scope.key(SessionCodec.key("fixed"))) <= 200);
assertTrue(client.pttl(scope.key("fixed")) <= 200);
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
{
@@ -211,7 +207,7 @@ class SharedRedisSessionStoreProviderIT
assertThrows(IllegalArgumentException.class,
() -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
RedisAccess.cache(first, "sessions", scope, 65536)
.put(SessionCodec.key("broken"), "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
.put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
var malformed = assertThrows(IllegalStateException.class, () -> a.load("broken"));
assertFalse(malformed.toString().contains("test-secret"));
assertNull(malformed.getCause());
@@ -537,7 +533,7 @@ class SharedRedisSessionStoreProviderIT
instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("record").withBackendName("memory").withPrimaryKeyField("id")
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER)));
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("sessions",
new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379))
.withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
.produce(instance);
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
@@ -1,491 +0,0 @@
/*
* Copyright 2024 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
*
* http://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.sessionstore.providers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderFactory;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.StoredSessionSchema;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
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.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The TABLE_BASED provider on a real PostgreSQL 17, with the production
** separation of identities: a schema OWNER applies the DDL once, and the
** provider runs as a DML-only role that owns nothing and cannot create or alter
** anything. Two application instances (separate QInstances and providers)
** share the one database, as replicas of the host would.
*******************************************************************************/
class TableBasedSessionStorePostgresIT
{
private static final String OWNER = "qbit_owner";
private static final String RUNTIME = "qbit_runtime";
private static final String BACKEND = "sessionsPg";
private static final String TABLE = "qbitSessionStoreEntry";
private static GenericContainer<?> postgres;
private static String host;
private static int port;
private QSessionStoreQBitConfig config;
private QInstance instanceA;
private QInstance instanceB;
private QSessionStoreProviderInterface providerA;
private QSessionStoreProviderInterface providerB;
/***************************************************************************
** One PG17, owner-created schema, DML-only runtime role.
***************************************************************************/
@BeforeAll
static void startDatabase() throws Exception
{
postgres = new GenericContainer<>("postgres:17-alpine")
.withEnv("POSTGRES_PASSWORD", "super-synthetic")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(Duration.ofMinutes(2)));
postgres.start();
host = postgres.getHost();
port = postgres.getMappedPort(5432);
try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement())
{
statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'");
statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'");
statement.execute("CREATE DATABASE sessions OWNER " + OWNER);
statement.execute("REVOKE ALL ON DATABASE sessions FROM PUBLIC");
statement.execute("GRANT CONNECT ON DATABASE sessions TO " + RUNTIME);
}
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC");
statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME);
String ddl = StoredSessionSchema.postgresDdl(configFor());
statement.execute(ddl);
statement.execute(ddl); // idempotent
String table = StoredSessionSchema.physicalTableName(configFor());
statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON " + StoredSessionSchema.quote(table) + " TO " + RUNTIME);
}
}
/***************************************************************************
** Stop the database.
***************************************************************************/
@AfterAll
static void stopDatabase()
{
if(postgres != null)
{
postgres.stop();
}
}
/***************************************************************************
** Fresh table content and two runtime instances per test.
***************************************************************************/
@BeforeEach
void setUp() throws Exception
{
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("DELETE FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(configFor())));
}
config = configFor();
instanceA = instance();
instanceB = instance();
QContext.init(instanceA, new QSession());
providerA = QSessionStoreProviderFactory.createProvider(instanceA, config);
QContext.init(instanceB, new QSession());
providerB = QSessionStoreProviderFactory.createProvider(instanceB, config);
}
/***************************************************************************
** Clear the context.
***************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/***************************************************************************
** Store and load as the DML-only role; a second instance sees it, and a
** removal on either instance revokes it on both.
***************************************************************************/
@Test
void testSharedAcrossInstancesAndRevocation()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceB, () -> providerB.load(id)).orElseThrow();
assertThat(seen.getUuid()).isEqualTo(id);
assertThat(seen.getUser().getIdReference()).isEqualTo("alice");
assertThat(seen.getValue("tenant")).isEqualTo("tenant-a");
assertThat(seen.getPermissions()).containsExactly("orders.read");
run(instanceB, () -> providerB.remove(id));
assertThat(on(instanceA, () -> providerA.load(id))).isEmpty();
assertThat(on(instanceA, () -> providerA.loadAndTouch(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** Re-storing a session replaces what was cached: narrower permissions or a
** different tenant never survive as a stale copy; one row remains.
***************************************************************************/
@Test
void testRestoreReplacesStaleCopy()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.write", "orders.read"), Duration.ofMinutes(10)));
run(instanceB, () -> providerB.store(id, session(id, "alice", "tenant-b", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceA, () -> providerA.load(id)).orElseThrow();
assertThat(seen.getValue("tenant")).isEqualTo("tenant-b");
assertThat(seen.getPermissions()).containsExactly("orders.read");
assertThat(rows()).hasSize(1);
}
/***************************************************************************
** Sessions of different actors and tenants stay separate, and a session id
** that was never stored loads nothing.
***************************************************************************/
@Test
void testActorAndTenantSeparation()
{
String idAlice = UUID.randomUUID().toString();
String idBob = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(idAlice, session(idAlice, "alice", "tenant-a", "a.read"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(idBob, session(idBob, "bob", "tenant-b", "b.read"), Duration.ofMinutes(10)));
QSession alice = on(instanceB, () -> providerB.load(idAlice)).orElseThrow();
QSession bob = on(instanceB, () -> providerB.load(idBob)).orElseThrow();
assertThat(alice.getUser().getIdReference()).isEqualTo("alice");
assertThat(alice.getValue("tenant")).isEqualTo("tenant-a");
assertThat(bob.getUser().getIdReference()).isEqualTo("bob");
assertThat(bob.getValue("tenant")).isEqualTo("tenant-b");
assertThat(on(instanceB, () -> providerB.load(UUID.randomUUID().toString()))).isEmpty();
assertThat(on(instanceB, () -> providerB.load(idAlice.toUpperCase()))).isEmpty();
}
/***************************************************************************
** The database holds no session id and no id-bearing JSON: only digests.
***************************************************************************/
@Test
void testDatabaseHoldsNoSessionId() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
List<String> columns = StoredSessionSchema.physicalColumnNames(config);
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT * FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
assertThat(rs.next()).isTrue();
for(String column : columns)
{
String value = rs.getString(column);
assertThat(value == null ? "" : value).as(column).doesNotContain(id);
}
assertThat(rs.getString("sessionUuid")).isEqualTo(SessionCodec.key(id));
}
}
/***************************************************************************
** An expired row is a miss and is removed; cleanup deletes only expired rows.
***************************************************************************/
@Test
void testExpiryAndCleanup() throws Exception
{
String live = UUID.randomUUID().toString();
String expired = UUID.randomUUID().toString();
String lazy = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(live, session(live, "alice", "t", "p"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(expired, session(expired, "bob", "t", "p"), Duration.ofMillis(1)));
run(instanceA, () -> providerA.store(lazy, session(lazy, "carol", "t", "p"), Duration.ofMillis(1)));
Thread.sleep(50);
assertThat(on(instanceB, () -> providerB.load(lazy))).isEmpty();
assertThat(rows()).hasSize(2);
run(instanceB, providerB::cleanExpired);
assertThat(rows()).hasSize(1);
assertThat(on(instanceA, () -> providerA.load(live))).isPresent();
assertThat(on(instanceA, () -> providerA.load(expired))).isEmpty();
}
/***************************************************************************
** A row whose identity column no longer matches its envelope is discarded
** rather than served.
***************************************************************************/
@Test
void testTamperedRowIsDiscarded() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("UPDATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " SET \"userId\" = 'mallory'");
}
assertThat(on(instanceB, () -> providerB.load(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** The runtime identity can do DML only: no DDL, no other relation, and the
** schema it was given is not its own.
***************************************************************************/
@Test
void testRuntimeRoleHasNoDdlOrOwnership() throws Exception
{
try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "sessions"); Statement statement = runtime.createStatement())
{
assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied");
assertThatThrownBy(() -> statement.execute("ALTER TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " ADD COLUMN x int")).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("DROP TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("TRUNCATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
try(ResultSet rs = statement.executeQuery("SELECT count(*) FROM pg_class c JOIN pg_roles r ON r.oid = c.relowner WHERE r.rolname = '" + RUNTIME + "'"))
{
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
}
/***************************************************************************
** The table is internal: hidden, and a signed-in user session, however
** privileged elsewhere, cannot query it through the core actions.
***************************************************************************/
@Test
void testTableIsInternal() throws Exception
{
QSessionStoreQBitConfig produced = configFor();
QInstance instance = baseInstance();
new QSessionStoreQBitProducer().withConfig(produced).produce(instance, "");
new QInstanceValidator().validate(instance);
assertThat(instance.getTable(TABLE).getIsHidden()).isTrue();
QSession user = new QSession();
user.setUser(new QUser().withIdReference("alice"));
user.setPermissions(Set.of("everything.read"));
QContext.init(instance, user);
assertThatThrownBy(() ->
{
QueryInput input = new QueryInput();
input.setTableName(TABLE);
new QueryAction().execute(input);
}).isInstanceOf(QException.class);
}
/***************************************************************************
** DDL is derived from the producer's metadata, idempotent, and names every
** field.
***************************************************************************/
@Test
void testDdlMatchesMetadata()
{
String ddl = StoredSessionSchema.postgresDdl(configFor());
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS \"qbitSessionStoreEntry\"").contains("\"sessionUuid\" VARCHAR(64) NOT NULL")
.contains("GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY").contains("CREATE INDEX IF NOT EXISTS");
assertThat(StoredSessionSchema.physicalColumnNames(configFor())).containsExactlyInAnyOrder("id", "sessionUuid", "userId", "sessionData", "expiresAt", "createDate", "modifyDate");
String snake = StoredSessionSchema.postgresDdl(configFor(), com.kingsrook.qqq.backend.core.instances.QInstanceEnricher::inferBackendName);
assertThat(snake).contains("CREATE TABLE IF NOT EXISTS \"qbit_session_store_entry\"").contains("\"session_uuid\" VARCHAR(64) NOT NULL").contains("\"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL");
}
/***************************************************************************
** Run something with the given instance as the QQQ context, as one
** application replica would.
***************************************************************************/
private static <T> T on(QInstance instance, java.util.function.Supplier<T> action)
{
QContext.init(instance, new QSession());
return action.get();
}
/***************************************************************************
** Void variant.
***************************************************************************/
private static void run(QInstance instance, Runnable action)
{
QContext.init(instance, new QSession());
action.run();
}
/***************************************************************************
** The row count, read as the owner.
***************************************************************************/
private List<String> rows()
{
List<String> keys = new ArrayList<>();
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT \"sessionUuid\" FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
while(rs.next())
{
keys.add(rs.getString(1));
}
}
catch(SQLException e)
{
throw new IllegalStateException(e);
}
return keys;
}
/***************************************************************************
** A synthetic session.
***************************************************************************/
private static QSession session(String id, String user, String tenant, String... permissions)
{
QSession session = new QSession();
session.setUuid(id);
session.setIdReference(id);
session.setUser(new QUser().withIdReference(user));
session.setPermissions(Set.of(permissions));
session.setValue("tenant", tenant);
return session;
}
/***************************************************************************
** The provider configuration.
***************************************************************************/
private static QSessionStoreQBitConfig configFor()
{
return new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.TABLE_BASED)
.withBackendName(BACKEND)
.withTableName(TABLE)
.withDefaultTtl(Duration.ofMinutes(30))
.withEnableCleanupProcess(false);
}
/***************************************************************************
** A runtime application instance on the DML-only role, enriched and
** validated the way a started server is.
***************************************************************************/
private static QInstance instance() throws QException
{
QInstance instance = baseInstance();
com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer.produce(instance, configFor());
new QInstanceValidator().validate(instance);
return instance;
}
/***************************************************************************
** The application without the qbit: enforcement on, native PG backend as the
** DML-only role.
***************************************************************************/
private static QInstance baseInstance()
{
QInstance instance = new QInstance();
instance.setEnforcePermissionsInCoreActions(true);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData();
backend.setName(BACKEND);
backend.setHostName(host);
backend.setPort(port);
backend.setDatabaseName("sessions");
backend.setUsername(RUNTIME);
backend.setPassword("runtime-synthetic");
instance.addBackend(backend);
return instance;
}
/***************************************************************************
** A JDBC connection as a named role.
***************************************************************************/
private static Connection connect(String user, String password, String database) throws SQLException
{
return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password);
}
}
@@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT
conn.createStatement().execute("""
CREATE TABLE `storedSession` (
`id` INTEGER AUTO_INCREMENT PRIMARY KEY,
`sessionUuid` VARCHAR(64) NOT NULL UNIQUE,
`sessionUuid` VARCHAR(36) NOT NULL UNIQUE,
`userId` VARCHAR(255),
`sessionData` TEXT,
`expiresAt` TIMESTAMP NOT NULL,