Skip to content

Commit

Permalink
Be explicit about database roles
Browse files Browse the repository at this point in the history
This changes the code to have explicitly separated roles for
database installs/upgrades, and for normal operation.

Fix: #81
  • Loading branch information
io7m committed Jun 21, 2023
1 parent d4171a1 commit b684498
Show file tree
Hide file tree
Showing 22 changed files with 479 additions and 209 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,28 @@

import java.time.Clock;
import java.util.Objects;
import java.util.Optional;

/**
* The server database configuration.
*
* @param user The username with which to connect
* @param password The password with which to connect
* @param port The database TCP/IP port
* @param upgrade The upgrade specification
* @param create The creation specification
* @param address The database address
* @param databaseName The database name
* @param clock A clock for time retrievals
* @param ownerRoleName The name of the role that owns the database; used for database setup and migrations
* @param ownerRolePassword The password of the role that owns the database
* @param workerRolePassword The password of the worker role used for normal database operation
* @param readerRolePassword The password of the role used for read-only database access
* @param port The database TCP/IP port
* @param upgrade The upgrade specification
* @param create The creation specification
* @param address The database address
* @param databaseName The database name
* @param clock A clock for time retrievals
*/

public record IdDatabaseConfiguration(
String user,
String password,
String ownerRoleName,
String ownerRolePassword,
String workerRolePassword,
Optional<String> readerRolePassword,
String address,
int port,
String databaseName,
Expand All @@ -45,20 +50,24 @@ public record IdDatabaseConfiguration(
/**
* The server database configuration.
*
* @param user The username with which to connect
* @param password The password with which to connect
* @param port The database TCP/IP port
* @param upgrade The upgrade specification
* @param create The creation specification
* @param address The database address
* @param databaseName The database name
* @param clock A clock for time retrievals
* @param ownerRoleName The name of the role that owns the database; used for database setup and migrations
* @param ownerRolePassword The password of the role that owns the database
* @param workerRolePassword The password of the worker role used for normal database operation
* @param readerRolePassword The password of the role used for read-only database access
* @param port The database TCP/IP port
* @param upgrade The upgrade specification
* @param create The creation specification
* @param address The database address
* @param databaseName The database name
* @param clock A clock for time retrievals
*/

public IdDatabaseConfiguration
{
Objects.requireNonNull(user, "user");
Objects.requireNonNull(password, "password");
Objects.requireNonNull(ownerRoleName, "ownerRoleName");
Objects.requireNonNull(ownerRolePassword, "ownerRolePassword");
Objects.requireNonNull(workerRolePassword, "workerRolePassword");
Objects.requireNonNull(readerRolePassword, "readerRolePassword");
Objects.requireNonNull(address, "address");
Objects.requireNonNull(databaseName, "databaseName");
Objects.requireNonNull(create, "create");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@

public enum IdDatabaseRole
{
/**
* The administration role.
*/

ADMIN,

/**
* The main idstore role.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import com.io7m.trasco.vanilla.TrSchemaRevisionSetParsers;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import org.postgresql.util.PSQLState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -167,6 +169,14 @@ public IdDatabaseType open(
Objects.requireNonNull(telemetry, "telemetry");
Objects.requireNonNull(startupMessages, "startupMessages");

createOrUpgrade(telemetry, configuration, startupMessages);
return connect(telemetry, configuration);
}

private static IdDatabaseType connect(
final IdDatabaseTelemetry telemetry,
final IdDatabaseConfiguration configuration)
{
final var resources = CloseableCollection.create(() -> {
return new IdDatabaseException(
"Closing a resource failed.",
Expand All @@ -176,98 +186,207 @@ public IdDatabaseType open(
);
});

try {
final var url = new StringBuilder(128);
url.append("jdbc:postgresql://");
url.append(configuration.address());
url.append(':');
url.append(configuration.port());
url.append('/');
url.append(configuration.databaseName());

final var config = new HikariConfig();
config.setJdbcUrl(url.toString());
config.setUsername(configuration.user());
config.setPassword(configuration.password());
config.setAutoCommit(false);

final var dataSource =
resources.add(new HikariDataSource(config));

final var parsers = new TrSchemaRevisionSetParsers();
final TrSchemaRevisionSet revisions;
try (var stream = IdDatabases.class.getResourceAsStream(
"/com/io7m/idstore/database/postgres/internal/database.xml")) {
revisions = parsers.parse(URI.create("urn:source"), stream);
}
final var url = new StringBuilder(128);
url.append("jdbc:postgresql://");
url.append(configuration.address());
url.append(':');
url.append(configuration.port());
url.append('/');
url.append(configuration.databaseName());

try (var connection = dataSource.getConnection()) {
connection.setAutoCommit(false);

new TrExecutors().create(
new TrExecutorConfiguration(
IdDatabases::schemaVersionGet,
IdDatabases::schemaVersionSet,
event -> publishTrEvent(startupMessages, event),
revisions,
switch (configuration.upgrade()) {
case UPGRADE_DATABASE -> PERFORM_UPGRADES;
case DO_NOT_UPGRADE_DATABASE -> FAIL_INSTEAD_OF_UPGRADING;
},
connection
)
).execute();
connection.commit();
}
final var config = new HikariConfig();
config.setJdbcUrl(url.toString());
config.setUsername(configuration.ownerRoleName());
config.setPassword(configuration.ownerRolePassword());
config.setAutoCommit(false);

return new IdDatabase(
telemetry,
configuration.clock(),
dataSource,
resources
);
} catch (final IOException e) {
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
IO_ERROR,
Map.of(),
Optional.empty()
);
} catch (final TrException e) {
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
TRASCO_ERROR,
Map.of(),
Optional.empty()
);
} catch (final ParsingException e) {
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
SQL_REVISION_ERROR,
Map.of(),
Optional.empty()
);
} catch (final SQLException e) {
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
final var dataSource =
resources.add(new HikariDataSource(config));

return new IdDatabase(
telemetry,
configuration.clock(),
dataSource,
resources
);
}

private static void createOrUpgrade(
final IdDatabaseTelemetry telemetry,
final IdDatabaseConfiguration configuration,
final Consumer<String> startupMessages)
throws IdDatabaseException
{
final var resources = CloseableCollection.create(() -> {
return new IdDatabaseException(
"Closing a resource failed.",
SQL_ERROR,
Map.of(),
Optional.empty()
);
});

final var span =
telemetry.tracer()
.spanBuilder("DatabaseSetup")
.startSpan();

try (var ignored0 = span.makeCurrent()) {
try (var ignored1 = resources) {
final var url = new StringBuilder(128);
url.append("jdbc:postgresql://");
url.append(configuration.address());
url.append(':');
url.append(configuration.port());
url.append('/');
url.append(configuration.databaseName());

final var config = new HikariConfig();
config.setJdbcUrl(url.toString());
config.setUsername(configuration.ownerRoleName());
config.setPassword(configuration.ownerRolePassword());
config.setAutoCommit(false);

final var dataSource =
resources.add(new HikariDataSource(config));

final var parsers = new TrSchemaRevisionSetParsers();
final TrSchemaRevisionSet revisions;
try (var stream = IdDatabases.class.getResourceAsStream(
"/com/io7m/idstore/database/postgres/internal/database.xml")) {
revisions = parsers.parse(URI.create("urn:source"), stream);
}

try (var connection = dataSource.getConnection()) {
connection.setAutoCommit(false);

new TrExecutors().create(
new TrExecutorConfiguration(
IdDatabases::schemaVersionGet,
IdDatabases::schemaVersionSet,
event -> publishTrEvent(startupMessages, event),
revisions,
switch (configuration.upgrade()) {
case UPGRADE_DATABASE -> PERFORM_UPGRADES;
case DO_NOT_UPGRADE_DATABASE -> FAIL_INSTEAD_OF_UPGRADING;
},
connection
)
).execute();

updateWorkerRolePassword(configuration, connection);
updateReadOnlyRolePassword(configuration, connection);
connection.commit();
}
} catch (final IOException e) {
failSpan(e);
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
IO_ERROR,
Map.of(),
Optional.empty()
);
} catch (final TrException e) {
failSpan(e);
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
TRASCO_ERROR,
Map.of(),
Optional.empty()
);
} catch (final ParsingException e) {
failSpan(e);
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
SQL_REVISION_ERROR,
Map.of(),
Optional.empty()
);
} catch (final SQLException e) {
failSpan(e);
throw new IdDatabaseException(
requireNonNullElse(e.getMessage(), e.getClass().getSimpleName()),
e,
SQL_ERROR,
Map.of(),
Optional.empty()
);
}
}
}

/**
* Update the read-only role password. If no password is specified, then
* logging in is prevented.
*/

private static void updateReadOnlyRolePassword(
final IdDatabaseConfiguration configuration,
final Connection connection)
throws SQLException
{
final var passwordOpt = configuration.readerRolePassword();
if (passwordOpt.isPresent()) {
LOG.debug("updating idstore_read_only role to allow password logins");
try (var st = connection.prepareStatement(
"ALTER USER idstore_read_only WITH PASSWORD '?'")) {
st.setString(1, passwordOpt.get());
st.execute();
}
try (var st = connection.prepareStatement(
"ALTER USER idstore_read_only SET LOGIN")) {
st.execute();
}
} else {
LOG.debug("updating idstore_read_only role to disallow logins");
try (var st = connection.prepareStatement(
"ALTER USER idstore_read_only SET NOLOGIN")) {
st.execute();
}
}
}

/**
* Update the worker role password. Might be a no-op.
*/

private static void updateWorkerRolePassword(
final IdDatabaseConfiguration configuration,
final Connection connection)
throws SQLException
{
try (var st = connection.prepareStatement(
"ALTER USER idstore WITH PASSWORD '?'")) {
st.setString(1, configuration.workerRolePassword());
st.execute();
}
try (var st = connection.prepareStatement(
"ALTER USER idstore SET LOGIN")) {
st.execute();
}
}

private static void failSpan(
final Exception e)
{
final Span span = Span.current();
span.recordException(e);
span.setStatus(StatusCode.ERROR);
}

private static void publishEvent(
final Consumer<String> startupMessages,
final String message)
{
try {
LOG.trace("{}", message);
startupMessages.accept(message);

final var span = Span.current();
span.addEvent(message);
} catch (final Exception e) {
LOG.error("ignored consumer exception: ", e);
}
Expand Down

0 comments on commit b684498

Please sign in to comment.