7 Jun 2023

Rotation des token d'API Gitlab

Sommaire


Introduction

Depuis la mise à jour majeur de Gitlab 16 (la dernière en date pour le moment), il n’est plus possible d’avoir des tokens d’accès d’API avec une durée de vie illimité, que ce soit personnel, par projet ou par groupe. Annoncé depuis la version 15.4, ce breaking restreint la durée de vie et applique une expiration de 365 jours sur les tokens déjà existant.

Bien que ce ne soit pas forcément ce qu’on utilise le plus, les tokens d’accès personnels (ou PAT pour Personnal Access Token) offre de multiples possibilités intéressantes et indispensables si l’on souhaite automatiser l’accès à l’API Gitlab, ou si on s’appelle Mitchell Hashimoto et qu’on utilise que des tokens pour git.

La mise en place d’une expiration de ces tokens est assez gênante pour moi. Je me sers régulièrement de ces tokens pour mettre en place la création de mes projets standardisé avec des templates personnalisés. Il me suffisait de mettre en place le token en variable de ci et c’était tout bon, mais il a fallu que je re-pense au sujet.

Les secrets dans Vault

Je gère l’intégralité de mes secrets dans vault depuis quelques années déjà et j’ai intégré depuis un moment un stage vault standard qui permet l’obtention d’un token vault jwt depuis mes CI Gitlab, exemple:

vault:
  stage: vault
  image: vault:1.13.0
  script:
    - export VAULT_TOKEN="$(vault write -field=token ${VAULT_AUTH} role=${VAULT_ROLE} jwt=${CI_JOB_JWT})"
    - |
        if [ -z ${VAULT_TOKEN} ]; then
          echo 'Vault token is empty'
          exit 1
        fi
    - echo VAULT_TOKEN="${VAULT_TOKEN}" >> vault.env
    - echo VAULT_ADDR="${VAULT_SERVER_URL}" >> vault.env
  variables:
    VAULT_ROLE: default
    VAULT_AUTH: auth/gitlab-jwt/login
    VAULT_ADDR: https://vault.secret.gouv
  artifacts:
    expire_in: 1 hour
    reports:
      dotenv: vault.env

Mes tokens Gitlab ne font pas exception à la règles, ils sont stockés dans vault dans des chemins spécifiques, ce qui me permettent une synchronisation de consommation avec mes différents outils. Mon idée était relativement simple, plutôt que d’utiliser les variables Gitlab je passe uniquement par un job de CI qui vas récupérer le token sur Vault. Je voyais deux objectifs intéressants:

  • standardiser ma consommation de token Gitlab, finit les exceptions en varialbe de CI.
  • mettre en place une rotation du token grâce à la CI et un Pipeline schedule

J’ai commencé par re-penser la structure de mon vault, et mettre en place l’ACL suivante:

path "auth/token/create" {
  capabilities = ["create", "read", "update", "list"]
}

path "secret/data/gitlab/api/token/project/" {
  capabilities = ["create", "read", "update"]
}

path "secret/data/gitlab/api/token/project//+" {
  capabilities = ["create", "read", "update"]
}

path "secret/data/gitlab/api/token/namespace//+" {
  capabilities = ["create", "read", "update"]
}

path "secret/data/gitlab/api/project//*" {
  capabilities = ["create", "read", "update"]
}

Avec ces règles d’accès, un projet qui se nommera group0/group1/project aura accès aux chemins suivants (respectivement aux règles précédantes):

  • secret/data/gitlab/api/token/project/group0/group1/project
  • secret/data/gitlab/api/token/project/group0/group1/project/<key>
  • secret/data/gitlab/api/token/namespace/group0/group1/<key>

