Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions core-utils/k8s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ public class KubernetesTokenVerifier {
| com.netcracker.cloud.security.kubernetes.service.account.token.dir | /path/to/directory | /var/run/secrets/kubernetes.io/serviceaccount | change the directory where kubernetes service account token is located |
| com.netcracker.cloud.security.kubernetes.tokens.polling.interval | 2m | 1m | change the interval of polling events from file system about token rotations |

### Local-dev TokenRequest (`LocalDevTokenSource`)

When application profile is `dev` (Quarkus: `-Dquarkus.profile=dev`, Spring: `--spring.profiles.active=dev`),
`LocalDevTokenSource` mints real SA tokens via Kubernetes TokenRequest API using the developer kubeconfig
instead of projected-volume files.

| requirement | value |
|---|---|
| Profile | `dev` |
| SA name | `cloud.microservice.name` (contract, no override) |
| Namespace | env `CLOUD_NAMESPACE` |
| Kube access | `KUBECONFIG` or `~/.kube/config` (static `token`, OIDC `auth-provider` with refresh via `idp-issuer-url`, or `exec` auth). IdP TLS uses trust-all in local-dev by default (`security.local-dev.insecure-idp-tls=false` to disable). |
| Token TTL | 8 hours (`expirationSeconds=28800`) |
| Audience | value passed to `KubernetesAudienceToken.getToken(audience)` |

Framework bootstrap (security-core / security-quarkus-extensions m2m-manager) copies `cloud.microservice.name`
into a system property so `k8s-utils` can read it without depending on Spring/Quarkus config APIs.

On HTTP 401/403 from TokenRequest the error message explains missing RBAC on `serviceaccounts/token`.

For inbound validation, consumers rewrite the JWKS URL to `LocalDevKubernetesOidc.jwksUrl()`
(kube API + `/openid/v1/jwks`). Discovery and JWKS on the API server are public (no Bearer).
When a projected SA token is missing, `resolveIssuerClaimFromDiscovery()` reads the issuer from
kube OIDC discovery. TokenRequest and other protected API calls still use `userToken()`.

### Custom TokenSource

`com.netcracker.cloud.security.core.utils.k8s.impl.WatchingTokenSource` is the current default implementation of the
Expand Down
4 changes: 4 additions & 0 deletions core-utils/k8s/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>

<dependency>
<groupId>net.jodah</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.netcracker.cloud.security.core.utils.k8s.localdev;

import lombok.Builder;
import lombok.Value;

@Value
@Builder
public class KubeConfigCredentials {
String serverUrl;
String userToken;
byte[] certificateAuthorityData;
boolean insecureSkipTlsVerify;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.netcracker.cloud.security.core.utils.k8s.localdev;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.http.HttpClient;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Collection;

final class KubeConfigHttpClientFactory {

private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30);

private KubeConfigHttpClientFactory() {
}

static HttpClient create(KubeConfigCredentials credentials) {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(CONNECT_TIMEOUT)
.sslContext(createSslContext(credentials))
.build();
}

static HttpClient createInsecureForLocalDev() {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(CONNECT_TIMEOUT)
.sslContext(createInsecureSslContext())
.build();
}

private static SSLContext createInsecureSslContext() {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, new SecureRandom());
return sslContext;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Failed to create insecure SSL context for local-dev IdP", e);
}
}

private static SSLContext createSslContext(KubeConfigCredentials credentials) {
try {
if (credentials.isInsecureSkipTlsVerify()) {
TrustManager[] trustAll = new TrustManager[]{new InsecureTrustManager()};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAll, new SecureRandom());
return sslContext;
}
if (credentials.getCertificateAuthorityData() == null
|| credentials.getCertificateAuthorityData().length == 0) {
return SSLContext.getDefault();
}

CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates =
certificateFactory.generateCertificates(
new ByteArrayInputStream(credentials.getCertificateAuthorityData()));

KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
int index = 0;
for (Certificate certificate : certificates) {
keyStore.setCertificateEntry("ca-" + index++, certificate);
}

TrustManagerFactory trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());
return sslContext;
} catch (GeneralSecurityException | IOException e) {
throw new IllegalStateException("Failed to create SSL context from kubeconfig CA", e);
}
}

