Implement read-only check for queryable stores

Squash commits of branch feature/write_protected_queryable_store:

- Bootstrap read-only check for queryable stores

- Use class name instead of static string

- Fix build breaker

- Log change

- Add unit tests

- Clean up

- Merge branch 'develop' into feature/write_protected_queryable_store
This commit is contained in:
Rene Pfeuffer
2025-06-19 16:19:45 +02:00
parent 4b49748561
commit 7c9aa016d8
7 changed files with 172 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Write lock check for queryable stores

View File

@@ -55,8 +55,9 @@ class SQLiteQueryableMutableStore<T> extends SQLiteQueryableStore<T> implements
Class<T> clazz, Class<T> clazz,
QueryableTypeDescriptor queryableTypeDescriptor, QueryableTypeDescriptor queryableTypeDescriptor,
String[] parentIds, String[] parentIds,
ReadWriteLock lock) { ReadWriteLock lock,
super(objectMapper, connection, clazz, queryableTypeDescriptor, parentIds, lock); boolean readOnly) {
super(objectMapper, connection, clazz, queryableTypeDescriptor, parentIds, lock, readOnly);
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.clazz = clazz; this.clazz = clazz;
this.parentIds = parentIds; this.parentIds = parentIds;

View File

@@ -30,6 +30,7 @@ import sonia.scm.store.LogicalCondition;
import sonia.scm.store.QueryableMaintenanceStore; import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableStore; import sonia.scm.store.QueryableStore;
import sonia.scm.store.StoreException; import sonia.scm.store.StoreException;
import sonia.scm.store.StoreReadOnlyException;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@@ -71,19 +72,22 @@ class SQLiteQueryableStore<T> implements QueryableStore<T>, QueryableMaintenance
private final String[] parentIds; private final String[] parentIds;
private final ReadWriteLock lock; private final ReadWriteLock lock;
private final boolean readOnly;
public SQLiteQueryableStore(ObjectMapper objectMapper, public SQLiteQueryableStore(ObjectMapper objectMapper,
Connection connection, Connection connection,
Class<T> clazz, Class<T> clazz,
QueryableTypeDescriptor queryableTypeDescriptor, QueryableTypeDescriptor queryableTypeDescriptor,
String[] parentIds, String[] parentIds,
ReadWriteLock lock) { ReadWriteLock lock,
boolean readOnly) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.connection = connection; this.connection = connection;
this.clazz = clazz; this.clazz = clazz;
this.parentIds = parentIds; this.parentIds = parentIds;
this.queryableTypeDescriptor = queryableTypeDescriptor; this.queryableTypeDescriptor = queryableTypeDescriptor;
this.lock = lock; this.lock = lock;
this.readOnly = readOnly;
} }
@Override @Override
@@ -243,11 +247,18 @@ class SQLiteQueryableStore<T> implements QueryableStore<T>, QueryableMaintenance
} }
<R> R executeWrite(SQLNodeWithValue sqlStatement, StatementCallback<R> callback) { <R> R executeWrite(SQLNodeWithValue sqlStatement, StatementCallback<R> callback) {
assertNotReadOnly();
String sql = sqlStatement.toSQL(); String sql = sqlStatement.toSQL();
log.debug("executing 'write' SQL: {}", sql); log.debug("executing 'write' SQL: {}", sql);
return executeWithLock(sqlStatement, callback, lock.writeLock(), sql); return executeWithLock(sqlStatement, callback, lock.writeLock(), sql);
} }
private void assertNotReadOnly() {
if (readOnly) {
throw new StoreReadOnlyException(clazz.getName());
}
}
private <R> R executeWithLock(SQLNodeWithValue sqlStatement, StatementCallback<R> callback, Lock writeLock, String sql) { private <R> R executeWithLock(SQLNodeWithValue sqlStatement, StatementCallback<R> callback, Lock writeLock, String sql) {
writeLock.lock(); writeLock.lock();
try (PreparedStatement statement = connection.prepareStatement(sql, RETURN_GENERATED_KEYS)) { try (PreparedStatement statement = connection.prepareStatement(sql, RETURN_GENERATED_KEYS)) {

View File

@@ -28,6 +28,8 @@ import sonia.scm.SCMContextProvider;
import sonia.scm.config.ConfigValue; import sonia.scm.config.ConfigValue;
import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.QueryableTypeDescriptor; import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator; import sonia.scm.security.KeyGenerator;
import sonia.scm.store.QueryableMaintenanceStore; import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableStoreFactory; import sonia.scm.store.QueryableStoreFactory;
@@ -59,6 +61,7 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final KeyGenerator keyGenerator; private final KeyGenerator keyGenerator;
private final DataSource dataSource; private final DataSource dataSource;
private final RepositoryReadOnlyChecker readOnlyChecker;
private final Map<String, QueryableTypeDescriptor> queryableTypes = new HashMap<>(); private final Map<String, QueryableTypeDescriptor> queryableTypes = new HashMap<>();
@@ -69,6 +72,7 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
PluginLoader pluginLoader, PluginLoader pluginLoader,
ObjectMapper objectMapper, ObjectMapper objectMapper,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
RepositoryReadOnlyChecker readOnlyChecker,
@ConfigValue(key = "queryableStore.maxPoolSize", defaultValue = DEFAULT_MAX_POOL_SIZE) int maxPoolSize, @ConfigValue(key = "queryableStore.maxPoolSize", defaultValue = DEFAULT_MAX_POOL_SIZE) int maxPoolSize,
@ConfigValue(key = "queryableStore.connectionTimeout", defaultValue = DEFAULT_CONNECTION_TIMEOUT_IN_SECONDS) int connectionTimeoutInSeconds, @ConfigValue(key = "queryableStore.connectionTimeout", defaultValue = DEFAULT_CONNECTION_TIMEOUT_IN_SECONDS) int connectionTimeoutInSeconds,
@ConfigValue(key = "queryableStore.idleTimeout", defaultValue = DEFAULT_IDLE_TIMEOUT_IN_SECONDS) int idleTimeoutInSeconds, @ConfigValue(key = "queryableStore.idleTimeout", defaultValue = DEFAULT_IDLE_TIMEOUT_IN_SECONDS) int idleTimeoutInSeconds,
@@ -80,6 +84,7 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
objectMapper, objectMapper,
keyGenerator, keyGenerator,
pluginLoader.getExtensionProcessor().getQueryableTypes(), pluginLoader.getExtensionProcessor().getQueryableTypes(),
readOnlyChecker,
maxPoolSize, maxPoolSize,
connectionTimeoutInSeconds, connectionTimeoutInSeconds,
idleTimeoutInSeconds, idleTimeoutInSeconds,
@@ -92,14 +97,16 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
public SQLiteQueryableStoreFactory(String connectionString, public SQLiteQueryableStoreFactory(String connectionString,
ObjectMapper objectMapper, ObjectMapper objectMapper,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
Iterable<QueryableTypeDescriptor> queryableTypeIterable) { Iterable<QueryableTypeDescriptor> queryableTypeIterable,
this(connectionString, objectMapper, keyGenerator, queryableTypeIterable, 10, 30, 600, 1800, 30); RepositoryReadOnlyChecker readOnlyChecker) {
this(connectionString, objectMapper, keyGenerator, queryableTypeIterable, readOnlyChecker, 10, 30, 600, 1800, 30);
} }
private SQLiteQueryableStoreFactory(String connectionString, private SQLiteQueryableStoreFactory(String connectionString,
ObjectMapper objectMapper, ObjectMapper objectMapper,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
Iterable<QueryableTypeDescriptor> queryableTypeIterable, Iterable<QueryableTypeDescriptor> queryableTypeIterable,
RepositoryReadOnlyChecker readOnlyChecker,
int maxPoolSize, int maxPoolSize,
int connectionTimeoutInSeconds, int connectionTimeoutInSeconds,
int idleTimeoutInSeconds, int idleTimeoutInSeconds,
@@ -112,6 +119,7 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
idleTimeoutInSeconds, idleTimeoutInSeconds,
maxLifetimeInSeconds, maxLifetimeInSeconds,
leakDetectionThresholdInSeconds); leakDetectionThresholdInSeconds);
this.readOnlyChecker = readOnlyChecker;
this.dataSource = new HikariDataSource(config); this.dataSource = new HikariDataSource(config);
this.objectMapper = objectMapper this.objectMapper = objectMapper
@@ -170,17 +178,57 @@ public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
@Override @Override
public <T> SQLiteQueryableStore<T> getReadOnly(Class<T> clazz, String... parentIds) { public <T> SQLiteQueryableStore<T> getReadOnly(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableStore<>(objectMapper, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock); QueryableTypeDescriptor queryableTypeDescriptor = getQueryableTypeDescriptor(clazz);
return new SQLiteQueryableStore<>(
objectMapper,
openDefaultConnection(),
clazz,
queryableTypeDescriptor,
parentIds,
lock,
mustBeReadOnly(queryableTypeDescriptor, parentIds)
);
} }
@Override @Override
public <T> QueryableMaintenanceStore<T> getForMaintenance(Class<T> clazz, String... parentIds) { public <T> QueryableMaintenanceStore<T> getForMaintenance(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableStore<>(objectMapper, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock); QueryableTypeDescriptor queryableTypeDescriptor = getQueryableTypeDescriptor(clazz);
return new SQLiteQueryableStore<>(
objectMapper,
openDefaultConnection(),
clazz,
queryableTypeDescriptor,
parentIds,
lock,
mustBeReadOnly(queryableTypeDescriptor, parentIds)
);
} }
@Override @Override
public <T> SQLiteQueryableMutableStore<T> getMutable(Class<T> clazz, String... parentIds) { public <T> SQLiteQueryableMutableStore<T> getMutable(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableMutableStore<>(objectMapper, keyGenerator, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock); QueryableTypeDescriptor queryableTypeDescriptor = getQueryableTypeDescriptor(clazz);
return new SQLiteQueryableMutableStore<>(
objectMapper,
keyGenerator,
openDefaultConnection(),
clazz,
queryableTypeDescriptor,
parentIds,
lock,
mustBeReadOnly(queryableTypeDescriptor, parentIds)
);
}
private boolean mustBeReadOnly(QueryableTypeDescriptor queryableTypeDescriptor, String... parentIds) {
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
if (queryableTypeDescriptor.getTypes()[i].startsWith(Repository.class.getName()) && parentIds.length > i) {
String repositoryId = parentIds[i];
if (repositoryId != null && readOnlyChecker.isReadOnly(repositoryId)) {
return true;
}
}
}
return false;
} }
private <T> QueryableTypeDescriptor getQueryableTypeDescriptor(Class<T> clazz) { private <T> QueryableTypeDescriptor getQueryableTypeDescriptor(Class<T> clazz) {

View File

@@ -21,9 +21,12 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import sonia.scm.group.Group;
import sonia.scm.repository.Repository;
import sonia.scm.store.IdGenerator; import sonia.scm.store.IdGenerator;
import sonia.scm.store.QueryableMutableStore; import sonia.scm.store.QueryableMutableStore;
import sonia.scm.store.QueryableStore; import sonia.scm.store.QueryableStore;
import sonia.scm.store.StoreReadOnlyException;
import sonia.scm.user.User; import sonia.scm.user.User;
import java.nio.file.Path; import java.nio.file.Path;
@@ -36,6 +39,7 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SuppressWarnings("resource") @SuppressWarnings("resource")
class SQLiteQueryableMutableStoreTest { class SQLiteQueryableMutableStoreTest {
@@ -510,6 +514,75 @@ class SQLiteQueryableMutableStoreTest {
assertThat(crewmateStoreForShipTwo.getAll()).hasSize(2); assertThat(crewmateStoreForShipTwo.getAll()).hasSize(2);
} }
} }
}
@Test
void shouldThrowExceptionWhenReadOnlyStoreIsModified() {
StoreTestBuilder storeTestBuilder = new StoreTestBuilder(
connectionString,
Repository.class.getName() + ".class"
).readOnly("42");
assertThrows(StoreReadOnlyException.class, () ->
storeTestBuilder
.withIds("42")
.put("tricia", new User("trillian"))
);
}
@Test
void shouldThrowExceptionWhenStoreForReadOnlyRepositoryAsFirstParentIsModified() {
StoreTestBuilder storeTestBuilder = new StoreTestBuilder(
connectionString,
Repository.class.getName() + ".class",
Group.class.getName() + ".class"
).readOnly("42");
assertThrows(StoreReadOnlyException.class, () ->
storeTestBuilder
.withIds("42", "hitchhikers")
.put("tricia", new User("trillian"))
);
}
@Test
void shouldThrowExceptionWhenStoreForReadOnlyRepositoryAsSecondParentIsModified() {
StoreTestBuilder storeTestBuilder = new StoreTestBuilder(
connectionString,
Group.class.getName() + ".class",
Repository.class.getName() + ".class"
).readOnly("42");
assertThrows(StoreReadOnlyException.class, () ->
storeTestBuilder
.withIds("hitchhikers", "42")
.put("tricia", new User("trillian"))
);
}
@Test
void shouldWriteToWritableStore() {
StoreTestBuilder storeTestBuilder = new StoreTestBuilder(
connectionString,
Repository.class.getName() + ".class"
).readOnly("42");
SQLiteQueryableMutableStore<User> store = storeTestBuilder.withIds("23");
store.put("tricia", new User("trillian"));
assertThat(store.get("tricia")).isNotNull();
}
@Test
void shouldWriteToWritableStoreWithDifferentParentClass() {
StoreTestBuilder storeTestBuilder = new StoreTestBuilder(
connectionString,
Group.class.getName() + ".class"
).readOnly("42");
SQLiteQueryableMutableStore<User> store = storeTestBuilder.withIds("42");
store.put("tricia", new User("trillian"));
assertThat(store.get("tricia")).isNotNull();
} }
} }

