Aller au contenu
Architecture

Architecture

Modèle de sécurité

    flowchart LR
    W["Workload<br/>GitHub Actions / Azure"]

    IDP["OIDC<br/>Identity Provider"]

    subgraph STS["github-sts"]
        V["Verify workload identity"]
        A["Authorize against<br/>trust policy"]
        M["Mint least-privilege<br/>GitHub token"]
        V --> A --> M
    end

    GH["GitHub API"]

    W -- "1. Request OIDC identity" --> IDP
    IDP -- "2. OIDC JWT" --> W

    W -- "3. Exchange OIDC JWT<br/>scope + identity + app" --> V

    A -. "Load trust policy" .-> GH

    M -- "4. GitHub App authentication" --> GH
    GH -- "5. Scoped installation token" --> M

    M -- "6. Short-lived token" --> W
  

OIDC prouve qui est la charge de travail → la politique détermine ce qu’elle peut faire → GitHub émet le crédentiel.

Le jeton OIDC de la charge de travail ne devient jamais un crédentiel GitHub. github-sts valide l’identité OIDC, l’autorise par rapport à une politique de confiance, puis utilise sa propre autorité de GitHub App pour émettre un nouveau jeton d’installation indépendant.

Échange de jeton

L’échange complet implique deux émissions de jetons GitHub distinctes : une pour lire la politique de confiance, et une pour émettre le jeton de la charge de travail.

    sequenceDiagram
    autonumber

    participant W as Workload
    participant IDP as OIDC Provider
    participant STS as github-sts
    participant GH as GitHub API
    participant P as Trust Policy Repository

    W->>IDP: Request OIDC token
    IDP-->>W: OIDC JWT

    W->>STS: POST /sts/exchange<br/>OIDC JWT + scope + identity + app

    Note over STS: Authenticate workload
    STS->>IDP: OIDC discovery / JWKS (cacheable)
    IDP-->>STS: Signing keys
    STS->>STS: Verify issuer, signature,<br/>expiry, audience and replay

    Note over STS,GH: Authorize requested identity

    STS->>STS: Select configured GitHub App

    opt Policy cache miss
        STS->>STS: Sign GitHub App JWT
        STS->>GH: Mint policy-read installation token<br/>contents: read
        GH-->>STS: Internal installation token

        STS->>P: GET .github/sts/{app}/{identity}.sts.yaml
        P-->>STS: Trust policy
    end

    STS->>STS: Evaluate OIDC claims<br/>against trust policy

    alt Policy denied
        STS-->>W: 403 Forbidden
    else Policy authorized
        STS->>STS: Sign/reuse GitHub App JWT
        STS->>GH: Create installation token<br/>policy permissions + repositories
        GH-->>STS: Scoped installation token
        STS-->>W: Short-lived GitHub token
    end
  

Limites de crédentiels

Cinq crédentiels distincts existent au cours d’un échange, chacun avec un détenteur et un objectif différents :

CrédentielDétenteurObjectif
Jeton OIDCCharge de travail → github-stsProuver l’identité de la charge de travail
Clé privée de la GitHub Appgithub-sts uniquementSigner le JWT d’App (ne quitte jamais le STS)
JWT de la GitHub Appgithub-sts → API GitHubAuthentifier la GitHub App
Jeton d’installation de lecture de politiquegithub-sts → API GitHubLire .sts.yaml (contents:read)
Jeton d’installation de charge de travailgithub-sts → Charge de travailAccès GitHub autorisé

github-sts signe le JWT d’App à l’aide de la clé privée, l’utilise pour s’authentifier auprès de GitHub, puis émet des jetons d’installation. La charge de travail ne reçoit jamais le JWT d’App, la clé privée, ni le jeton de lecture de politique.

Pipeline d’autorisation

Chaque requête passe par ces vérifications, dans l’ordre. Un échec à n’importe quelle étape arrête le pipeline et renvoie un 403 avec un code d’erreur spécifique.

    flowchart TD
    A["Exchange request<br/>OIDC JWT + scope + identity + app"]

    B["Validate OIDC JWT<br/>issuer • signature • exp • iat"]
    C["Validate server audience"]
    D["Reserve JTI<br/>replay protection"]
    E["Resolve GitHub App"]
    F["Resolve trust policy"]
    G["Validate policy audience"]
    H["Evaluate subject + claims"]
    I["Derive repositories<br/>and permissions"]
    J["Mint installation token"]
    K["Return short-lived token"]

    DENY["Deny request"]
    ERROR["Upstream / internal error"]

    A --> B

    B -->|valid| C
    B -->|invalid| DENY

    C -->|match| D
    C -->|mismatch| DENY

    D -->|unused| E
    D -->|replayed| DENY

    E -->|configured| F
    E -->|unknown| DENY

    F -->|found| G
    F -->|not found| DENY
    F -->|GitHub error| ERROR

    G -->|match| H
    G -->|mismatch| DENY

    H -->|allowed| I
    H -->|denied| DENY

    I --> J

    J -->|created| K
    J -->|GitHub error| ERROR
  