/**
* Local-dev only: accepts any server certificate when kubeconfig sets insecure-skip-tls-verify
* or the IdP uses a private CA not in the JVM trust store.
*/
private static final class InsecureTrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) { // NOSONAR
// Local-dev: client certificates are not used for TokenRequest / OIDC discovery.
}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) { // NOSONAR
// Local-dev: kubeconfig insecure-skip-tls-verify or private IdP CA — validation intentionally skipped.
}

@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package com.netcracker.cloud.security.core.utils.k8s.localdev;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;

import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields;
import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevJsonUtils.*;

@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class KubeConfigLoader {

private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory());
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
private static final long EXEC_TIMEOUT_SECONDS = 30;

public static KubeConfigCredentials load() {
Path kubeConfigPath = resolveKubeConfigPath();
if (!Files.isRegularFile(kubeConfigPath)) {
throw new IllegalStateException("Kubeconfig not found at " + kubeConfigPath
+ ". Set KUBECONFIG or create ~/.kube/config.");
}
try {
JsonNode root = YAML_MAPPER.readTree(kubeConfigPath.toFile());
String currentContext = getTextField(root, KubeConfigFields.CURRENT_CONTEXT);
if (StringUtils.isBlank(currentContext)) {
throw new IllegalStateException("Kubeconfig has no current-context: " + kubeConfigPath);
}
JsonNode context = findKubeConfigEntryByName(root.path(KubeConfigFields.CONTEXTS), currentContext)
.path(KubeConfigFields.CONTEXT);
String clusterName = getTextField(context, KubeConfigFields.CLUSTER);
String userName = getTextField(context, KubeConfigFields.USER);
if (StringUtils.isBlank(clusterName) || StringUtils.isBlank(userName)) {
throw new IllegalStateException("Context '" + currentContext
+ "' must define cluster and user in " + kubeConfigPath);
}

JsonNode cluster = findKubeConfigEntryByName(root.path(KubeConfigFields.CLUSTERS), clusterName)
.path(KubeConfigFields.CLUSTER);
JsonNode user = findKubeConfigEntryByName(root.path(KubeConfigFields.USERS), userName)
.path(KubeConfigFields.USER);

String server = getTextField(cluster, KubeConfigFields.SERVER);
if (StringUtils.isBlank(server)) {
throw new IllegalStateException("Cluster '" + clusterName + "' has no server URL");
}

return KubeConfigCredentials.builder()
.serverUrl(StringUtils.stripEnd(server, "/"))
.userToken(resolveUserToken(user))
.certificateAuthorityData(decodeOptionalBase64(
getTextField(cluster, KubeConfigFields.CERTIFICATE_AUTHORITY_DATA)))
.insecureSkipTlsVerify(cluster.path(KubeConfigFields.INSECURE_SKIP_TLS_VERIFY).asBoolean(false))
.build();
} catch (IOException e) {
throw new IllegalStateException("Failed to parse kubeconfig: " + kubeConfigPath, e);
}
}

static Path resolveKubeConfigPath() {
String kubeConfig = System.getenv("KUBECONFIG");
if (StringUtils.isNotBlank(kubeConfig)) {
// KUBECONFIG may be a list; use the first entry
String first = kubeConfig.split(java.io.File.pathSeparator)[0].trim();
return Path.of(first);
}
return Path.of(System.getProperty("user.home"), ".kube", "config");
}

private static String resolveUserToken(JsonNode user) {
String token = getTextField(user, KubeConfigFields.TOKEN);
if (StringUtils.isNotBlank(token)) {
return token;
}
String authProviderToken = resolveAuthProviderToken(user.path(KubeConfigFields.AUTH_PROVIDER));
if (StringUtils.isNotBlank(authProviderToken)) {
return authProviderToken;
}
String directOidcToken = firstNonBlank(
getTextField(user, KubeConfigFields.ID_TOKEN),
getTextField(user, KubeConfigFields.ACCESS_TOKEN));
if (StringUtils.isNotBlank(directOidcToken)) {
return directOidcToken;
}
JsonNode exec = user.path(KubeConfigFields.EXEC);
if (!exec.isMissingNode() && !exec.isNull()) {
return runExecCredential(exec);
}
throw new IllegalStateException(
"Kubeconfig user has neither 'token', OIDC auth-provider (with refresh-token/id-token), nor 'exec'. "
+ "Local-dev TokenRequest supports static token, OIDC auth-provider refresh, and exec auth.");
}

