diff --git a/build.gradle b/build.gradle index e54b809..80400e2 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,9 @@ dependencies { compileOnly 'io.nextflow:nf-lang:25.04.4' implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.3' implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.80.3' + // Enables OKE Workload Identity authentication for pods running in + // Oracle Kubernetes Engine (Instance/Resource principals ship in the common SDK). + implementation 'com.oracle.oci.sdk:oci-java-sdk-addons-oke-workload-identity:3.80.3' } asciidoctor{ diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 8ad4710..ca683c8 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -39,6 +39,70 @@ tenancy=ocid1.tenancy.oc1..aaaaaaaayyyyyyyyyyyyyyyyyyyyyyyyy region=us-ashburn-1 ---- +== Authentication + +By default (`authType = 'auto'`) the plugin uses inline API key credentials when +they are supplied in the config, otherwise it falls back to the local +`~/.oci/config` file — the original behaviour. For Kubernetes pods running in OKE +you can instead use Workload Identity, a credential-less method that avoids +shipping API keys into your containers. + +Select a method with the `oci.authType` config option (or the `OCI_AUTH_TYPE` +environment variable): + +[cols="1,3", options="header"] +|=== +| `authType` | Description + +| `auto` (default) +| Inline API key credentials when supplied, otherwise the local `~/.oci/config` file. + +| `workload_identity` +| *OKE Workload Identity* — the recommended method for pods running in an Oracle + Kubernetes Engine (enhanced) cluster. No API keys are needed; OKE mounts a + service account token into the pod and the plugin federates it to an OCI + principal. + +| `simple` +| Inline API key credentials supplied directly in the Nextflow config. + +| `config_file` +| The standard `~/.oci/config` file. +|=== + +=== Kubernetes / OKE (recommended) + +For workloads running in OKE, use Workload Identity so no long-lived credentials +are stored in the pod. First grant access by creating the IAM policy and an +enhanced-cluster service account (see the +https://docs.oracle.com/en-us/iaas/Content/ContEng/Tasks/contenggrantingworkloadaccesstoresources.htm[OCI docs]), +then configure the plugin: + +.nextflow.config +[source] +---- +oci { + authType = 'workload_identity' + region = 'us-ashburn-1' +} +---- + +The service account token is read from the standard OKE pod mount path by default. +Override it with `oci.tokenPath` if your cluster mounts it elsewhere. + +=== Inline credentials + +[source] +---- +oci { + region = 'us-ashburn-1' + tenantId = 'ocid1.tenancy.oc1..aaaa...' + userId = 'ocid1.user.oc1..aaaa...' + fingerprint = '12:34:56:78:90:ab:cd:ef:12:34:56:78:90:ab:cd:ef' + privateKey = '-----BEGIN PRIVATE KEY-----\n...' +} +---- + == ObjectStorage FileSystem The nf-oci plugin brings native Oracle Cloud Infrastructure support to the Nextflow ecosystem. It implements a dedicated file system provider that allows users to interact with OCI Object Storage using a standardized protocol. diff --git a/src/main/groovy/incsteps/plugin/oci/client/OciClient.groovy b/src/main/groovy/incsteps/plugin/oci/client/OciClient.groovy index e357341..53984fd 100644 --- a/src/main/groovy/incsteps/plugin/oci/client/OciClient.groovy +++ b/src/main/groovy/incsteps/plugin/oci/client/OciClient.groovy @@ -2,6 +2,8 @@ package incsteps.plugin.oci.client import com.oracle.bmc.Region +import com.oracle.bmc.auth.AbstractAuthenticationDetailsProvider +import com.oracle.bmc.auth.RegionProvider import com.oracle.bmc.http.client.jersey3.Jersey3HttpProvider import com.oracle.bmc.objectstorage.ObjectStorage import com.oracle.bmc.objectstorage.ObjectStorageClient @@ -38,14 +40,30 @@ class OciClient { } private ObjectStorageClient getObjectStorageClient(){ - final useRegion = ociConfig.region final provider = ociConfig.authentificationProvider?.provider return ObjectStorageClient.builder() .httpProvider(Jersey3HttpProvider.instance) - .region(Region.fromRegionCode(useRegion)) + .region(resolveRegion(provider)) .build(provider) } + /** + * Determines the region for the client. An explicitly configured region always + * wins; otherwise, when the provider advertises one (e.g. OKE Workload Identity) + * that region is used, falling back to the default region only as a last resort. + */ + private Region resolveRegion(AbstractAuthenticationDetailsProvider provider){ + final configured = ociConfig.configuredRegion + if( configured ) + return Region.fromRegionCode(configured) + if( provider instanceof RegionProvider ) { + final region = ((RegionProvider) provider).region + if( region ) + return region + } + return Region.US_PHOENIX_1 + } + private T runWithPermit(Supplier action) { try { if (semaphore != null) semaphore.acquire(); diff --git a/src/main/groovy/incsteps/plugin/oci/config/AuthentificationDetailProvider.groovy b/src/main/groovy/incsteps/plugin/oci/config/AuthentificationDetailProvider.groovy index 3d32968..ad73719 100644 --- a/src/main/groovy/incsteps/plugin/oci/config/AuthentificationDetailProvider.groovy +++ b/src/main/groovy/incsteps/plugin/oci/config/AuthentificationDetailProvider.groovy @@ -5,56 +5,124 @@ import com.oracle.bmc.Region import com.oracle.bmc.auth.AbstractAuthenticationDetailsProvider import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider +import com.oracle.bmc.auth.okeworkloadidentity.OkeWorkloadIdentityAuthenticationDetailsProvider import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j import java.nio.charset.StandardCharsets +/** + * Resolves the OCI {@link AbstractAuthenticationDetailsProvider} used by the plugin. + * + * The method is selected via the {@code oci.authType} config option (or the + * {@code OCI_AUTH_TYPE} environment variable): + * + * + */ +@Slf4j @CompileStatic class AuthentificationDetailProvider { + static final String AUTH_AUTO = 'auto' + static final String AUTH_SIMPLE = 'simple' + static final String AUTH_CONFIG_FILE = 'config_file' + static final String AUTH_WORKLOAD_IDENTITY = 'workload_identity' + final AbstractAuthenticationDetailsProvider provider - AuthentificationDetailProvider(Map otps, String region){ - provider = build(otps, region) + AuthentificationDetailProvider(Map opts, String region) { + provider = build(opts, region) } AbstractAuthenticationDetailsProvider getProvider() { this.provider } - private AbstractAuthenticationDetailsProvider buildEnvProvider(Map otps, String region){ - if( !otps.containsKey("tenantId") ) - return null - if( !otps.containsKey("userId") ) - return null - if( !otps.containsKey("fingerprint") ) - return null - if( !otps.containsKey("privateKey") ) + private AbstractAuthenticationDetailsProvider build(Map opts, String region) { + final authType = normalizeAuthType(opts.get('authType')) + switch (authType) { + case AUTH_SIMPLE: + return requireProvider(buildSimpleProvider(opts, region), AUTH_SIMPLE) + case AUTH_CONFIG_FILE: + return buildConfigFileProvider(opts) + case AUTH_WORKLOAD_IDENTITY: + return buildWorkloadIdentityProvider(opts, region) + default: + return autoDetect(opts, region) + } + } + + /** Inline API key credentials when supplied, otherwise the local config file. */ + private AbstractAuthenticationDetailsProvider autoDetect(Map opts, String region) { + return buildSimpleProvider(opts, region) ?: buildConfigFileProvider(opts) + } + + protected boolean hasSimpleCredentials(Map opts) { + opts.get('tenantId') && opts.get('userId') && opts.get('fingerprint') && opts.get('privateKey') + } + + private AbstractAuthenticationDetailsProvider buildSimpleProvider(Map opts, String region) { + if (!hasSimpleCredentials(opts)) return null - final String privKey = otps.get("privateKey").toString() - return SimpleAuthenticationDetailsProvider.builder() - .tenantId(otps.get("tenantId").toString()) - .userId(otps.get("userId").toString()) - .fingerprint(otps.get("fingerprint").toString()) - .region(Region.fromRegionCode(region)) - .privateKeySupplier(() -> { - return new ByteArrayInputStream(privKey.getBytes(StandardCharsets.UTF_8)) - }) - .build(); - } - - private AbstractAuthenticationDetailsProvider buildDefaultProvider(Map opts){ - final ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault(opts.get("profile")?.toString()) - final ConfigFileAuthenticationDetailsProvider provider = new ConfigFileAuthenticationDetailsProvider(configFile) + final String privKey = opts.get('privateKey').toString() + final builder = SimpleAuthenticationDetailsProvider.builder() + .tenantId(opts.get('tenantId').toString()) + .userId(opts.get('userId').toString()) + .fingerprint(opts.get('fingerprint').toString()) + .privateKeySupplier(() -> new ByteArrayInputStream(privKey.getBytes(StandardCharsets.UTF_8))) + if (region) + builder.region(Region.fromRegionCode(region)) + return builder.build() + } + + private AbstractAuthenticationDetailsProvider buildConfigFileProvider(Map opts) { + final ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault(opts.get('profile')?.toString()) + return new ConfigFileAuthenticationDetailsProvider(configFile) + } + + private AbstractAuthenticationDetailsProvider buildWorkloadIdentityProvider(Map opts, String region) { + final builder = OkeWorkloadIdentityAuthenticationDetailsProvider.builder() + // The OKE SDK reads the service account token from the standard pod mount path + // by default; only override it when the user points us elsewhere. + final tokenPath = opts.get('tokenPath')?.toString() + if (tokenPath) + builder.tokenPath(tokenPath) + if (region) + builder.region(Region.fromRegionCode(region)) + return builder.build() + } + + private static AbstractAuthenticationDetailsProvider requireProvider(AbstractAuthenticationDetailsProvider provider, String authType) { + if (!provider) + throw new IllegalStateException("OCI authType '${authType}' was requested but required credentials are missing") return provider } - private AbstractAuthenticationDetailsProvider build(Map opts, String region){ - def ret = buildEnvProvider(opts, region) - if( !ret ){ - ret = buildDefaultProvider(opts) + /** Resolves the configured auth type to one of the supported canonical values. */ + protected static String normalizeAuthType(Object raw) { + if (!raw) + return AUTH_AUTO + final value = raw.toString().trim().toLowerCase() + switch (value) { + case AUTH_AUTO: + return AUTH_AUTO + case AUTH_SIMPLE: + return AUTH_SIMPLE + case AUTH_CONFIG_FILE: + return AUTH_CONFIG_FILE + case AUTH_WORKLOAD_IDENTITY: + return AUTH_WORKLOAD_IDENTITY + default: + throw new IllegalArgumentException("Unknown OCI authType '${raw}'. Valid values: " + + "auto, simple, config_file, workload_identity") } - return ret } - } diff --git a/src/main/groovy/incsteps/plugin/oci/config/OciConfig.groovy b/src/main/groovy/incsteps/plugin/oci/config/OciConfig.groovy index 61a4b2b..70ae15c 100644 --- a/src/main/groovy/incsteps/plugin/oci/config/OciConfig.groovy +++ b/src/main/groovy/incsteps/plugin/oci/config/OciConfig.groovy @@ -33,6 +33,22 @@ class OciConfig implements ConfigScope{ """) final String profile + @ConfigOption + @Description(""" + Authentication method to use. One of `auto` (default, uses inline API key + credentials when supplied otherwise `~/.oci/config`), `workload_identity` + (OKE Workload Identity, recommended for Kubernetes pods), `simple` + (inline API key) or `config_file` (`~/.oci/config`). + """) + final String authType + + @ConfigOption + @Description(""" + Path to the Kubernetes service account token used by `workload_identity` + authentication. Defaults to the standard OKE pod mount path. + """) + final String tokenPath + OciConfig(){ this([:]) } @@ -40,8 +56,13 @@ class OciConfig implements ConfigScope{ OciConfig(Map opts){ this.profile = getOciProfile0(SysEnv.get(), opts) this.region = getOciRegion(SysEnv.get(), opts) + this.authType = getOciAuthType(SysEnv.get(), opts) + this.tokenPath = opts.tokenPath as String this.objectStorageConfig = new OciObjectStorageConfig( (Map)opts.storage ?: Collections.emptyMap()) - this.authentificationProvider = new AuthentificationDetailProvider(opts, region) + // make the resolved auth settings visible to the provider regardless of their source + final Map authOpts = new LinkedHashMap(opts) + authOpts.authType = authType + this.authentificationProvider = new AuthentificationDetailProvider(authOpts, region) } AuthentificationDetailProvider getAuthentificationProvider(){ @@ -52,6 +73,11 @@ class OciConfig implements ConfigScope{ return region ?: Region.US_PHOENIX_1.regionCode } + /** The region explicitly set via config/env, or {@code null} if unset. */ + String getConfiguredRegion(){ + return region + } + static protected String getOciProfile0(Map env, Map config) { final profile = config?.profile as String @@ -68,6 +94,19 @@ class OciConfig implements ConfigScope{ } + static protected String getOciAuthType(Map env, Map config) { + + final authType = config?.authType as String + if( authType ) + return authType + + if( env?.containsKey('OCI_AUTH_TYPE')) + return env.get('OCI_AUTH_TYPE') + + return null + } + + static protected String getOciRegion(Map env, Map config) { def home = Paths.get(System.properties.get('user.home') as String) diff --git a/src/test/groovy/incsteps/plugin/oci/config/AuthProviderTest.groovy b/src/test/groovy/incsteps/plugin/oci/config/AuthProviderTest.groovy index 5793792..5514ccb 100644 --- a/src/test/groovy/incsteps/plugin/oci/config/AuthProviderTest.groovy +++ b/src/test/groovy/incsteps/plugin/oci/config/AuthProviderTest.groovy @@ -1,9 +1,11 @@ package incsteps.plugin.oci.config import com.oracle.bmc.Region +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider import incsteps.plugin.oci.nio.PrivKeyUtil import spock.lang.IgnoreIf import spock.lang.Specification +import spock.lang.Unroll class AuthProviderTest extends Specification{ @@ -22,7 +24,56 @@ class AuthProviderTest extends Specification{ def provider = detailProvider.provider then: - provider + provider instanceof SimpleAuthenticationDetailsProvider + } + + void "explicit simple authType builds a simple provider"(){ + given: + def config = [ + authType: 'simple', + tenantId:'test', + userId:'test', + fingerprint:'test', + privateKey: PrivKeyUtil.generatePrivateKeyPem() + ] + + when: + def detailProvider = new AuthentificationDetailProvider(config, Region.US_PHOENIX_1.regionCode) + + then: + detailProvider.provider instanceof SimpleAuthenticationDetailsProvider + } + + void "explicit simple authType fails when credentials are missing"(){ + when: + new AuthentificationDetailProvider([authType:'simple'], Region.US_PHOENIX_1.regionCode) + + then: + thrown(IllegalStateException) + } + + void "unknown authType is rejected"(){ + when: + new AuthentificationDetailProvider([authType:'nonsense'], Region.US_PHOENIX_1.regionCode) + + then: + thrown(IllegalArgumentException) + } + + @Unroll + void "normalizes authType '#raw' to '#expected'"(){ + expect: + AuthentificationDetailProvider.normalizeAuthType(raw) == expected + + where: + raw | expected + null | AuthentificationDetailProvider.AUTH_AUTO + '' | AuthentificationDetailProvider.AUTH_AUTO + 'auto' | AuthentificationDetailProvider.AUTH_AUTO + 'simple' | AuthentificationDetailProvider.AUTH_SIMPLE + 'config_file' | AuthentificationDetailProvider.AUTH_CONFIG_FILE + 'workload_identity' | AuthentificationDetailProvider.AUTH_WORKLOAD_IDENTITY + 'WORKLOAD_IDENTITY' | AuthentificationDetailProvider.AUTH_WORKLOAD_IDENTITY } @IgnoreIf({ !new File(System.getProperty("user.home")+"/.oci/config").exists() }) diff --git a/src/test/groovy/incsteps/plugin/oci/config/OciConfigTest.groovy b/src/test/groovy/incsteps/plugin/oci/config/OciConfigTest.groovy index a04b2a9..b766381 100644 --- a/src/test/groovy/incsteps/plugin/oci/config/OciConfigTest.groovy +++ b/src/test/groovy/incsteps/plugin/oci/config/OciConfigTest.groovy @@ -13,4 +13,24 @@ class OciConfigTest extends Specification{ region } + void "authType is resolved from config"(){ + expect: + OciConfig.getOciAuthType([:], [authType:'workload_identity']) == 'workload_identity' + } + + void "authType falls back to OCI_AUTH_TYPE env"(){ + expect: + OciConfig.getOciAuthType([OCI_AUTH_TYPE:'config_file'], [:]) == 'config_file' + } + + void "config authType takes precedence over env"(){ + expect: + OciConfig.getOciAuthType([OCI_AUTH_TYPE:'config_file'], [authType:'workload_identity']) == 'workload_identity' + } + + void "authType defaults to null when unset"(){ + expect: + OciConfig.getOciAuthType([:], [:]) == null + } + }