Skip to content

Commit 062610a

Browse files
committed
reconcile Role resources with a non-superuser admin such as the master user of managed PostgreSQL cloud services
1 parent 0ad61c4 commit 062610a

13 files changed

Lines changed: 1000 additions & 260 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package it.aboutbits.postgresql.core;
2+
3+
import io.fabric8.kubernetes.api.model.SecretBuilder;
4+
import io.fabric8.kubernetes.client.KubernetesClient;
5+
import io.fabric8.kubernetes.client.KubernetesClientException;
6+
import it.aboutbits.postgresql.crd.role.PasswordEncryption;
7+
import jakarta.inject.Singleton;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.eclipse.microprofile.config.inject.ConfigProperty;
11+
import org.jspecify.annotations.NullMarked;
12+
import org.jspecify.annotations.Nullable;
13+
14+
import javax.crypto.Mac;
15+
import javax.crypto.spec.SecretKeySpec;
16+
import java.net.HttpURLConnection;
17+
import java.nio.charset.StandardCharsets;
18+
import java.security.SecureRandom;
19+
import java.util.Base64;
20+
21+
/// Computes keyed fingerprints (HMAC-SHA256) of `Role` passwords.
22+
///
23+
/// The operator stores the fingerprint of the password it applied last in the `Role` status.
24+
/// On the next reconcile it compares the referenced Secret against that fingerprint.
25+
/// This replaces a read of the password hash from `pg_authid`, which needs superuser rights.
26+
/// Managed PostgreSQL services of cloud providers, such as AWS RDS, Google Cloud SQL, or Azure Database for PostgreSQL,
27+
/// do not grant these rights.
28+
///
29+
/// The HMAC key is random, generated once, and kept in a Secret in the operator namespace.
30+
/// Without the key, the fingerprint in the status is useless for an attack on the password.
31+
/// If the key Secret is lost, the operator generates a new key and re-applies every `Role` password once.
32+
@Slf4j
33+
@Singleton
34+
@RequiredArgsConstructor
35+
@NullMarked
36+
public class PasswordFingerprintService {
37+
public static final String SECRET_DATA_KEY = "key";
38+
39+
private static final String HMAC_SHA_256 = "HmacSHA256";
40+
private static final int KEY_LENGTH_BYTES = 32;
41+
private static final byte SEPARATOR = 0;
42+
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
43+
44+
private final KubernetesClient kubernetesClient;
45+
46+
@SuppressWarnings("NullAway.Init")
47+
@ConfigProperty(name = "postgresql-operator.password-fingerprint.secret-name")
48+
String secretName;
49+
50+
private byte @Nullable [] key;
51+
52+
/// Fingerprint of the password together with the requested encryption,
53+
/// so that a change of either re-applies the password.
54+
public String fingerprint(
55+
String password,
56+
PasswordEncryption passwordEncryption
57+
) {
58+
var encryption = passwordEncryption.toValue().getBytes(StandardCharsets.UTF_8);
59+
var secret = password.getBytes(StandardCharsets.UTF_8);
60+
61+
try {
62+
var mac = Mac.getInstance(HMAC_SHA_256);
63+
mac.init(new SecretKeySpec(getKey(), HMAC_SHA_256));
64+
mac.update(encryption);
65+
mac.update(SEPARATOR);
66+
mac.update(secret);
67+
68+
return Base64.getEncoder().encodeToString(mac.doFinal());
69+
} catch (Exception e) {
70+
throw new IllegalStateException("%s not available".formatted(HMAC_SHA_256), e);
71+
}
72+
}
73+
74+
private synchronized byte[] getKey() {
75+
var current = key;
76+
if (current == null) {
77+
current = loadOrCreateKey();
78+
key = current;
79+
}
80+
81+
return current;
82+
}
83+
84+
private byte[] loadOrCreateKey() {
85+
var namespace = kubernetesClient.getNamespace();
86+
if (namespace == null) {
87+
throw new IllegalStateException(
88+
"Cannot determine the operator namespace to store the password fingerprint key Secret [secret.name=%s]".formatted(secretName)
89+
);
90+
}
91+
92+
var secrets = kubernetesClient.secrets()
93+
.inNamespace(namespace)
94+
.withName(secretName);
95+
96+
var secret = secrets.get();
97+
if (secret == null) {
98+
var generatedKey = new byte[KEY_LENGTH_BYTES];
99+
SECURE_RANDOM.nextBytes(generatedKey);
100+
101+
var newSecret = new SecretBuilder()
102+
.withNewMetadata()
103+
.withNamespace(namespace)
104+
.withName(secretName)
105+
.endMetadata()
106+
.withType("Opaque")
107+
.addToData(SECRET_DATA_KEY, Base64.getEncoder().encodeToString(generatedKey))
108+
.build();
109+
110+
try {
111+
secret = kubernetesClient.secrets()
112+
.inNamespace(namespace)
113+
.resource(newSecret)
114+
.create();
115+
116+
log.info(
117+
"Created password fingerprint key Secret [secret.namespace={}, secret.name={}]",
118+
namespace,
119+
secretName
120+
);
121+
} catch (KubernetesClientException e) {
122+
if (e.getCode() != HttpURLConnection.HTTP_CONFLICT) {
123+
throw e;
124+
}
125+
126+
// Another operator replica created the Secret in the meantime
127+
secret = secrets.require();
128+
}
129+
}
130+
131+
var data = secret.getData();
132+
var keyBase64 = data == null
133+
? null
134+
: data.get(SECRET_DATA_KEY);
135+
if (keyBase64 == null || keyBase64.isBlank()) {
136+
throw new IllegalStateException(
137+
"The password fingerprint key Secret is missing required data '%s' [secret.namespace=%s, secret.name=%s]".formatted(
138+
SECRET_DATA_KEY,
139+
namespace,
140+
secretName
141+
)
142+
);
143+
}
144+
145+
return Base64.getDecoder().decode(keyBase64);
146+
}
147+
}

0 commit comments

Comments
 (0)