diff --git a/security/gitlabCiJwtAuth/GitlabCiJwtAuthTest.groovy b/security/gitlabCiJwtAuth/GitlabCiJwtAuthTest.groovy new file mode 100644 index 00000000..209fb5ca --- /dev/null +++ b/security/gitlabCiJwtAuth/GitlabCiJwtAuthTest.groovy @@ -0,0 +1,10 @@ +import spock.lang.Specification + +class GitlabCiJwtAuthTest extends Specification { + def 'not implemented plugin test'() { + when: + throw new Exception("Not implemented.") + then: + false + } +} diff --git a/security/gitlabCiJwtAuth/README.md b/security/gitlabCiJwtAuth/README.md new file mode 100644 index 00000000..db3ba6b6 --- /dev/null +++ b/security/gitlabCiJwtAuth/README.md @@ -0,0 +1,44 @@ +This User Plugin adds an authentication realm that can validate gitlab CI job jwt tokens +as an auth credential. The jwt token must be sent with a `{JWT}` prefix or artifactory +will recognize it as its own jwt and intercept the login attempt before this plugin sees it. + +This effectively gives gitlab projects themselves an identity in artifactory's eyes by +leveraging signed metadata in the JWT token, and allows permissions to be dynamically attached +to requests from those identities without needing to create discrete users or credentials. + +Once a JWT auth request is validated, the plugin will then look for a group in artifactory's +DB with a `gitlab-` prefix and a postfix matching the name of each level of the `project_path` +claim's hierarchy (with any specified root node removed and all characthers lowercased). + +For example, with a `project_path: 'org/platform/k8s'` it would look for groups named +`gitlab-platform` and `gitlab-platform-k8s`, attaching any it finds to the request session. +The session would then continue with any permissions assigned to the attached groups. + +If no groups are found, then the request continues with the context of an anonymous user. + +An example of authenticating using the gitlab job jwt token for pushing docker images from +the `.gitlabci.yml` file: + +``` +stages: + - build +variables: + REGISTRY: docker-local.artifactory.example.com + REGISTRY_IMAGE: ${REGISTRY}/platform/k8s/myimage + ARTIFACTORY_URL: "https://docker-local.artifactory.example.com/" + +build: + stage: build + image: docker:latest + before_script: + - docker login -u gitlabci -p "{JWT}${CI_JOB_JWT}" $REGISTRY + script: + - docker build --tag $REGISTRY_IMAGE:$CI_COMMIT_SHA . + - docker push $REGISTRY_IMAGE:$CI_COMMIT_SHA +``` + +This would be paired with a group named `gitlab-platform-k8s` in artifactory that has a write +permission to the `docker-local` repo at path `platform/k8s/**` to limit this particular repo to +push to a unique registry path. + +jfrog plugin docs: https://www.jfrog.com/confluence/display/JFROG/User+Plugins#UserPlugins-Realms diff --git a/security/gitlabCiJwtAuth/gitlabCiJwtAuth.groovy b/security/gitlabCiJwtAuth/gitlabCiJwtAuth.groovy new file mode 100644 index 00000000..d6ea22f7 --- /dev/null +++ b/security/gitlabCiJwtAuth/gitlabCiJwtAuth.groovy @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2021 MX Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@Grab(group='com.auth0', module='java-jwt', version='3.18.1') +@Grab(group='com.auth0', module='jwks-rsa', version='0.19.0') +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTVerificationException +import com.auth0.jwk.UrlJwkProvider + +import org.artifactory.api.security.UserGroupService + +import java.security.interfaces.RSAPublicKey + +/** + * + * This plugin allows gitlab projects to authenticate directly with artifactory + * using job-specific signed JWT tokens and the claims they contain to drive + * dynamic authorization policies without the need to manage users and credentials + * between gitlab and artifactory. + * + * @author Josh Perry + * @since 08/24/2021 + */ + +@Field final String ISSUER = 'gitlab.example.com' +@Field final String IGNORE_ROOT = 'org/' + +realms { + + gitlabJwtRealm(autoCreateUsers: false) { + + authenticate { username, credentials -> + try { + // Only handle requests with the virtual user `gitlabci` + if (username != 'gitlabci') return false + + // Check if this is a gitlab JWT credential + if (!credentials.startsWith('{JWT}')) return false + def realjwt = credentials.drop(5) + + log.info('got gitlabci JWT auth request') + + // Pre-decode the (currently) untrusted token + def prejwt = JWT.decode(realjwt) + + log.debug('loading jwks provider') + // Setup the jwks validation with the key ID from the token + def provider = new UrlJwkProvider(new URL("https://$ISSUER/-/jwks")) + def jwk = provider.get(prejwt.getKeyId()) + def algo = Algorithm.RSA256((RSAPublicKey)jwk.getPublicKey(), null) + + log.debug('creating verifier') + // Create the verification policy + def verifier = JWT.require(algo) + .withIssuer(ISSUER) + .build() + + log.debug('verifying token') + // Verify and get a trusted decoded token + def jwt = verifier.verify(realjwt) + + // Get the project path without a particular root + def path = jwt.getClaim('project_path').asString().toLowerCase() + if (path.startsWith(IGNORE_ROOT)) + path = path.drop(IGNORE_ROOT.length()) + log.debug('got project path claim {}', path) + + // Find and attach any `gitlab-` groups for each level of the project path + def paths = path.split('/') + def groupsvc = ctx.beanForType(UserGroupService.class) + asSystem { + for(x in (0..paths.size()-1)) { + def groupname = "gitlab-${paths[0..x].join('-')}" + + log.debug('checking for group {}', groupname) + if(groupsvc.findGroup(groupname) != null) { + log.debug('attaching group {}', groupname) + groups += groupname + } + } + } + + return true + } catch(JWTVerificationException e) { + log.error('Error verifying jwt signature') + } catch(Exception e) { + log.error("Unexpected Error: ${e}") + } + + return false + } + + userExists { username -> + return username == 'gitlabci' + } + + } + +}