De plus, si l’utilisateur s’appelle fe80 il pourra manipuler les secrets de tous ses projets personnelles grâce à la dernière règles secret/data/gitlab/api/token/project/*.

Enfin, afin de limiter les accès au maximum, je me suis assuré que le bound claim laisse accès uniquement à la meta-data ref_protected = "true".

Utilisation du token

Avant de parler de rotation du token, il est nécessaire de le consommer correment. Dans un premier temps on vas créer un token d’API avec le scope apiet le role mainteneur, puis le stocker dans le bon chemin vault (secret/gitlab/api/token/project/group0/group1/project) par exemple.

Ensuite dans la CI je vais mettre en place un premier stage qui vas récupérer un token vault (comme vus dans Les secrets dans Vault), puis mettre un nouveau stage qui vas récupérer le token et l’exporter dans un dotenv.

---
stages:
  - vault
  - pat

variables:
  VAULT_SERVER_URL: https://vault.secret.gouv
  VAULT_KEY: gitlab/api/token/project/${CI_PROJECT_PATH}
  VAULT_ROLE: default
  VAULT_AUTH: auth/gitlab-jwt/login

.pat:vault:
  stage: vault
  image: vault:1.13.0
  script:
    - export VAULT_ADDR="${VAULT_SERVER_URL}"
    - export VAULT_SKIP_VERIFY=true
    - export VAULT_TOKEN="$(vault write -field=token ${VAULT_AUTH} role=${VAULT_ROLE} jwt=${CI_JOB_JWT})"
    - |
        if [ -z ${VAULT_TOKEN} ]; then
          echo 'Vault token is empty'
          exit 1
        fi
    - echo VAULT_TOKEN="${VAULT_TOKEN}" >> vault.env
    - echo VAULT_ADDR="${VAULT_SERVER_URL}" >> vault.env
  artifacts:
    expire_in: 1 hour
    reports:
      dotenv: vault.env

vault:
  extends: .pat:vault


.pat:job:
  stage: pat
  image: registry.gitlab.com/gitlab-ci-utils/curl-jq:1.1.0
  needs:
    - vault  # Require the vault dotenv artefact
  script:
    # Ensure vault context is OK
    - |
        if [[ -z ${VAULT_TOKEN} ]]; then
          echo 'VAULT_TOKEN not found'
          exit 1
        elif [[ -z ${VAULT_ADDR} ]]; then
          echo 'VAULT_ADDR not found'
          exit 1
        elif [[ -z ${VAULT_KEY} ]]; then
          echo 'VAULT_KEY not found'
          exit 1
        fi

        export VAULT_URL="${VAULT_ADDR}/v1/secret/data/${VAULT_KEY}"

    # Get Actual token
    - |
        echo "Get Gitlab token on vault path ${VAULT_KEY}"

        # Get data secret on vault
        VAULT_DATA=$(
          curl -sk --request GET --header "Content-Type: application/json" \
            --header "X-Vault-Token: ${VAULT_TOKEN}" ${VAULT_URL} | jq -r '.data.data'
        )

        # Ensure key token is present
        GITLAB_TOKEN=$(jq -r '.token' <(echo ${VAULT_DATA}))
        if [[ -z ${GITLAB_TOKEN} ]] || [[ ${GITLAB_TOKEN} == "null" ]] ; then
          echo "Couldn't get Gitlab token"
          exit 1
        fi

        # Export GITLAB_TOKEN_MGT, split token to renew and token avaiable to doing this request
        # Not mandatory but just in case
        [[ -z ${GITLAB_TOKEN_MGT} ]] && export GITLAB_TOKEN_MGT="${GITLAB_TOKEN}"

    # Export token with variable GITLAB_API_PRIVATE_TOKEN on gitlab.env artefact
    - echo ${TOKEN_VAR_NAME:-GITLAB_API_PRIVATE_TOKEN}="${GITLAB_TOKEN}" >> gitlab.env
  artifacts:
    expire_in: 1 hour
    reports:
      dotenv: gitlab.env
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

pat:get-token:
  extends: .pat:job

Avec cette CI le prochain stage qui a en dépendance le stage pat aura en variable d’environment GITLAB_API_PRIVATE_TOKEN contenant le token.

Rotation du token

ATTENTION: La rotation d’un token lui donnera un TTL de 7 jours non modifiable

Enfin vient la partie qui concerne le temps d’expiration du token. Tout d’abord il est nécessaire d’avoir scope api, il n’est pas possible de re-nouveller un token en api_read. Ensuite on utilise tout simplement l’API avec /personal_access_tokens

variables:
# ... Old content
  RENEW_PAT: 'true'

.pat:job:
# ... Old content
    # Get token id
    - |
        ID=$(
          curl -sk --request GET --header "Content-Type: application/json" \
          --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" ${CI_API_V4_URL}/personal_access_tokens/self | jq -r '.id'
        )
        if [[ -z ${ID} ]] || [[ ${ID} == "null" ]] ; then
          echo "Couldn't get Token id"
          exit 1
        fi
    # Renew token and update variable if RENEW is not set or set at 'true'
    - |
        if [[ "${RENEW_PAT}" == true ]]; then
          res=$(
            curl -sk --request POST --header "Content-Type: application/json" --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" ${CI_API_V4_URL}/personal_access_tokens/${ID}/rotate
          )

          # Ensure token is correctly renew
          GITLAB_TOKEN=$(jq -r '.token' <(echo ${res}))
          if [[ -z ${GITLAB_TOKEN} ]] || [[ ${GITLAB_TOKEN} == "null" ]] ; then
            echo "Couldn't renew Gitlab token"
            echo ${res} | jq
            exit 1
          fi
          echo 'Token is correctly renew'

          # Save new token in vault and keep all unknow key
          VAULT_DATA="{\"data\": $(jq ".token=\"${GITLAB_TOKEN}\"" <(echo ${VAULT_DATA}) -c)}"
          curl -sk --request POST --header "Content-Type: application/json" \
            --header "X-Vault-Token: ${VAULT_TOKEN}" ${VAULT_URL} -XPOST -d "${VAULT_DATA}" | jq
        fi

Automatisation

Pour fini il est nécessaire de mettre en place un pipeline schedule afin de pouvoir effecuter une rotation du token. De mon côté j’ai mise en place une rotation tout les 2 jours et pas à chaque fois que la CI tourne, et j’ai décidé de mettre en place le schedule via l’API dans la CI

variables:
# ... Old content
  SCHEDULE_NAME: Renew Gitlab Token
  PIPELINE_ENDPOINT: projects/${CI_PROJECT_ID}/pipeline_schedules
  CREATE_PIPELINE: 'true'


.pat:job:
# ... Old content
    # Ensure Pipeline schedule exist
    - |
        if [[ ${CREATE_PIPELINE} == "true" ]]; then
          n=$(
            curl -sk --request GET --header "Content-Type: application/json" \
              --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" ${CI_API_V4_URL}/${PIPELINE_ENDPOINT} | \
              jq --arg SCHEDULE_NAME "${SCHEDULE_NAME}" '[.[] | select(.description == $SCHEDULE_NAME)] | length'
          )
          echo "${n} schedule pipeline match"
          if [[ ${n} -eq 0 ]]; then
            min=$(( $RANDOM % 60 + 1 ))
            hour=$(( $RANDOM % 24 + 1 ))
            echo "Create schedule pipeline"
            res=$(curl -sk --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" \
                ${CI_API_V4_URL}/${PIPELINE_ENDPOINT} \
                --form description="${SCHEDULE_NAME}" \
                --form ref="${CI_DEFAULT_BRANCH}" \
                --form cron="${min} ${hour} */2 * *" \
                --form cron_timezone="Europe/Paris")
            jq '.' <(echo $res)
            id=$(jq '.id' <(echo $res))
            if [[ $id == 'null' ]]; then
              echo 'Pipeline creation fail'
              exit 1
            fi
            curl -sk --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" \
              --request POST ${CI_API_V4_URL}/${PIPELINE_ENDPOINT}/${id}/variables \
              --form "key=RENEW_PAT" \
              --form "value=true" | jq
          fi
        fi
