Aller au contenu

Installation et configuration

Ce chapitre couvre le déploiement pas-a-pas de la plateforme documentaire : installation de MkDocs Material, creation du plugin classification-filter, configuration du pipeline CI multi-build, mise en place du reverse proxy Caddy et integration Keycloak pour le contrôle d'accès par classification.


Pre-requis

Composant Version minimum Vérification
Python 3.10+ python --version
pip 23+ pip --version
Git 2.40+ git --version
Caddy 2.7+ caddy version
Keycloak 22+ (operationnel) curl https://iam.company.io/health
Gitea Actions Gitea 1.21+ ou équivalent CI Voir documentation CI

Installation MkDocs Material

pip install mkdocs-material
pip install mkdocs-minify-plugin
mkdocs --version
# mkdocs, version 1.5.x from ...

Isoler les dependances dans un environnement virtuel est recommande pour eviter les conflits : python -m venv .venv && source .venv/bin/activate, puis reinstaller les paquets ci-dessus.


Plugin classification-filter

Le plugin lit la metadonnee classification dans le front matter de chaque page et exclut les pages dont le niveau depasse la cible du build (variable MKDOCS_CLASSIFICATION).

Structure du paquet

mkdocs-classification-filter/
├── setup.py
└── mkdocs_classification_filter/
    ├── __init__.py
    └── plugin.py

setup.py

from setuptools import setup, find_packages

setup(
    name="mkdocs-classification-filter",
    version="0.1.0",
    packages=find_packages(),
    entry_points={
        "mkdocs.plugins": [
            "classification-filter = mkdocs_classification_filter.plugin:ClassificationFilterPlugin",
        ]
    },
    install_requires=["mkdocs>=1.5"],
)

plugin.py

import os
import logging
from mkdocs.plugins import BasePlugin

log = logging.getLogger("mkdocs.plugins.classification-filter")

LEVELS = {"public": 0, "interne": 1, "confidentiel": 2, "restreint": 3}


class ClassificationFilterPlugin(BasePlugin):

    def on_files(self, files, config):
        target = os.environ.get("MKDOCS_CLASSIFICATION", "public").lower()
        if target not in LEVELS:
            raise ValueError(
                f"MKDOCS_CLASSIFICATION='{target}' invalide. "
                f"Valeurs autorisees : {', '.join(LEVELS.keys())}"
            )
        target_level = LEVELS[target]
        log.info(f"Classification filter: build niveau '{target}' (<= {target_level})")

        to_remove = []
        for file in files:
            if not file.src_path.endswith(".md"):
                continue
            page_level = self._get_level(file, config)
            if page_level is not None and page_level > target_level:
                log.info(f"  Exclusion: {file.src_path}")
                to_remove.append(file)

        for file in to_remove:
            files.remove(file)

        return files

    def on_nav(self, nav, config, files):
        self._clean_nav(nav.items)
        return nav

    def on_env(self, env, config, files):
        target = os.environ.get("MKDOCS_CLASSIFICATION", "public").lower()
        env.globals["classification_level"] = target
        return env

    def _get_level(self, file, config):
        import yaml

        full_path = os.path.join(config["docs_dir"], file.src_path)
        try:
            with open(full_path, "r", encoding="utf-8") as f:
                content = f.read()
        except (OSError, IOError):
            return None

        if not content.startswith("---"):
            return LEVELS["public"]

        parts = content.split("---", 2)
        if len(parts) < 3:
            return LEVELS["public"]

        try:
            meta = yaml.safe_load(parts[1])
        except yaml.YAMLError:
            return LEVELS["public"]

        if not meta or "classification" not in meta:
            return LEVELS["public"]

        classification = str(meta["classification"]).lower()
        if classification not in LEVELS:
            raise ValueError(
                f"classification '{classification}' invalide dans {file.src_path}. "
                f"Valeurs autorisees : {', '.join(LEVELS.keys())}"
            )
        return LEVELS[classification]

    def _clean_nav(self, items):
        to_remove = []
        for item in items:
            if hasattr(item, "children") and item.children:
                self._clean_nav(item.children)
                if not item.children:
                    to_remove.append(item)
            elif hasattr(item, "file") and item.file is None:
                to_remove.append(item)
        for item in to_remove:
            items.remove(item)

Installation locale

cd mkdocs-classification-filter
pip install -e .

Configuration mkdocs.yml

Ajouter classification-filter avant minify dans la section plugins :

plugins:
  - search:
      lang: fr
  - classification-filter
  - blog:
      blog_dir: blog
  - minify:
      minify_html: true

Chaque page qui doit etre filtree declare sa classification dans son front matter :

---
title: Procedure interne
classification: interne
---

Les pages sans champ classification sont traitees comme public par defaut.


Test local

# Build public
MKDOCS_CLASSIFICATION=public mkdocs build --site-dir site-public

# Build interne
MKDOCS_CLASSIFICATION=interne mkdocs build --site-dir site-interne

# Build confidentiel
MKDOCS_CLASSIFICATION=confidentiel mkdocs build --site-dir site-confidentiel

