Skip to content
Closed
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
42 changes: 42 additions & 0 deletions docs/provider_github_actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,45 @@ The following changes are necessary for the ZTS server to support GitHub Actions
- ZTS enforces IP ranges where services are authorized to request identities for service identities from a given provider.
It is expected that the worker nodes for a team will be configured to run in their own private AWS accounts, for example,
and as such the team needs to provide the NAT gateway IP addresses for the account, so they can be authorized.


The following attributes must be configured in the ZTS server configuration file:

```sh
athenz.zts.github_actions.provider_dns_suffix=
athenz.zts.github_actions.issuer=
athenz.zts.github_actions.audience=
```

By default, your `issuer` value will generate a derived `jwks_uri` with the suffix `/.well-known/openid-configuration`, which is used to fetch the public keys for validating the OIDC token signature. If you want to use a different endpoint for fetching the public keys, you can specify `jwks_uri`:

```sh
athenz.zts.github_actions.jwks_uri=https://your-github-website.com/_services/token/.well-known/jwks
```

If you want to use multiple environments for the same GitHub Actions provider, you can create a JSON file locally on your ZTS server and specify its path with `athenz.zts.github_actions.prop_file_path` in your ZTS server configuration file. The JSON file should contain an array of objects with the following attributes:

> [!WARNING]
> It is recommended to use the `prop_file_path` attribute only if you want to offer multiple environments for the same GitHub Actions provider. If you want to use only one environment, you can set the attributes directly in the ZTS server configuration file. The `prop_file_path` will override your direct configuration in the ZTS server configuration file if the same `issuer` is used in the JSON file