# ... Old content

pat:get-token:
  extends: .pat:job

Conclusion

De mon côté j’ai mis en place mon template de CI dans un repo spécifique, ce qui donne:

---
stages:
  - vault
  - pat

variables:
  VAULT_SERVER_URL: https://vault.secret.gouv
  VAULT_KEY: infra/api/token/project/${CI_PROJECT_PATH}
  VAULT_ROLE: default
  VAULT_AUTH: auth/gitlab-jwt/login
  RENEW_PAT: 'false'
  SCHEDULE_NAME: Renew Gitlab Token
  PIPELINE_ENDPOINT: projects/${CI_PROJECT_ID}/pipeline_schedules
  CREATE_PIPELINE: 'true'
  EXPORT_TOKEN: 'true'

.pat:vault:
  stage: vault
  image: vault:1.13.0
  script:
    - export VAULT_ADDR="${VAULT_SERVER_URL}"
    - export VAULT_SKIP_VERIFY=true
    - export VAULT_TOKEN="$(vault write -field=token ${VAULT_AUTH} role=${VAULT_ROLE} jwt=${CI_JOB_JWT})"
    - |
        if [ -z ${VAULT_TOKEN} ]; then
          echo 'Vault token is empty'
          exit 1
        fi
    - echo VAULT_TOKEN="${VAULT_TOKEN}" >> vault.env
    - echo VAULT_ADDR="${VAULT_SERVER_URL}" >> vault.env
  artifacts:
    expire_in: 1 hour
    reports:
      dotenv: vault.env