# Build restreint (toutes les pages)
MKDOCS_CLASSIFICATION=restreint mkdocs build --site-dir site-restreint

Variable non definie

Si MKDOCS_CLASSIFICATION n'est pas definie, le plugin utilise public par defaut. Le pipeline CI doit toujours positionner la variable explicitement.


Pipeline CI (Gitea Actions)

Le pipeline construit les quatre artefacts en parallele via une matrice, puis les deploie dans les sous-répertoires correspondants de la racine web.

# .gitea/workflows/docs.yml
name: docs-multi-build

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build ${{ matrix.level }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        level: [public, interne, confidentiel, restreint]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install mkdocs-material mkdocs-minify-plugin
          pip install -e mkdocs-classification-filter/

      - name: Build
        env:
          MKDOCS_CLASSIFICATION: ${{ matrix.level }}
        run: mkdocs build --site-dir site-${{ matrix.level }}

      - uses: actions/upload-artifact@v4
        with:
          name: docs-${{ matrix.level }}
          path: site-${{ matrix.level }}/
          retention-days: 7

  deploy:
    name: Deploy
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/download-artifact@v4
        with:
          path: artifacts/

      - name: Deploy via rsync
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
        run: |
          echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
          for level in public interne confidentiel restreint; do
            rsync -avz --delete \
              -e "ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no" \
              artifacts/docs-${level}/ \
              ${DEPLOY_USER}@${DEPLOY_HOST}:/var/www/docs/${level}/
          done
          rm -f /tmp/deploy_key

Reverse proxy Caddy

Caddy route chaque requête vers le build correspondant au niveau de classification transmis par oauth2-proxy dans le header X-Doc-Classification.

# /etc/caddy/Caddyfile
{
    email admin@company.io
}

docs.company.io {

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        -Server
    }

    forward_auth oauth2-proxy:4180 {
        uri /oauth2/auth
        copy_headers X-Forwarded-User X-Doc-Classification
    }

    @restreint    { header X-Doc-Classification restreint }
    @confidentiel { header X-Doc-Classification confidentiel }
    @interne      { header X-Doc-Classification interne }

    handle @restreint    { root * /var/www/docs/restreint;    file_server }
    handle @confidentiel { root * /var/www/docs/confidentiel; file_server }
    handle @interne      { root * /var/www/docs/interne;      file_server }
    handle               { root * /var/www/docs/public;       file_server }

    log {
        output file /var/log/caddy/docs-access.log
        format json
    }
}

oauth2-proxy

oauth2-proxy valide le token OIDC aupres de Keycloak et enrichit la requête avec le claim doc_classification converti en header X-Doc-Classification.


Configuration Keycloak

Créer le client OIDC

Dans la console Keycloak, realm company, créer un client docs-platform :

  • Client type : OpenID Connect
  • Client authentication : On (flux confidentiel)
  • Valid redirect URIs : https://docs.company.io/oauth2/callback

Protocol mapper pour doc_classification

Dans Clients > docs-platform > Client scopes > Add mapper > By configuration, choisir User Attribute :

Champ Valeur
Name doc-classification
User attribute doc_classification
Token Claim Name doc_classification
Claim JSON Type String
Add to access token On
Add to userinfo On

Groupes de classification

Créer trois groupes dans Groups et positionner l'attribut doc_classification via Attributes :

Groupe Attribut doc_classification
doc-interne interne
doc-confidentiel confidentiel
doc-restreint restreint

Les utilisateurs sans groupe obtiennent la valeur par defaut public (fallback Caddy).


Test de bout en bout

# Verifier que les quatre builds sont deployes
for level in public interne confidentiel restreint; do
  echo -n "Build $level : "
  curl -sf "https://docs.company.io/index.html" \
    -H "X-Doc-Classification: $level" -o /dev/null && echo "OK" || echo "KO"
done

# Verifier qu'une page confidentielle est absente du build public
curl -sf "https://docs.company.io/procedure-confidentielle/" \
  -H "X-Doc-Classification: public" \
  -o /dev/null && echo "KO: page visible" || echo "OK: page absente"

# Verifier le claim OIDC
curl -s "https://docs.company.io/oauth2/userinfo" \
  -H "Cookie: _oauth2_proxy=<token>" | jq '.doc_classification'
Vérification Attendu
mkdocs --version Version affichee
Build sans erreur d'import Plugin charge
Page interne absente du build public Fichier absent
HTTPS docs.company.io HTTP/2 200
Accès sans cookie Redirect vers Keycloak
Claim doc_classification present Valeur conforme au groupe

Synthese

La plateforme est operationnelle apres quatre étapes : installation de MkDocs Material et du plugin classification-filter, configuration du pipeline CI qui produit quatre artefacts distincts, mise en place du reverse proxy Caddy qui route selon le claim OIDC, et parametrage Keycloak qui attribue le niveau correct à chaque utilisateur. Le chapitre suivant couvre l'integration avec les outils de l'entreprise (SSO, monitoring, gestion des accès en self-service).