View File

@@ -18,17 +18,25 @@ package sonia.scm.store.sqlite;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.mockito.Mockito;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.UUIDKeyGenerator; import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.IdGenerator; import sonia.scm.store.IdGenerator;
import sonia.scm.store.QueryableMaintenanceStore; import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableStore; import sonia.scm.store.QueryableStore;
import sonia.scm.user.User; import sonia.scm.user.User;
import java.util.Collection;
import java.util.HashSet;
import java.util.List; import java.util.List;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS;
import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS; import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.when;
import static sonia.scm.store.sqlite.QueryableTypeDescriptorTestData.createDescriptor; import static sonia.scm.store.sqlite.QueryableTypeDescriptorTestData.createDescriptor;
class StoreTestBuilder { class StoreTestBuilder {
@@ -48,6 +56,8 @@ class StoreTestBuilder {
private final String[] parentClasses; private final String[] parentClasses;
private final IdGenerator idGenerator; private final IdGenerator idGenerator;
private Collection<String> readOnlyRepositoryIds = new HashSet<>();
StoreTestBuilder(String connectionString, String... parentClasses) { StoreTestBuilder(String connectionString, String... parentClasses) {
this(connectionString, IdGenerator.DEFAULT, parentClasses); this(connectionString, IdGenerator.DEFAULT, parentClasses);
} }
@@ -80,12 +90,27 @@ class StoreTestBuilder {
return createStoreFactory(clazz).getMutable(clazz, ids); return createStoreFactory(clazz).getMutable(clazz, ids);
} }
StoreTestBuilder readOnly(String... repositoryIds) {
readOnlyRepositoryIds.addAll(List.of(repositoryIds));
return this;
}
private <T> SQLiteQueryableStoreFactory createStoreFactory(Class<T> clazz) { private <T> SQLiteQueryableStoreFactory createStoreFactory(Class<T> clazz) {
RepositoryReadOnlyChecker readOnlyChecker;
if (readOnlyRepositoryIds.isEmpty()) {
readOnlyChecker = new RepositoryReadOnlyChecker();
} else {
readOnlyChecker = Mockito.mock(RepositoryReadOnlyChecker.class);
when(readOnlyChecker.isReadOnly(argThat((String repoId) -> readOnlyRepositoryIds.contains(repoId))))
.thenReturn(true);
when(readOnlyChecker.isReadOnly(any(Repository.class))).thenCallRealMethod();
}
return new SQLiteQueryableStoreFactory( return new SQLiteQueryableStoreFactory(
connectionString, connectionString,
mapper, mapper,
new UUIDKeyGenerator(), new UUIDKeyGenerator(),
List.of(createDescriptor("", clazz.getName(), parentClasses, idGenerator)) List.of(createDescriptor("", clazz.getName(), parentClasses, idGenerator)),
readOnlyChecker
); );
} }
} }

View File

@@ -35,6 +35,7 @@ import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.ParameterResolver;
import sonia.scm.plugin.QueryableTypeDescriptor; import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.UUIDKeyGenerator; import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.sqlite.SQLiteQueryableStoreFactory; import sonia.scm.store.sqlite.SQLiteQueryableStoreFactory;
import sonia.scm.util.IOUtil; import sonia.scm.util.IOUtil;
@@ -101,7 +102,8 @@ public class QueryableStoreExtension implements ParameterResolver, BeforeEachCal
connectionString, connectionString,
mapper, mapper,
new UUIDKeyGenerator(), new UUIDKeyGenerator(),
queryableTypeDescriptors queryableTypeDescriptors,
new RepositoryReadOnlyChecker()
) )
); );
} }