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.
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:
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"
.
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 api
et 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.
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
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
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.
---
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}
---
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}
---
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}