Skip to content

Politique de ressource sur les secrets (l'administrateur qui ne peut plus lire)

En une phrase. Jusqu'ici, n'importe quel chemin menant à AdministratorAccess lisait les mots de passe de production. Depuis le 2026-09-06, les sept secrets refusent par défaut et ne nomment que leurs lecteurs légitimes.

Issue #193, constat AW-10 de l'audit, action de remédiation 11.


1. Les deux endroits où AWS écrit un droit

C'est la seule notion à avoir en tête, tout le reste en découle.

Une policy d'identité est collée sur qui demande : un utilisateur, un rôle. Elle dit « ce rôle peut lire ce secret ».

Une politique de ressource est collée sur ce qui est demandé : ici le secret lui-même. Elle dit « ce secret n'est lisible que par ces principals ».

Un principal, c'est simplement l'identité qui appelle : un utilisateur IAM, ou un rôle assumé par un pod ou par un humain.

Pourquoi les deux, et pas seulement la première

Parce qu'une policy d'identité ne peut pas exclure un administrateur. AdministratorAccess autorise tout, par construction. Ajouter des restrictions ailleurs n'y change rien.

La politique de ressource est le seul niveau où un administrateur s'exclut lui-même. C'est tout l'objet de #193.


2. Avant : la seule barrière était du côté du demandeur

flowchart LR
    subgraph P["Qui demande"]
        ESO["Contrôleur ESO<br/>role eso-irsa"]
        DEV["Pod de dev<br/>role app-dev-irsa"]
        ADM["Humain admin<br/>user iamadmin"]
    end

    subgraph S["fastapi-eks/prod"]
        SEC["mot de passe<br/>de PRODUCTION"]
    end

    ESO -->|"policy d'identité : oui"| SEC
    DEV -->|"policy d'identité : non"| SEC
    ADM -->|"AdministratorAccess : OUI"| SEC

    style ADM fill:#ffdddd,stroke:#cc0000
    style SEC fill:#fff3cd,stroke:#856404

Deux faiblesses, de gravité croissante.

L'administrateur passe toujours. Rien, nulle part, ne dit non à AdministratorAccess. Et l'audit avait relevé que la CI pouvait devenir administrateur en un appel (#203) : le chemin existait vraiment.

Le refus du pod de dev tient à un seul fichier. Il vient de la policy d'identité de app-dev-irsa. Élargis-la par accident, et le secret de prod devient lisible. Le secret lui-même n'a pas son mot à dire.


3. Après : le secret refuse d'abord, et n'ouvre qu'aux nommés

flowchart LR
    subgraph P["Qui demande"]
        ESO["Contrôleur ESO<br/>role eso-irsa"]
        DEV["Pod de dev<br/>role app-dev-irsa"]
        PRD["Pod de prod<br/>role app-prod-irsa"]
        ADM["Humain admin<br/>user iamadmin"]
        RDR["Rôle secrets-reader<br/>assumé volontairement"]
    end

    GATE{"Politique de ressource<br/>Deny sauf principals nommés"}

    subgraph S["fastapi-eks/prod"]
        SEC["mot de passe<br/>de PRODUCTION"]
    end

    ESO --> GATE
    DEV --> GATE
    PRD --> GATE
    ADM --> GATE
    RDR --> GATE

    GATE -->|"nommé : passe"| SEC
    GATE -.->|"non nommé : REFUS"| X["AccessDenied<br/>explicit deny in a<br/>resource-based policy"]

    style GATE fill:#d4edda,stroke:#155724
    style X fill:#ffdddd,stroke:#cc0000

Pour fastapi-eks/prod, les principals nommés sont exactement trois : le contrôleur ESO, le rôle app-prod-irsa, et le rôle secrets-reader.

iamadmin n'y est pas. app-dev-irsa non plus.


4. Deux familles de secrets, deux listes

C'est le point de conception de l'extension du 2026-09-06.

Famille Secrets Lecteurs nommés
Plateforme app, grafana, alertmanager-slack, argocd ESO, secrets-reader
Environnement dev, staging, prod ESO, secrets-reader, et le seul app-<env>-irsa correspondant

Une liste unique aurait été plus simple à écrire. Elle aurait nommé les trois rôles applicatifs sur les sept secrets, y compris sur grafana, où ils n'ont rien à faire.

Le cloisonnement aurait tenu quand même, par les policies d'identité. Mais une politique qui nomme des lecteurs qui ne lisent jamais se lit mal à l'audit, et devient fausse le jour où une policy d'identité s'élargit.

La règle à retenir

Une liste par famille dit ce qu'elle veut dire. C'est le critère de choix, pas la longueur du code.


5. Trois détails de rédaction qui décident de tout

Le refus porte sur GetSecretValue seul, jamais sur *. Un Deny * couvrirait aussi DeleteResourcePolicy, et verrouillerait le secret définitivement : plus personne ne pourrait retirer la politique, y compris celui qui vient de la poser.

Les deux formes d'ARN sont listées pour chaque rôle. Selon le contexte, AWS renvoie role/mon-role ou assumed-role/mon-role/session. N'en nommer qu'une casse ESO sur un détail de forme.

C'est un Deny conditionné, pas un NotPrincipal. La documentation AWS déconseille NotPrincipal. Effet de bord précieux : comme aucun Principal n'est nommé, AWS ne vérifie pas que les rôles existent. La politique survit donc au teardown, alors que le rôle ESO naît avec le cluster.


6. Ce que ça ne protège pas, et il faut le dire

iamadmin peut s'attribuer le rôle secrets-reader. La barrière ne rend pas la lecture impossible.

Elle la rend délibérée et tracée : il faut un geste explicite, et CloudTrail l'enregistre (#187). Sur un compte à un seul humain administrateur, c'est le maximum atteignable sans séparer les identités.

flowchart LR
    ADM["iamadmin"] -->|"lecture directe"| NO["REFUS"]
    ADM -->|"assume secrets-reader<br/>geste volontaire"| OK["lecture autorisée"]
    OK --> CT["CloudTrail<br/>AssumeRole enregistré"]

    style NO fill:#ffdddd,stroke:#cc0000
    style CT fill:#d1ecf1,stroke:#0c5460

C'est aussi pour ça que scripts/seed-secrets.sh sort ABANDON sous iamadmin : ce n'est pas une panne, c'est la barrière qui fonctionne. Il faut exporter SECRETS_READER_ROLE_ARN (voir aws-setup.md §14).


7. Vérifier — protocole et résultats du 2026-09-06

Le principe du projet s'applique : la preuve est la vérification d'absence, jamais le succès de l'apply. Quatre contrôles, dans cet ordre.

Le témoin positif d'abord

Sans lui, un AccessDenied ne prouve rien : il pourrait venir d'une identité cassée ou d'un problème réseau.

kubectl get externalsecret -A

Les sept ExternalSecret sont sortis SecretSynced / Ready=True, avec un LAST SYNC à 19 secondes après un force-sync déclenché après la pose de la politique. Un état hérité aurait affiché plusieurs minutes.

Le refus de l'administrateur

aws secretsmanager get-secret-value --secret-id fastapi-eks/app   # sous iamadmin
AccessDeniedException

Mesuré sur app, dev, argocd et grafana. Les quatre refusés.

Les lectures légitimes passent encore

export SECRETS_READER_ROLE_ARN=$(cd terraform/persistent && terraform output -raw secrets_reader_role_arn)
bash scripts/seed-secrets.sh --check
identite : arn:aws:sts::...:assumed-role/fastapi-eks-secrets-reader/...
  OK  fastapi-eks/app (2 cles)
  ... les sept

L'identité affichée en première ligne est ce qui tranche, pas la présence de la variable.

Le cloisonnement entre environnements

Le contrôle le plus parlant. Un pod jetable, monté dans fastapi-dev avec le vrai ServiceAccount de l'application, donc la vraie identité AWS de dev.

# son propre secret : doit passer
aws secretsmanager get-secret-value --secret-id fastapi-eks/dev
-> arn:aws:secretsmanager:eu-west-3:...:secret:fastapi-eks/dev-D9XyXa

# le secret de prod : doit être refusé
aws secretsmanager get-secret-value --secret-id fastapi-eks/prod
-> AccessDeniedException ... is not authorized to perform:
   secretsmanager:GetSecretValue on resource: fastapi-eks/prod
   with an explicit deny in a RESOURCE-BASED POLICY

Le mot qui compte

explicit deny in a resource-based policy. AWS dit lui-même que le refus vient de la politique posée sur le secret, et non de la policy d'identité. C'est la barrière de #193 qui mord, et rien d'autre.

La même identité, dans la même seconde, lit son propre secret. Seule la cible change.


8. Deux pièges rencontrés en montant ce test

Ils n'ont rien à voir avec les secrets, et ils coûtent du temps.

Un pod sans le bon label ne joint pas AWS du tout. Le namespace porte une default-deny-all, et la règle d'egress ne cible que app=fastapi. Le premier pod sonde a échoué sur Could not connect to the endpoint URL, ce qui ressemble à un problème AWS et n'en est pas un. Contrôle réseau validé au passage.

Poser ce label est un piège à son tour. Le Service fastapi sélectionne exactement app=fastapi : la sonde serait entrée dans ses endpoints, et api-dev aurait renvoyé des 502 le temps du test. La parade est une readinessProbe qui échoue toujours — le pod reste NotReady, donc hors des endpoints, tout en portant le label que la NetworkPolicy exige.

kubectl -n fastapi-dev get endpoints fastapi   # vérifié AVANT de continuer

Voir aussi