ÉtapeVérificationCode d’erreur en cas d’échec
1Analyser le JWT et exiger une revendication issoidc_invalid
2Liste d’autorisation d’émetteurs : iss dans oidc.allowed_issuersoidc_invalid
3Récupération JWKS : récupérer les clés depuis le jwks_uri épinglé de l’émetteuroidc_invalid
4Signature et revendications : kid correspond à une clé JWKS, signature RS256 valide, exp et iat requises (nbf vérifié lorsqu’il est présent)oidc_invalid
5Audience au niveau du serveur : aud correspond à oidc.required_audience (si défini)audience_mismatch
6Rejeu JTI : jti non consommé dans la fenêtre jti.ttlreplay_detected
7Résolution d’App : ?app= correspond à une App configuréeapp_unknown
8Recherche de politique : fichier .sts.yaml trouvé dans le dépôt ou le dépôt de politiques d’organisationpolicy_not_found
9Audience de politique : aud du jeton correspond à audience: de la politiqueaudience_mismatch
10Évaluation des revendications : subject/subject_pattern et claim_pattern correspondentpolicy_denied
11Émission de jeton : l’API GitHub crée le jeton d’installationupstream_error

Consultez la Référence API pour la référence complète des codes d’erreur.

Résolution de politique

Pour la portée de dépôt (scope=org/repo), les politiques peuvent se trouver à deux endroits :

  • Locale au dépôt : org/repo/.github/sts/{app}/{identity}.sts.yaml
  • Niveau organisation : org/{org_policy_repo}/.github/sts/{app}/{identity}.sts.yaml

Le paramètre policy_resolution par App détermine lequel l’emporte en cas de collision :

    flowchart TD
    R["Resolve policy<br/>scope=org/repo"]

    R --> O["Check organization<br/>policy repository"]

    O -->|found| USEORG["Use centralized policy"]
    O -->|not found| LOCAL["Check repository-local policy"]

    LOCAL -->|found| USELOCAL["Use repository policy"]
    LOCAL -->|not found| DENY["Policy not found<br/>Deny"]

    USEORG --> AUTH["Evaluate OIDC claims"]
    USELOCAL --> AUTH
  

Le diagramme illustre org_first (par défaut). Autres modes :

ModeOrdreEn cas de collision
org_first (par défaut)org → repli dépôtl’organisation l’emporte
repo_first (obsolète)dépôt → repli orgle dépôt l’emporte
org_onlydépôt d’organisation uniquement, sans replin/a

Si org_policy_repo n’est pas défini, seul le dépôt demandeur est consulté, quel que soit le mode.

Structure du projet

cmd/github-sts/           Point d'entrée — démarrage du serveur, gestion des signaux
client/                   Bibliothèque cliente Go importable (échange de jeton + révocation)
internal/
  config/                 Configuration YAML + variables d'environnement
  audit/                  Journal d'audit asynchrone par canal
  handler/                Gestionnaires HTTP (exchange, health, readiness)
  server/                 Cycle de vie du serveur HTTP, middleware, arrêt gracieux
  metrics/                Registre de métriques Prometheus
  oidc/                   Validation JWT OIDC avec cache JWKS
  policy/                 Chargement des politiques de confiance et évaluation des revendications
  jti/                    Cache de rejeu JTI (en mémoire + Redis)
  ratelimit/              Limitation de débit par identité
  github/                 Authentification GitHub App, fournisseur de jetons d'installation
config/examples/          Modèles de politiques de confiance prêts à l'emploi

Mise en cache

CacheTTL par défautParamètreObjectif
Clés JWKS1h—Éviter de récupérer le JWKS à chaque requête
Politique de confiance60sGITHUBSTS_POLICY_CACHE_TTLÉviter de récupérer .sts.yaml depuis GitHub à chaque requête
ID d’installation de la GitHub App15m—Éviter de redécouvrir l’installation à chaque requête
Ensemble de rejeu JTI1hGITHUBSTS_JTI_TTLFenêtre de rejeu. Utilisez le backend redis pour les déploiements multi-réplicas.

Les jetons d’installation eux-mêmes ne sont pas mis en cache ; chaque échange émet un nouveau jeton.

Pools d’Apps et basculement

Un nom d’App logique peut être adossé à un pool de plusieurs GitHub Apps physiques (apps.<name>.instances) plutôt qu’à une seule, de sorte que le plafond effectif de limite de débit primaire GitHub pour cette App augmente avec le nombre d’instances. Voir Configuration pour le schéma. Une App à instance unique est, en interne, un pool d’une instance : toute App passe par le même chemin de sélection, donc il n’existe pas de chemin de code distinct susceptible de diverger.