vault:
  extends: .pat:vault

.pat:job:
  stage: pat
  image: registry.gitlab.com/gitlab-ci-utils/curl-jq:1.1.0
  needs:
    - vault
  script:
    - |
        if [[ -z ${VAULT_TOKEN} ]]; then
          echo 'VAULT_TOKEN not found'
          exit 1
        elif [[ -z ${VAULT_ADDR} ]]; then
          echo 'VAULT_ADDR not found'
          exit 1
        elif [[ -z ${VAULT_KEY} ]]; then
          echo 'VAULT_KEY not found'
          exit 1
        fi
    - export VAULT_URL="${VAULT_ADDR}/v1/secret/data/${VAULT_KEY}"
    # Get Actual token
    - |
        echo "Get Gitlab token on vault path ${VAULT_KEY}"
        VAULT_DATA=$(
          curl -sk --request GET --header "Content-Type: application/json" \
            --header "X-Vault-Token: ${VAULT_TOKEN}" ${VAULT_URL} | jq -r '.data.data'
        )
        GITLAB_TOKEN=$(jq -r '.token' <(echo ${VAULT_DATA}))
        if [[ -z ${GITLAB_TOKEN} ]] || [[ ${GITLAB_TOKEN} == "null" ]] ; then
          echo "Couldn't get Gitlab token"
          exit 1
        fi
        [[ -z ${GITLAB_TOKEN_MGT} ]] && export GITLAB_TOKEN_MGT="${GITLAB_TOKEN}"
    # Ensure Pipeline schedule exist
    - |
        if [[ ${CREATE_PIPELINE} == "true" ]]; then
          n=$(
            curl -sk --request GET --header "Content-Type: application/json" \
              --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" ${CI_API_V4_URL}/${PIPELINE_ENDPOINT} | \
              jq --arg SCHEDULE_NAME "${SCHEDULE_NAME}" '[.[] | select(.description == $SCHEDULE_NAME)] | length'
          )
          echo "${n} schedule pipeline match"
          if [[ ${n} -eq 0 ]]; then
            min=$(( $RANDOM % 60 + 1 ))
            hour=$(( $RANDOM % 24 + 1 ))
            echo "Create schedule pipeline"
            res=$(curl -sk --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" \
                ${CI_API_V4_URL}/${PIPELINE_ENDPOINT} \
                --form description="${SCHEDULE_NAME}" \
                --form ref="${CI_DEFAULT_BRANCH}" \
                --form cron="${min} ${hour} */2 * *" \
                --form cron_timezone="Europe/Paris")
            jq '.' <(echo $res)
            id=$(jq '.id' <(echo $res))
            if [[ $id == 'null' ]]; then
              echo 'Pipeline creation fail'
              exit 1
            fi
            curl -sk --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" \
              --request POST ${CI_API_V4_URL}/${PIPELINE_ENDPOINT}/${id}/variables \
              --form "key=RENEW_PAT" \
              --form "value=true" | jq
          fi
        fi
    # Get token id
    - |
        ID=$(
          curl -sk --request GET --header "Content-Type: application/json" \
          --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" ${CI_API_V4_URL}/personal_access_tokens/self | jq -r '.id'
        )
        if [[ -z ${ID} ]] || [[ ${ID} == "null" ]] ; then
          echo "Couldn't get Token id"
          exit 1
        fi
    # Renew token and update variable
    - |
        if [[ "${RENEW_PAT}" == true ]]; then
          res=$(
            curl -sk --request POST --header "Content-Type: application/json" --header "PRIVATE-TOKEN: ${GITLAB_TOKEN_MGT}" ${CI_API_V4_URL}/personal_access_tokens/${ID}/rotate
          )
          GITLAB_TOKEN=$(jq -r '.token' <(echo ${res}))
          if [[ -z ${GITLAB_TOKEN} ]] || [[ ${GITLAB_TOKEN} == "null" ]] ; then
            echo "Couldn't renew Gitlab token"
            echo ${res} | jq
            exit 1
          fi
          echo 'Token is correctly renew'
          VAULT_DATA="{\"data\": $(jq ".token=\"${GITLAB_TOKEN}\"" <(echo ${VAULT_DATA}) -c)}"
          curl -sk --request POST --header "Content-Type: application/json" \
            --header "X-Vault-Token: ${VAULT_TOKEN}" ${VAULT_URL} -XPOST -d "${VAULT_DATA}" | jq
        fi
    - touch ${TOKEN_VAR_NAME}.env  # Export an empty file if EXPORT_TOKEN is set at false
    - >-
        [[ ${EXPORT_TOKEN} == 'true' ]] && \
          echo ${TOKEN_VAR_NAME}="${GITLAB_TOKEN}" > ${TOKEN_VAR_NAME}.env
    - exit 0  # If EXPORT_TOKEN is set ad false the last conditionnal return an exit code 1
  artifacts:
    expire_in: 1 hour
    reports:
      dotenv: ${TOKEN_VAR_NAME}.env
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