```json
{
"props": [
{
"provider_dns_suffix": "test-provider-suffix,another-test-provider-suffix",
"enterprise": "test-enterprise",
"audience": "test-audience",
"issuer": "test-issuer",
"jwks_uri": "https://test-jwks-uri.com/openid/v1/jwks"
},
{
"provider_dns_suffix": "test-provider-suffix,another-test-provider-suffix",
"enterprise": "test-enterprise",
"audience": "test-audience",
"issuer": "test-issuer",
"jwks_uri": "https://test-jwks-uri.com/openid/v1/jwks"
},
...
]
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package com.yahoo.athenz.instance.provider.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
Expand All @@ -33,8 +35,15 @@
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLContext;
import com.nimbusds.jwt.SignedJWT;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import static com.yahoo.athenz.common.server.util.config.ConfigManagerSingleton.CONFIG_MANAGER;

Expand All @@ -45,13 +54,21 @@ public class InstanceGithubActionsProvider implements InstanceProvider {
private static final String URI_INSTANCE_ID_PREFIX = "athenz://instanceid/";
private static final String URI_SPIFFE_PREFIX = "spiffe://";

static final String GITHUB_ACTIONS_PROP_PROVIDER_DNS_SUFFIX = "athenz.zts.github_actions.provider_dns_suffix";
static final String KEY_PROVIDER_DNS_SUFFIX = "provider_dns_suffix";
static final String KEY_AUDIENCE = "audience";
static final String KEY_ENTERPRISE = "enterprise";
static final String KEY_JWKS_URI = "jwks_uri";
static final String KEY_ISSUER = "issuer";
static final String KEY_JWK_PROCESSOR = "jwk_processor";

static final String GITHUB_ACTIONS_PROP_FILE_PATH = "athenz.zts.github_actions.prop_file_path";
static final String GITHUB_ACTIONS_PROP_PROVIDER_DNS_SUFFIX = "athenz.zts.github_actions." + KEY_PROVIDER_DNS_SUFFIX;
static final String GITHUB_ACTIONS_PROP_BOOT_TIME_OFFSET = "athenz.zts.github_actions.boot_time_offset";
static final String GITHUB_ACTIONS_PROP_CERT_EXPIRY_TIME = "athenz.zts.github_actions.cert_expiry_time";
static final String GITHUB_ACTIONS_PROP_ENTERPRISE = "athenz.zts.github_actions.enterprise";
static final String GITHUB_ACTIONS_PROP_AUDIENCE = "athenz.zts.github_actions.audience";
static final String GITHUB_ACTIONS_PROP_ISSUER = "athenz.zts.github_actions.issuer";
static final String GITHUB_ACTIONS_PROP_JWKS_URI = "athenz.zts.github_actions.jwks_uri";
static final String GITHUB_ACTIONS_PROP_ENTERPRISE = "athenz.zts.github_actions." + KEY_ENTERPRISE;
static final String GITHUB_ACTIONS_PROP_AUDIENCE = "athenz.zts.github_actions." + KEY_AUDIENCE;
static final String GITHUB_ACTIONS_PROP_ISSUER = "athenz.zts.github_actions." + KEY_ISSUER;
static final String GITHUB_ACTIONS_PROP_JWKS_URI = "athenz.zts.github_actions." + KEY_JWKS_URI;

static final String GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com";
static final String GITHUB_ACTIONS_ISSUER_JWKS_URI = "https://token.actions.githubusercontent.com/.well-known/jwks";
Expand All @@ -61,21 +78,56 @@ public class InstanceGithubActionsProvider implements InstanceProvider {
public static final String CLAIM_EVENT_NAME = "event_name";
public static final String CLAIM_REPOSITORY = "repository";

Set<String> dnsSuffixes = null;
String githubIssuer = null;
Map<String, Map<String, Object>> props = null;
// Set<String> dnsSuffixes = null; // TODO: Wanna remove this
// String githubIssuer = null; // TODO: Wanna remove this
String provider = null;
String audience = null;
String enterprise = null;
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = null;
// String audience = null; // TODO: Wanna remove this
// String enterprise = null; // TODO: Wanna remove this
// ConfigurableJWTProcessor<SecurityContext> jwtProcessor = null; // TODO: Wanna remove this
Authorizer authorizer = null;
DynamicConfigLong bootTimeOffsetSeconds;
long certExpiryTime;
DynamicConfigLong bootTimeOffsetSeconds; // TODO: Wanna remove this
long certExpiryTime; // ? USE THIS AS THE ONLY SOURCE?

@Override
public Scheme getProviderScheme() {
return Scheme.CLASS;
}

public void initializeFromFilePath() throws ProviderResourceException {
final String propFilePath = System.getProperty(GITHUB_ACTIONS_PROP_FILE_PATH, "");
if (StringUtil.isEmpty(propFilePath)) {
return; // no prop file path specified, nothing to do
}

Path path = Paths.get(propFilePath);
try {
Map<String, List<Map<String, Object>>> propJson = new ObjectMapper().readValue(
Files.readAllBytes(path),
new TypeReference<Map<String, List<Map<String, Object>>>>() { }
);

for (Map<String, Object> prop : propJson.get("props")) {
String issuer = (String) prop.get(KEY_ISSUER);
if (StringUtil.isEmpty(issuer)) {
throw forbiddenError("Missing required issuer prop file: " + propFilePath);
}

// Put Data:
props.put(issuer, Map.of(
KEY_PROVIDER_DNS_SUFFIX, (String) prop.get(KEY_PROVIDER_DNS_SUFFIX),
KEY_AUDIENCE, (String) prop.get(KEY_AUDIENCE),
KEY_ENTERPRISE, (String) prop.get(KEY_ENTERPRISE), // optional
KEY_JWKS_URI, extractGitHubIssuerJwksUri(issuer, (String) prop.get(KEY_JWKS_URI))
));
}

} catch (IOException ex) {
throw forbiddenError("Unable to parse jwk endpoints file: " + propFilePath
+ ", error: " + ex.getMessage());
}
}

@Override
public void initialize(String provider, String providerEndpoint, SSLContext sslContext,
KeyStore keyStore) {
Expand All @@ -86,13 +138,14 @@ public void initialize(String provider, String providerEndpoint, SSLContext sslC

// lookup the zts audience. if not specified we'll default to athenz.io

audience = System.getProperty(GITHUB_ACTIONS_PROP_AUDIENCE, "athenz.io");
// audience = System.getProperty(GITHUB_ACTIONS_PROP_AUDIENCE, "athenz.io");

// determine the dns suffix. if this is not specified we'll just default to github-actions.athenz.cloud

final String dnsSuffix = System.getProperty(GITHUB_ACTIONS_PROP_PROVIDER_DNS_SUFFIX, "github-actions.athenz.io");
dnsSuffixes = new HashSet<>();
dnsSuffixes.addAll(Arrays.asList(dnsSuffix.split(",")));
// TODO: I dont have to do this here, I can just do so once I send it to the function for InstanceUtils.validateCertRequestSanDnsNames()
// final String dnsSuffix = System.getProperty(GITHUB_ACTIONS_PROP_PROVIDER_DNS_SUFFIX, "github-actions.athenz.io");
// dnsSuffixes = new HashSet<>();
// dnsSuffixes.addAll(Arrays.asList(dnsSuffix.split(",")));

// how long the instance must be booted in the past before we
// stop validating the instance requests
Expand All @@ -102,23 +155,42 @@ public void initialize(String provider, String providerEndpoint, SSLContext sslC

// determine if we're running in enterprise mode

enterprise = System.getProperty(GITHUB_ACTIONS_PROP_ENTERPRISE);
// enterprise = System.getProperty(GITHUB_ACTIONS_PROP_ENTERPRISE); // TODO: Remove ME!

// get default/max expiry time for any generated tokens - 6 hours

certExpiryTime = Long.parseLong(System.getProperty(GITHUB_ACTIONS_PROP_CERT_EXPIRY_TIME, "360"));

// initialize our jwt processor

githubIssuer = System.getProperty(GITHUB_ACTIONS_PROP_ISSUER, GITHUB_ACTIONS_ISSUER);
jwtProcessor = JwtsHelper.getJWTProcessor(new JwtsSigningKeyResolver(extractGitHubIssuerJwksUri(githubIssuer), null));
// githubIssuer = System.getProperty(GITHUB_ACTIONS_PROP_ISSUER, GITHUB_ACTIONS_ISSUER); // TODO: Remove ME!
// // TODO: Remove BELOW!
// jwtProcessor = JwtsHelper.getJWTProcessor(new JwtsSigningKeyResolver(extractGitHubIssuerJwksUri(githubIssuer, System.getProperty(GITHUB_ACTIONS_PROP_JWKS_URI)), null));

props.put(System.getProperty(GITHUB_ACTIONS_PROP_ISSUER, GITHUB_ACTIONS_ISSUER), Map.of(
KEY_PROVIDER_DNS_SUFFIX, System.getProperty(GITHUB_ACTIONS_PROP_PROVIDER_DNS_SUFFIX, "github-actions.athenz.io"),
KEY_AUDIENCE, System.getProperty(GITHUB_ACTIONS_PROP_AUDIENCE, "athenz.io"),
KEY_ENTERPRISE, System.getProperty(GITHUB_ACTIONS_PROP_ENTERPRISE), // optional
KEY_JWKS_URI, extractGitHubIssuerJwksUri(
System.getProperty(GITHUB_ACTIONS_PROP_ISSUER, GITHUB_ACTIONS_ISSUER),
System.getProperty(GITHUB_ACTIONS_PROP_JWKS_URI)
),
KEY_JWK_PROCESSOR, JwtsHelper.getJWTProcessor(new JwtsSigningKeyResolver(extractGitHubIssuerJwksUri(
System.getProperty(GITHUB_ACTIONS_PROP_ISSUER, GITHUB_ACTIONS_ISSUER),
System.getProperty(GITHUB_ACTIONS_PROP_JWKS_URI)
), null))
));
try {
initializeFromFilePath(); // initialize from file path if specified. If not specified, nothing happens.
} catch (ProviderResourceException ex) {
LOGGER.error("Unable to initialize from file path: {}", ex.getMessage());
}
}

String extractGitHubIssuerJwksUri(final String issuer) {
String extractGitHubIssuerJwksUri(final String issuer, String jwksUri) {

// if we have the value configured then that's what we're going to use

String jwksUri = System.getProperty(GITHUB_ACTIONS_PROP_JWKS_URI);
if (!StringUtil.isEmpty(jwksUri)) {
return jwksUri;
}
Expand Down Expand Up @@ -187,17 +259,28 @@ public InstanceConfirmation confirmInstance(InstanceConfirmation confirmation) t
}

StringBuilder errMsg = new StringBuilder(256);

String claimIssuer = null;
try {
// parse the token and get the issuer claim
claimIssuer = SignedJWT.parse(attestationData).getJWTClaimsSet().getIssuer();
} catch (Exception ex) {
errMsg.append("Unable to parse token: ").append(ex.getMessage());
throw forbiddenError("Unable to parse token: " + ex.getMessage());
}

final String reqInstanceId = InstanceUtils.getInstanceProperty(instanceAttributes,
InstanceProvider.ZTS_INSTANCE_ID);
if (!validateOIDCToken(attestationData, instanceDomain, instanceService, reqInstanceId, errMsg)) {
throw forbiddenError("Unable to validate Certificate Request: " + errMsg);
if (!validateOIDCToken(claimIssuer, attestationData, instanceDomain, instanceService, reqInstanceId, errMsg)) {
throw forbiddenError("Unable to validate Certificate Request: " + errMsg);
}

// validate the certificate san DNS names

StringBuilder instanceId = new StringBuilder(256);

if (!InstanceUtils.validateCertRequestSanDnsNames(instanceAttributes, instanceDomain,
instanceService, dnsSuffixes, null, null, false, instanceId, null)) {
instanceService, Arrays.stream(((String) props.get(claimIssuer).get(KEY_PROVIDER_DNS_SUFFIX)).split(",")).collect(Collectors.toSet()), null, null, false, instanceId, null)) {
throw forbiddenError("Unable to validate certificate request sanDNS entries");
}

Expand Down Expand Up @@ -245,8 +328,23 @@ boolean validateSanUri(final String sanUri) {
return true;
}

boolean validateOIDCToken(final String jwToken, final String domainName, final String serviceName,
boolean validateOIDCToken(final String claimIssuer, final String jwToken, final String domainName, final String serviceName,
final String instanceId, StringBuilder errMsg) {
if (StringUtil.isEmpty(claimIssuer)) {
errMsg.append("token does not contain required issuer claim");
return false;
}

Map<String, String> prop = props.get(claimIssuer)
.entrySet()
.stream()
.filter(entry -> entry.getValue() instanceof String)
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> (String) entry.getValue()
));

ConfigurableJWTProcessor<SecurityContext> jwtProcessor = JwtsHelper.getJWTProcessor(new JwtsSigningKeyResolver(prop.get(KEY_JWKS_URI), null));

if (jwtProcessor == null) {
errMsg.append("JWT Processor not initialized");
Expand All @@ -263,23 +361,23 @@ boolean validateOIDCToken(final String jwToken, final String domainName, final S

// verify the issuer in set to GitHub Actions

if (!githubIssuer.equals(claimsSet.getIssuer())) {
if (!prop.get(KEY_ISSUER).equals(claimsSet.getIssuer())) {
errMsg.append("token issuer is not GitHub Actions: ").append(claimsSet.getIssuer());
return false;
}

// verify that token audience is set for our service

if (!audience.equals(JwtsHelper.getAudience(claimsSet))) {
if (!prop.get(KEY_AUDIENCE).equals(JwtsHelper.getAudience(claimsSet))) {
errMsg.append("token audience is not ZTS Server audience: ").append(JwtsHelper.getAudience(claimsSet));
return false;
}

// verify that token issuer is set for our enterprise if one is configured

if (!StringUtil.isEmpty(enterprise)) {
if (!StringUtil.isEmpty((String) prop.get(KEY_ENTERPRISE))) {
final String tokenEnterprise = JwtsHelper.getStringClaim(claimsSet, CLAIM_ENTERPRISE);
if (!enterprise.equals(tokenEnterprise)) {
if (!prop.get(KEY_ENTERPRISE).equals(tokenEnterprise)) {
errMsg.append("token enterprise is not the configured enterprise: ").append(tokenEnterprise);
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"props": [
{
"provider_dns_suffix": "test-provider-suffix,another-test-provider-suffix",
"enterprise": "test-enterprise",
"audience": "test-audience",
"issuer": "test-issuer",
"jwks_uri": "https://test-jwks-uri.com/openid/v1/jwks"
}
]
}
Loading
Loading