Une « instance », ici, est une GitHub App physique au sein du pool d’une App logique, pas un réplica du serveur github-sts. Les deux notions sont indépendantes : un pool existe indépendamment du nombre de réplicas github-sts en cours d’exécution, et chaque réplica sélectionne dans le même pool configuré. Voir Considérations multi-réplicas ci-dessous pour la notion de réplica.

    flowchart TD
    A["Request arrives for app 'checkout'"]
    B["Advance pool cursor,<br/>build ring order"]
    C{"Reachability prober:<br/>any candidate reachable?"}
    D["Filter out instances<br/>reported unreachable"]
    E["Try next candidate<br/>in ring order"]
    F{"Result"}
    G["Return token +<br/>instance label"]
    H{"Error retryable?<br/>(network/5xx/rate-limited)"}
    I["Return error<br/>(no failover)"]
    J{"max_attempts<br/>reached?"}
    K["githubsts_app_pool_exhausted_total++"]

    A --> B --> C
    C -->|yes| D
    C -->|no, all look down| E
    D --> E
    E --> F
    F -->|success| G
    F -->|failure| H
    H -->|no| I
    H -->|yes| J
    J -->|no| E
    J -->|yes| K
  

Par requête, AppPool :

  1. Avance un curseur partagé et construit un anneau à partir de celui-ci, de sorte que les propres tentatives d’une requête parcourent des membres consécutifs plutôt que de retirer au hasard.
  2. Écarte toute instance actuellement signalée comme inaccessible par la sonde d’accessibilité, sauf si cela écarterait toutes les instances candidates, auquel cas il tente quand même l’anneau non filtré. Un échec en direct fait autorité ; un état local en cache « probablement en panne » n’en fait pas.
  3. Tente les instances candidates dans l’ordre. En cas de succès, renvoie le jeton et l’étiquette de l’instance qui a servi. En cas d’échec réessayable (réseau/timeout, 5xx, ou un 403 portant un signal de limite de débit), il passe à l’instance candidate suivante, jusqu’à rotation.max_attempts. En cas d’échec non réessayable (422 pour incohérence d’autorisations/dépôts, ou toute autre erreur non classifiée), il renvoie immédiatement : un identifiant différent ne peut pas corriger un problème d’autorisations.
  4. Si toutes les instances tentées échouent, la requête échoue et githubsts_app_pool_exhausted_total s’incrémente. C’est la métrique à surveiller par alerte, car la chute de la limite de débit d’une seule instance ne signifie pas en soi que des requêtes échouent.

Les appelants ne voient jamais quelle instance a servi une requête ; cela n’apparaît que dans l’étiquette instance des métriques et dans le journal d’audit (vide en cas d’échec de l’échange, car nommer une instance tentée arbitrairement parmi plusieurs ayant toutes échoué suggérerait à tort qu’elle est seule en cause).

La stratégie rotation.strategy par défaut est round_robin. rate_limit_aware est optionnelle : à chaque requête, elle place en fin d’ordre un membre dont l’installation est connue comme étant sous rotation.min_remaining_pct, sans le retirer. Ses relevés proviennent du sondeur de limite de débit, que chaque réplique exécute sur minuterie quel que soit le trafic qu’elle route, et des appels de github-sts faits avec ses propres jetons d’installation. Seules les réponses qui ont consommé une requête sont retenues, car pour les jetons d’installation GitHub répond aux requêtes non décomptées (GET /rate_limit, 304 Not Modified) avec un compteur inutilisé. Un relevé peut être en retard sur un épuisement rapide, et un compteur dépensé uniquement par des jetons clients n’est jamais visible : la bascule réactive sur un 403 en direct reste le filet de sécurité. Le filtre d’accessibilité de base ci-dessus s’applique aux deux stratégies.

Considérations multi-réplicas

  • Utilisez GITHUBSTS_JTI_BACKEND=redis pour que la protection contre le rejeu JTI soit partagée entre les instances. Avec memory, un attaquant qui atteint un autre réplica peut rejouer un jeton OIDC.
  • Le cache des politiques de confiance est par instance ; les instances peuvent brièvement servir des politiques différentes après une modification de .sts.yaml. Réduisez GITHUBSTS_POLICY_CACHE_TTL si cela est important.
  • /ready renvoie 503 avec {"ready":false} tant que le serveur n’est pas en service et 200 avec {"ready":true} une fois qu’il l’est ; utilisez-le pour les vérifications de santé des équilibreurs de charge.
  • L’état d’accessibilité du pool d’Apps (utilisé pour le filtre de base ci-dessus) est lui aussi par réplica, mais, contrairement à la stratégie tenant compte de la limite de débit, ce n’est pas un angle mort significatif ; voir le raisonnement ci-dessus.