pat:get-token:
  extends: .pat:job

L’utilisation peut ensuite se faire de différentes manières.

  • Utilisation simple
---
include:
  - project: ci-templates/gitlab
    file: pat.yml

stage:
  - vault
  - pat
  - gitlab

gitlab:
  stage: gitlab
  image: registry.gitlab.com/gitlab-ci-utils/curl-jq:1.1.0
  needs:
    - pat:get-token
  script:
    - >-
        curl -s --request GET --header "Content-Type: application/json" \
            --header "PRIVATE-TOKEN: ${GITLAB_API_PRIVATE_TOKEN}" \
            ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}
  • Utilisation plus complexe
---
include:
  - project: ci-templates/gitlab
    file: pat.yml

variables:
  # Disable renew scheduler
  CREATE_PIPELINE: 'false'
  # Change environment variable name
  TOKEN_VAR_NAME: MY_NEW_VAR
  # Change vault secret path
  VAULT_KEY: >-
    infra/gitlab/api/token/project/${CI_PROJECT_PATH}/namespace-dependabot

stage:
  - vault
  - pat
  - gitlab

gitlab:
  stage: gitlab
  image: registry.gitlab.com/gitlab-ci-utils/curl-jq:1.1.0
  needs:
    - pat:get-token
  script:
    - >-
        curl -s --request GET --header "Content-Type: application/json" \
            --header "PRIVATE-TOKEN: ${MY_NEW_VAR}" \
            ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}
  • Gérer plusieurs token dans un seul job
---
include:
  - project: ci-templates/gitlab
    file: pat.yml

stage:
  - vault
  - pat
  - gitlab

pat:get-token:
  parallel:
    matrix:
      # Keep the default job
      - CREATE_PIPELINE: 'true'
      # Add a additional token to renew
      - CREATE_PIPELINE: 'false'
        EXPORT_TOKEN: 'false'
        VAULT_KEY: >-
          infra/gitlab/api/token/project/${CI_PROJECT_PATH}/namespace-dependabot

gitlab:
  stage: gitlab
  image: registry.gitlab.com/gitlab-ci-utils/curl-jq:1.1.0
  needs:
    - pat:get-token
  script:
    - >-
        curl -s --request GET --header "Content-Type: application/json" \
            --header "PRIVATE-TOKEN: ${GITLAB_API_PRIVATE_TOKEN}" \
            ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}

Tags: