Compare commits
7
Commits
v0.1.0-RC.2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fe921e0c | ||
|
|
39448b58f9
|
||
|
|
094a759849
|
||
|
|
b85b9555bf
|
||
|
|
375cf053a1
|
||
|
|
54e780e9f9
|
||
|
|
22a8ae0cf5 |
No files matched your search
@@ -18,23 +18,24 @@
|
||||
<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.1.0-SNAPSHOT</revision>
|
||||
<revision>0.3.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.22</qqq.version>
|
||||
<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.1.0-RC.1</qbit-redis.version>
|
||||
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>1.4.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -125,6 +126,11 @@
|
||||
<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>
|
||||
@@ -192,6 +198,9 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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" : "");
|
||||
}
|
||||
|
||||
}
|
||||
+14
-2
@@ -22,12 +22,19 @@ 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
|
||||
{
|
||||
@@ -42,6 +49,11 @@ 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")
|
||||
@@ -49,10 +61,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 UUID")
|
||||
.withLabel("Session Key (digest)")
|
||||
.withIsRequired(true)
|
||||
.withIsEditable(false)
|
||||
.withMaxLength(36))
|
||||
.withMaxLength(64))
|
||||
.withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING)
|
||||
.withLabel("User ID")
|
||||
.withIsEditable(false))
|
||||
|
||||
+28
-9
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -57,6 +58,24 @@ 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.
|
||||
***************************************************************************/
|
||||
@@ -99,8 +118,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
{
|
||||
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
|
||||
Instant expiresAt = Instant.now().plus(effectiveTtl);
|
||||
cache.put(sessionUuid, new CachedSession(session, expiresAt));
|
||||
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
|
||||
cache.put(sessionUuid, new CachedSession(copy(session), expiresAt));
|
||||
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
|
||||
}
|
||||
|
||||
|
||||
@@ -120,11 +139,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
if(cached.isExpired())
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
|
||||
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(cached.session());
|
||||
return Optional.of(copy(cached.session()));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +155,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
public void remove(String sessionUuid)
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
|
||||
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +171,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("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +203,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
if(cached.isExpired())
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
|
||||
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -193,9 +212,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("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Loaded and touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
|
||||
return Optional.of(cached.session());
|
||||
return Optional.of(copy(cached.session()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-14
@@ -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 + sessionUuid;
|
||||
return keyPrefix + SessionCodec.key(sessionUuid);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
{
|
||||
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
|
||||
String key = buildKey(sessionUuid);
|
||||
String sessionJson = JsonUtils.toJson(session);
|
||||
String sessionJson = SessionCodec.encode(sessionUuid, session);
|
||||
|
||||
jedis.setex(key, effectiveTtl.toSeconds(), sessionJson);
|
||||
LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds()));
|
||||
LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
|
||||
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
|
||||
return Optional.of(session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to load session from Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(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("sessionUuid", sessionUuid));
|
||||
LOG.debug("Removed session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(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("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
LOG.debug("Touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
|
||||
LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
|
||||
LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
return Optional.of(session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -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(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
|
||||
cache().put(SessionCodec.key(id), SessionCodec.encode(id, 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(cache().get(id));
|
||||
return decode(id, cache().get(SessionCodec.key(id)));
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
|
||||
@Override
|
||||
public void remove(String id)
|
||||
{
|
||||
cache().remove(id);
|
||||
cache().remove(SessionCodec.key(id));
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
|
||||
@Override
|
||||
public void touch(String id)
|
||||
{
|
||||
cache().touch(id, defaultTtl);
|
||||
cache().touch(SessionCodec.key(id), defaultTtl);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,8 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
|
||||
@Override
|
||||
public Optional<QSession> loadAndTouch(String id)
|
||||
{
|
||||
return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id));
|
||||
String key = SessionCodec.key(id);
|
||||
return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key));
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +168,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
|
||||
/***************************************************************************
|
||||
** Bound decoded payloads and never include session contents in error messages.
|
||||
***************************************************************************/
|
||||
private Optional<QSession> decode(Optional<byte[]> value)
|
||||
private Optional<QSession> decode(String id, Optional<byte[]> value)
|
||||
{
|
||||
return value.map(bytes ->
|
||||
{
|
||||
@@ -177,7 +178,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
|
||||
}
|
||||
try
|
||||
{
|
||||
return Objects.requireNonNull(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class));
|
||||
return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
|
||||
+192
-153
@@ -16,23 +16,26 @@
|
||||
package com.kingsrook.qbits.sessionstore.providers;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.io.IOException;
|
||||
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;
|
||||
@@ -40,19 +43,22 @@ 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
|
||||
{
|
||||
@@ -61,6 +67,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
private QSessionStoreQBitConfig config;
|
||||
private String tableName;
|
||||
private Duration defaultTtl;
|
||||
private boolean sliding;
|
||||
|
||||
|
||||
|
||||
@@ -73,85 +80,196 @@ 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.
|
||||
** Store a session with the given TTL, replacing any existing copy.
|
||||
***************************************************************************/
|
||||
@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 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);
|
||||
Instant expiresAt = Instant.now().plus(effectiveTtl);
|
||||
String stored = SessionCodec.encode(sessionUuid, session);
|
||||
String userId = session.getUser() != null ? session.getUser().getIdReference() : null;
|
||||
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName(tableName);
|
||||
insertInput.setRecords(List.of(record));
|
||||
new InsertAction().execute(insertInput);
|
||||
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);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
|
||||
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
remove(sessionUuid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Load a session by UUID.
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
@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
|
||||
{
|
||||
QRecord record = runWithSystemSession(() ->
|
||||
return runWithSystemSession(() ->
|
||||
{
|
||||
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();
|
||||
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);
|
||||
});
|
||||
|
||||
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("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to load session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -159,7 +277,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Remove a session by UUID.
|
||||
** Remove a session by id.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void remove(String sessionUuid)
|
||||
@@ -168,26 +286,35 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
{
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
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);
|
||||
deleteByKey(SessionCodec.key(sessionUuid));
|
||||
return null;
|
||||
});
|
||||
|
||||
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
|
||||
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Touch a session to reset its TTL.
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void touch(String sessionUuid)
|
||||
@@ -198,33 +325,24 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// 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 record = get(SessionCodec.key(sessionUuid));
|
||||
if(record != 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(updateRecord));
|
||||
updateInput.setRecords(List.of(new QRecord()
|
||||
.withValue("id", record.getValue("id"))
|
||||
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt)));
|
||||
new UpdateAction().execute(updateInput);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e);
|
||||
LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,83 +359,6 @@ 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.
|
||||
***************************************************************************/
|
||||
@@ -356,11 +397,9 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
{
|
||||
long count = runWithSystemSession(() ->
|
||||
{
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(tableName);
|
||||
queryInput.setFilter(new QQueryFilter());
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
return (long) queryOutput.getRecords().size();
|
||||
CountInput countInput = new CountInput();
|
||||
countInput.setTableName(tableName);
|
||||
return new CountAction().execute(countInput).getCount().longValue();
|
||||
});
|
||||
return "TableBased: " + count + " sessions in " + tableName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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, " "));
|
||||
}
|
||||
|
||||
}
|
||||
+115
-4
@@ -28,6 +28,7 @@ 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;
|
||||
@@ -45,6 +46,115 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*******************************************************************************/
|
||||
class SharedRedisSessionStoreProviderIT
|
||||
{
|
||||
/***************************************************************************
|
||||
** Observed replicated sessions and revocations survive controlled promotion.
|
||||
** This does not claim survival of writes lost before asynchronous replication.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void replicatedSessionsAndRevocationsSurviveControlledPromotion() throws Exception
|
||||
{
|
||||
QInstance original = QContext.getQInstance();
|
||||
try(var network = org.testcontainers.containers.Network.newNetwork();
|
||||
var primary = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withNetworkAliases("primary").withExposedPorts(6379);
|
||||
var replica = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withExposedPorts(6379)
|
||||
.withCommand("redis-server", "--replicaof", "primary", "6379"))
|
||||
{
|
||||
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);
|
||||
var firstRuntime = new RedisRuntimeService();
|
||||
var secondRuntime = new RedisRuntimeService();
|
||||
var thirdRuntime = new RedisRuntimeService();
|
||||
try
|
||||
{
|
||||
firstRuntime.start(first);
|
||||
secondRuntime.start(second);
|
||||
thirdRuntime.start(third);
|
||||
var a = QSessionStoreQBitContext.getProvider(first);
|
||||
var b = QSessionStoreQBitContext.getProvider(second);
|
||||
var c = QSessionStoreQBitContext.getProvider(third);
|
||||
a.store("active", new QSession().withValue("owner", "alice"), Duration.ofSeconds(60));
|
||||
a.store("revoked", new QSession().withValue("owner", "bob"), Duration.ofSeconds(60));
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(15), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(b.load("revoked").isPresent())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch(redis.clients.jedis.exceptions.JedisConnectionException initialSync)
|
||||
{
|
||||
// Initial replica loading can temporarily block reads; the outer deadline stays bounded.
|
||||
}
|
||||
Thread.sleep(25);
|
||||
}
|
||||
});
|
||||
assertThrows(RuntimeException.class, () -> b.store("replica-write", new QSession(), null));
|
||||
a.remove("revoked");
|
||||
// Seeing a later write proves this test's deletion reached the replica.
|
||||
a.store("barrier", new QSession(), Duration.ofSeconds(60));
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(b.load("barrier").isEmpty())
|
||||
{
|
||||
Thread.sleep(25);
|
||||
}
|
||||
});
|
||||
assertTrue(b.load("revoked").isEmpty());
|
||||
primary.stop();
|
||||
assertThrows(RuntimeException.class, () -> a.load("active"));
|
||||
var promoted = replica.execInContainer("redis-cli", "REPLICAOF", "NO", "ONE");
|
||||
assertEquals(0, promoted.getExitCode());
|
||||
assertEquals("OK", promoted.getStdout().trim());
|
||||
assertEquals("alice", b.load("active").orElseThrow().getValue("owner"));
|
||||
assertTrue(c.loadAndTouch("revoked").isEmpty());
|
||||
c.touch("revoked");
|
||||
assertTrue(b.load("revoked").isEmpty());
|
||||
b.store("new", new QSession().withValue("owner", "carol"), Duration.ofSeconds(1));
|
||||
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);
|
||||
c.remove("active");
|
||||
assertTrue(b.load("active").isEmpty());
|
||||
b.store("expires", new QSession(), Duration.ofMillis(200));
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
|
||||
{
|
||||
while(b.load("expires").isPresent())
|
||||
{
|
||||
Thread.sleep(25);
|
||||
}
|
||||
});
|
||||
c.touch("expires");
|
||||
assertTrue(c.loadAndTouch("expires").isEmpty());
|
||||
}
|
||||
finally
|
||||
{
|
||||
for(var runtime : List.of(firstRuntime, secondRuntime, thirdRuntime))
|
||||
{
|
||||
runtime.stop();
|
||||
runtime.afterApplicationStop();
|
||||
}
|
||||
for(var instance : List.of(first, second, third))
|
||||
{
|
||||
QSessionStoreQBitContext.clear(instance);
|
||||
QSessionStoreRegistry.getInstance(instance).clear();
|
||||
}
|
||||
QContext.setQInstance(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Separate nodes share one app's sessions while other apps remain isolated.
|
||||
***************************************************************************/
|
||||
@@ -85,10 +195,11 @@ 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("sliding")) > 20000);
|
||||
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");
|
||||
a.store("fixed", new QSession(), Duration.ofMillis(200));
|
||||
assertTrue(b.loadAndTouch("fixed").isPresent());
|
||||
assertTrue(client.pttl(scope.key("fixed")) <= 200);
|
||||
assertTrue(client.pttl(scope.key(SessionCodec.key("fixed"))) <= 200);
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
|
||||
{
|
||||
@@ -100,7 +211,7 @@ class SharedRedisSessionStoreProviderIT
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
|
||||
RedisAccess.cache(first, "sessions", scope, 65536)
|
||||
.put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
|
||||
.put(SessionCodec.key("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());
|
||||
@@ -426,7 +537,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(redis.getHost()).withPort(redis.getMappedPort(6379))
|
||||
new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
|
||||
.withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
|
||||
.produce(instance);
|
||||
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
|
||||
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT
|
||||
conn.createStatement().execute("""
|
||||
CREATE TABLE `storedSession` (
|
||||
`id` INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||
`sessionUuid` VARCHAR(36) NOT NULL UNIQUE,
|
||||
`sessionUuid` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`userId` VARCHAR(255),
|
||||
`sessionData` TEXT,
|
||||
`expiresAt` TIMESTAMP NOT NULL,
|
||||
|
||||
Reference in new issue
Block a user