private static String resolveAuthProviderToken(JsonNode authProvider) {
if (authProvider.isMissingNode() || authProvider.isNull()) {
return null;
}
JsonNode config = authProvider.path(KubeConfigFields.CONFIG);
if (config.isMissingNode() || config.isNull()) {
return null;
}
String providerName = getTextField(authProvider, KubeConfigFields.NAME);
if (LocalDevConstants.OIDC_AUTH_PROVIDER_NAME.equalsIgnoreCase(providerName)) {
return OidcAuthProviderTokenRefresher.resolveToken(config);
}
return firstNonBlank(
getTextField(config, KubeConfigFields.ID_TOKEN),
getTextField(config, KubeConfigFields.ACCESS_TOKEN));
}

private static String runExecCredential(JsonNode exec) {
String command = requireExecCommand(exec);
List<String> commandLine = buildExecCommandLine(exec, command);
log.debug("Resolving kubeconfig credentials via exec: {}", commandLine);
try {
String output = runExecProcess(exec, commandLine, command);
return parseExecToken(output);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Kubeconfig exec interrupted", e);
} catch (IOException e) {
throw new IllegalStateException("Failed to run kubeconfig exec command: " + command, e);
}
}

private static String requireExecCommand(JsonNode exec) {
String command = getTextField(exec, KubeConfigFields.COMMAND);
if (StringUtils.isBlank(command)) {
throw new IllegalStateException("Kubeconfig exec.command is empty");
}
return command;
}

private static List<String> buildExecCommandLine(JsonNode exec, String command) {
List<String> commandLine = new ArrayList<>();
commandLine.add(command);
JsonNode args = exec.path(KubeConfigFields.ARGS);
if (args.isArray()) {
for (JsonNode arg : args) {
commandLine.add(arg.asText());
}
}
return commandLine;
}

private static void applyExecEnvironment(ProcessBuilder processBuilder, JsonNode exec) {
JsonNode env = exec.path(KubeConfigFields.ENV);
if (!env.isArray()) {
return;
}
for (JsonNode envVar : env) {
String name = getTextField(envVar, KubeConfigFields.NAME);
String value = getTextField(envVar, KubeConfigFields.VALUE);
if (StringUtils.isNotBlank(name)) {
processBuilder.environment().put(name, value == null ? "" : value);
}
}
}

private static String runExecProcess(JsonNode exec, List<String> commandLine, String command)
throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(commandLine);
processBuilder.redirectErrorStream(true);
applyExecEnvironment(processBuilder, exec);
Process process = processBuilder.start();
boolean finished = process.waitFor(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new IllegalStateException("Kubeconfig exec timed out after " + EXEC_TIMEOUT_SECONDS + "s: " + command);
}
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (process.exitValue() != 0) {
throw new IllegalStateException("Kubeconfig exec failed (exit " + process.exitValue() + "): " + output);
}
return output;
}

private static String parseExecToken(String output) throws IOException {
JsonNode credential = JSON_MAPPER.readTree(output);
String token = getTextField(credential.path(KubeConfigFields.STATUS), KubeConfigFields.TOKEN);
if (StringUtils.isBlank(token)) {
throw new IllegalStateException("Kubeconfig exec did not return status.token");
}
return token;
}

private static JsonNode findKubeConfigEntryByName(JsonNode array, String name) {
if (array != null && array.isArray()) {
for (Iterator<JsonNode> it = array.elements(); it.hasNext(); ) {
JsonNode item = it.next();
if (name.equals(getTextField(item, KubeConfigFields.NAME))) {
return item;
}
}
}
throw new IllegalStateException("Kubeconfig entry not found: " + name);
}

private static byte[] decodeOptionalBase64(String value) {
if (StringUtils.isBlank(value)) {
return new byte[0];
}
return Base64.getDecoder().decode(value.replaceAll("\\s", ""));
}
}
Loading
Loading