ProvisioningApiKeyBootstrap.java
package net.hostsharing.hsadminng.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.regex.Pattern;
/**
* Provisions the global-admin API_KEY subject `hsadminng.provisioning.key` from the configuration.
*
* When starting with a legacy database, there is neither a Keycloak-matching USER subject nor
* any API_KEY subject, thus nobody could create the first API-key via the API. If
* `hsadminng.security.provisioning-api-key-sha256` (HSADMINNG_PROVISIONING_API_KEY_SHA256) is configured,
* this bootstrap creates the API_KEY subject "hsadminng.provisioning.key" with the global ADMIN role and
* stores the configured SHA-256 hash - but only if no API-key is stored for that subject yet:
* an already stored API-key always takes precedence, thus restarting the application never
* changes it, not even if the configured hash differs.
*
* Only the SHA-256 hash of the API-key is configured, thus the clear-text API-key exists
* neither on the application server nor in the database, but only at the client. It is
* validated on each request by the ApiKeyAuthenticationFilter, just like any other API-key.
*/
@Slf4j
@Component
public class ProvisioningApiKeyBootstrap implements ApplicationRunner {
public static final String HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME = "hsadminng.provisioning.key";
private static final Pattern SHA256_HEX_PATTERN = Pattern.compile("^[0-9a-f]{64}$");
private final JdbcTemplate jdbcTemplate;
private final String configuredApiKeyHash;
public ProvisioningApiKeyBootstrap(
final JdbcTemplate jdbcTemplate,
@Value("${hsadminng.security.provisioning-api-key-sha256:}") final String configuredApiKeyHash) {
this.jdbcTemplate = jdbcTemplate;
this.configuredApiKeyHash = configuredApiKeyHash;
}
@Override
public void run(final ApplicationArguments args) {
if (!StringUtils.hasText(configuredApiKeyHash)) {
return;
}
final var configuredKeyHash = configuredApiKeyHash.trim().toLowerCase();
if (!SHA256_HEX_PATTERN.matcher(configuredKeyHash).matches()) {
throw new IllegalStateException(
"hsadminng.security.provisioning-api-key-sha256 (HSADMINNG_PROVISIONING_API_KEY_SHA256)"
+ " must be a hex-encoded SHA-256 hash (64 hex digits)");
}
final var storedKeyHashes = jdbcTemplate.queryForList("""
select ak.keyHash
from rbac.api_key ak
join rbac.subject s on s.uuid = ak.uuid
where s.name = '%s'
""".formatted(HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME), String.class);
if (!storedKeyHashes.isEmpty()) {
if (storedKeyHashes.getFirst().equals(configuredKeyHash)) {
log.info("the provisioning API-key subject '{}' is already provisioned", HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME);
} else {
log.warn("the configured provisioning API-key hash differs from the API-key stored for subject '{}';"
+ " the stored API-key takes precedence and remains valid", HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME);
}
return;
}
// Privileged bootstrap seed, run before any global-admin subject exists, so it must use the
// unchecked RBAC primitives: the checked repository path (rbac.create_api_key) rejects callers
// without an authenticated global admin -- the very subject this seed creates. Like the Liquibase
// seed changesets it inlines a system-context do-block; hash validated as hex, name is a constant.
jdbcTemplate.execute("""
do language plpgsql $$
declare
subjectUuid uuid;
admins uuid;
begin
call base.defineContext('provisioning the API-key from configuration', null, null, null);
subjectUuid := rbac.create_subject_if_not_exist('%s', 'API_KEY'::rbac.SubjectType);
admins := rbac.findRoleId(rbac.global_ADMIN());
if not exists (
select 1 from rbac.grant
where ascendantUuid = subjectUuid and descendantUuid = admins) then
call rbac.grantRoleToSubjectUnchecked(admins, admins, subjectUuid);
end if;
insert into rbac.api_key (uuid, keyHash) values (subjectUuid, '%s');
end $$;
""".formatted(HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME, configuredKeyHash));
log.info("provisioned the provisioning API-key subject '{}' with the global ADMIN role", HSADMINNG_PROVISIONING_API_KEY_SUBJECT_NAME);
}
}