Automatisons la création des comptes Gitlab avec une CI Gitlab

Automatisons la création des comptes Gitlab avec une CI Gitlab !

dernière mise à jour : 18/12/2023

Actuellement, notre gitlab est un gitlab de taille moyenne, avec authentification LDAP. Il est possible de créer des utilisateurs en dehors du LDAP, mais c'est une action manuelle sur laquelle seul un administrateur a la main.

Comment créer des comptes sans être admin ? Actuellement, ce n'est pas possible, sauf ...

Et si nous essayons de réfléchir à une CI basée sur un token admin qui utiliserait l'API gitlab !?

La documentation de l'API gitlab à ce sujet est ici.

Les utilisateurs déjà existants feraient un commit pour rajouter leurs collaborateurs, avec pour chacun, une ligne contenant les champs requis : email, name et username.

On rajoutera les champs external à true et reset_password, ainsi l'utilisateur recevra un lien pour définir son mot de passe directement avec le mail fourni.

Premières étapes

  • on crée un dépôt "create user" sur lequel seuls les utilisateurs internes ont accès,
  • on crée un token en tant qu'administrateur avec les droits api et admin_mode (1),
  • on copie le token et on le met dans un fichier admin_token.txt,
  • on crée un gitlab-runner associé à ce dépôt (2),
  • on crée une variable gitlab contenant le token admin attaché au projet (Paramètres > CI/CD > Variables > Ajouter une variable (cocher Masquer la variable).

Commençons maintenant à faire quelques requêtes curl pour tester l'API.

On commence par la simple requête pour lister les projets visibles depuis l'extérieur, sans token :

curl -X GET "https://<GITLAB_URL>/api/v4/projects" | jq .

Maintenant, listons les utilsateurs actifs :

curl -s --header "Authorization: Bearer $(cat admin_token.txt)" -X GET "https://<GITLAB_URL>/api/v4/users?active=true" | jq .

Créons un utilisateur de test avec un mail créé pour l'occasion :

curl -s -H "Content-type: application/json" -H "Authorization: Bearer $(cat admin_token.txt)" -X POST --data '{"name": "toto", "username": "toto", "email": "[email protected]", "external": "true", "reset_password": "true"}'  "https://<GITLAB_URL>/api/v4/users"

Ok; ça fonctionne. Je supprime l'utilisateur de test, puis on va pouvoir commencer à faire les choses de manière un peu plus propre.

Création du script python associé

Le fonctionnement du dépôt sera le suivant : je rajoute des utilisateurs développeurs qui pourront faire des commits sur la branche main; ces derniers éditent le fichier users_to_create.csv et rajoutent une ligne avec le nom de la personne, son nom d'utilisateur et enfin son adresse email (les 3 champs obligatoires pour la création de compte).

users_to_create.csv :

# utilisateurs à créer, au format:
# Mon nom, username, [email protected]
John Doe, johntest, [email protected]

Ici, le script rajoutera donc John Doe, avec le username johntest et lui enverra un mail à [email protected] pour intialiser son compte et son mot de passe.

Mon script python lira le fichier et récupèrera uniquement la dernière ligne (si ce n'est pas un commentaire), validera les champs, puis enverra la requête à l'API Gitlab.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import requests
import argparse
import re
import json

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("token", type=str,
        help="Admin token value")
    args = parser.parse_args()

    token = args.token

    API_URL = "https://<GITLAB_URL>/api/v4/users"
    user = read_lastline("users_to_create.csv")
    user = sanitized_user(user)
    response = send_notif(API_URL, user, token)
    print(response)

def read_lastline(file):
    """
    :param a csv file to read
    :return user array from last line of the file
    """
    with open(file, "r", encoding="utf-8", errors="ignore") as scraped:
        final_line = scraped.readlines()[-1]
    if final_line.startswith("#"):
        raise ValueError("Last line seems to be a comment")
    user = final_line.split(",")
    return user


def sanitized_user(user):
    """
    :param user to clean
    :return user sanitized value
    """
    suser = []
    for key,val in enumerate(user):
        if key < 3:
            val = val.strip()
            if key == 2:
                val = validate_email(val)
            val = strip_dquotes(val)
            suser.append(val)
    if len(suser) == 3:
        return suser
    raise ValueError('Something went wrong while trying to read your CSV last line...')


def send_notif(API_URL, user, token):
    """
    :params str message: body of the message (there is no subject in messages)
    """
    name = user[0]
    username = user[1]
    email = user[2]
    token_str = "Bearer {}".format(token)
    headers = {"Authorization": token_str}
    values = {"name": name, "username": username, "email": email, "external": "true", "reset_password": "true"}
    response = requests.post(API_URL, headers=headers, data=values)
    return response

def strip_dquotes(s):
    """
    If a string has single or double quotes around it, remove them.
    Make sure the pair of quotes match.
    If a matching pair of quotes is not found,
    or there are less than 2 characters, return the string unchanged.
    """
    if (len(s) >= 2 and s[0] == s[-1]) and s.startswith(("'", '"')):
        return s[1:-1]
    return s

def validate_email(email):
    """
    Basic email check with regexp
    param emails
    return valid emails
    """
    regex = re.compile(r"([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])")
    if not re.fullmatch(regex, email):
        raise ValueError("Email does not match a valid email format")
    return email

if __name__ == "__main__":
    main()

Le script se décompose ainsi :

  • on récupère le token admin en variable,
  • on lit la dernière ligne du fichier users_to_create.csv (read_lastline()),
  • on vérifie un peu les champs (sanitized_user() + validate_email()) + on les nettoie (strip_dquotes()),
  • on envoie la requête à l'API Gitlab (send_notif())

Contrairement à mes 1ers tests, il faut penser à enlever "Content": "application/json" des Headers. La méthode requests.post() s'en chargera pour nous.

On teste :

python3 create_user.py

Ok, ça fonctionne ! On supprime johntest, puis ne reste plus qu'à mettre en place la CI.

Pour la CI, on lancera nos essais dans un conteneur docker python3.12.

La CI se déclenchera sur le mot useradd ou adduser (toute allusion à la création des comptes Linux étant purement fortuite !) :

.gitlab-ci.yml

image: python:3.12-bookworm
services:
  - docker:20.10.16-dind

variables:
  TOKEN: $ADMIN_TOKEN
workflow:
  rules:
    - if: ( $CI_COMMIT_TITLE =~ /adduser/ || $CI_COMMIT_TITLE =~ /useradd/ ) && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

stages:
  - test
  - deploy

before_script:
  - python3 -m venv createusers_venv
  - source createusers_venv/bin/activate
  - python3 -m pip install requests

check_env:
  stage: test
  timeout: 60 seconds
  script:
    - source createusers_venv/bin/activate
    - python3 -c "import requests"

create_user:
  stage: deploy
  timeout: 5 minutes
  script:
    - source createusers_venv/bin/activate
    - python3 create_user.py "${TOKEN}"

On constate que la CI se fait en 2 phases + celle de création de l'environnement qui s'exécutera dans chacune des phases :

  • on crée l'environnement dans un venv,
  • on teste l'import d'une librairie non présente par défaut dans l'image docker,
  • on exécute le script.

On teste en faisant un commit, et miracle, tout fonctionne !

Pour plus de supervision sur ces actions, on rajoute une intégration sur ce dépôt. Ainsi, les commits seront visibles sur l'outil interne utilisé.

Personnellement, j'attends avec impatience l'intégration Matrix (3).


Notes


1

A noter que si vous n'êtes pas en community edition, un service account serait certainement plus approprié...


2 On l'enregistre ainsi :

gitlab-runner register \
  --non-interactive \
  --url "https://<GITLAB_URL>/" \
  --token "$RUNNER_TOKEN" \
  --executor "docker" \
  --docker-image python:3.12-bookworm \
  --description "docker-runner CreateGitlabExtUsers"

3 Mais ça peut aussi se coder ! ... Et se rajouter dans la CI.