ProxyAuthK8S

APIs virtuelles

Exposer une API que le cluster cible ne sert pas

Une API virtuelle est une API que le cluster cible ne sert pas, et que ProxyAuthK8S synthétise : il répond lui-même à la discovery, puis traduit les requêtes vers une API que le cluster sert réellement, et retraduit les réponses.

Le premier mapper expose project.openshift.io/v1 Project par-dessus les Namespace du cœur Kubernetes : un client habitué à oc peut donc faire kubectl get projects sur un cluster vanilla.

Activation

apiVersion: weebo.si.rs/v1
kind: ProxyKubeApi
metadata:
  name: local
  namespace: default
spec:
  cert:
    SystemRoots: true
  service:
    KubernetesService:
      name: kubernetes
      namespace: default
      port: 443
  virtual_apis:
    - kind: OpenShiftProject
      enabled: true

kind est une énumération fermée : seuls les mappers compilés dans le binaire sont acceptés, un CR demandant un mapper inconnu est rejeté à l'admission.

Tant que virtual_apis est vide (le défaut), rien n'est construit et le chemin de proxy n'est pas modifié.

Mapper OpenShiftProject

Requête clienteRequête envoyée au cluster
GET /apis/project.openshift.io/v1/projectsGET /api/v1/namespaces
GET /apis/project.openshift.io/v1/projects?watch=trueGET /api/v1/namespaces?watch=true
GET /apis/project.openshift.io/v1/projects/{name}GET /api/v1/namespaces/{name}
DELETE /apis/project.openshift.io/v1/projects/{name}DELETE /api/v1/namespaces/{name}
POST /apis/project.openshift.io/v1/projectrequestsPOST /api/v1/namespaces

Traductions appliquées aux objets :

  • Namespace → Project, NamespaceList → ProjectList (kind et apiVersion réécrits, le reste de l'objet est conservé tel quel) ;
  • les annotations openshift.io/display-name et openshift.io/description sont conservées ;
  • à la création, les champs displayName et description d'un ProjectRequest deviennent ces mêmes annotations sur le Namespace ;
  • chaque évènement d'un watch est retraduit individuellement, au fil du flux ;
  • un objet Status (une erreur de l'apiserver) traverse sans être modifié : le client doit voir la vraie erreur.

Les verbes non supportés par une ressource virtuelle sont refusés (405 + en-tête Allow) sans appel au cluster, pour qu'un verbe ne soit pas mappé sur une opération amont non voulue : projectrequests n'accepte que GET/POST, projects que GET/DELETE (ainsi un DELETE projectrequests ne peut plus atterrir sur un deletecollection de namespaces).

Discovery

  • GET /apis est transmis au cluster puis fusionné avec les groupes virtuels. Si le cluster sert déjà le groupe (un vrai cluster OpenShift derrière le proxy), le groupe réel gagne : le proxy ne masque jamais une API existante.
  • GET /apis/project.openshift.io et GET /apis/project.openshift.io/v1 sont répondus directement par le proxy, sans appel au cluster.

/openapi/v2 et /openapi/v3 ne sont pas modifiés. Les clients tolèrent qu'une ressource manque du document OpenAPI : on y perd la validation de champs côté client et kubectl explain projects, rien de plus.

Limitations connues

LIST projects — filtrage par utilisateur (list_fallback_token)

Sur OpenShift, LIST projects ne renvoie que les projets visibles par l'appelant, parce que l'apiserver les filtre par utilisateur. Un simple LIST namespaces n'a pas ce comportement : soit tout, soit rien, selon que l'appelant a le droit de lister les namespaces au niveau du cluster.

Sans configuration supplémentaire, le comportement reste ce "tout ou rien" : un utilisateur sans droits de list cluster-wide reçoit le 403 de l'apiserver là où OpenShift lui aurait renvoyé une liste (éventuellement vide), et un utilisateur qui a ces droits voit tous les namespaces sans filtrage.

En configurant list_fallback_token sur l'entrée OpenShiftProject, LIST projects est filtré comme sur OpenShift, de façon inconditionnelle : que l'appelant ait ou non des droits de list cluster-wide, seuls les namespaces sur lesquels il a individuellement le droit get sont renvoyés (jamais un 403, jamais une liste non filtrée). Le mécanisme :

  1. le token — un jeton à privilège minimal, list sur namespaces et rien d'autre — sert à énumérer les namespaces candidats ;
  2. chaque candidat est ensuite vérifié — voir la section suivante pour le détail — avec l'identité impersonnée de l'appelant ; aucun droit RBAC supplémentaire n'est nécessaire côté utilisateur, system:basic-user accorde déjà create selfsubjectaccessreviews et create selfsubjectrulesreviews à tout utilisateur authentifié par défaut ;
  3. seuls les namespaces confirmés visibles sont conservés.
spec:
  virtual_apis:
    - kind: OpenShiftProject
      enabled: true
      list_fallback_token:
        Secret:
          name: proxyauthk8s-project-lister-token
          key: token

RBAC minimal requis sur le cluster cible pour l'identité de ce token — list uniquement, rien d'autre (ni get, ni watch, ni création de reviews, ni cluster-admin) :

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: proxyauthk8s-project-lister
rules:
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: proxyauthk8s-project-lister
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: proxyauthk8s-project-lister
subjects:
  - kind: ServiceAccount
    name: proxyauthk8s-project-lister
    namespace: default

Si l'appel privilégié échoue (token mal configuré, RBAC manquant, cluster injoignable), le proxy répond 503 plutôt que de se rabattre sur une liste non filtrée (fuite de visibilité) ou une liste vide silencieuse (résultat trompeur). Si la review d'un namespace précis échoue (erreur réseau, par exemple), seul ce namespace est exclu — le reste de la liste est renvoyé normalement.

Limitation restante : WATCH n'est pas filtré, avec ou sans list_fallback_token — un flux de watch sur projects continue de retransmettre chaque évènement de namespace sans filtrage.

Coût : un SelfSubjectRulesReview d'abord, puis un cache

Vérifier chaque candidat un par un via SelfSubjectAccessReview coûterait un appel HTTP par namespace — 300 namespaces, c'est potentiellement 300 appels à chaque LIST projects. Deux mécanismes évitent ça, dans l'ordre :

  1. Un seul SelfSubjectRulesReview est tenté d'abord. Il renvoie en un appel l'ensemble des règles RBAC de l'appelant ; celles qui portent sur namespaces avec un resourceNames explicite (ou sans restriction du tout) résolvent directement la visibilité des candidats correspondants, sans aucun SelfSubjectAccessReview. Comme Namespace est une ressource cluster-scoped, seuls des ClusterRole liés via ClusterRoleBinding peuvent y donner accès — un RoleBinding namespaced n'y change jamais rien — donc un résultat complet (incomplete: false) de cette review couvre exactement le même calcul qu'un vrai SelfSubjectAccessReview pour ce cas précis. Un candidat que la review ne résout pas (règles agrégées, incomplete: true, review indisponible) retombe sur une vraie vérification individuelle — la review ne sert jamais à refuser, seulement à raccourcir un accord.
  2. Le résultat résolu est mis en cache (Redis) par (cluster, identité de l'appelant) pendant PROXY_VIRTUAL_LIST_FALLBACK_CACHE_TTL_SECONDS (30s par défaut, 0 désactive le cache). Un appelant qui reliste ses projets dans cette fenêtre — un dashboard qui rafraîchit toutes les quelques secondes, typiquement — ne redéclenche ni la review ni aucune vérification d'accès ; seule la liste de candidats elle-même (list_fallback_token) est toujours refaite à chaque requête, pour rester à jour vis-à-vis des namespaces créés/supprimés et des filtres (labelSelector, etc.) envoyés par le client.

Le cache introduit un délai — jusqu'à PROXY_VIRTUAL_LIST_FALLBACK_CACHE_TTL_SECONDS — entre la révocation d'un droit RBAC et sa prise en compte dans LIST projects. L'accès réel à un namespace précis (GET/DELETE /projects/{name}) n'est jamais concerné : il repasse toujours par une autorisation en direct sur le cluster cible, la mise en cache ne porte que sur la liste.

Le nombre de connexions simultanées pour la vérification individuelle (le repli, quand la review ne résout pas tout) est borné par PROXY_VIRTUAL_LIST_FALLBACK_CONCURRENCY (16 par défaut).

N'activez pas un mapper sur un cluster qui sert déjà le groupe

La discovery d'un groupe virtuel (GET /apis/{group} et GET /apis/{group}/{version}) est répondue localement par le mapper, sans appel au cluster. Sur un cluster qui sert déjà réellement ce groupe d'API (ex. un vrai OpenShift pour project.openshift.io), la discovery de ce groupe ne renverrait alors que la ressource virtuelle et masquerait les ressources réelles. Le mapper est prévu pour émuler le groupe sur un cluster qui ne le sert pas ; ne l'activez pas là où le groupe existe déjà. (La liste globale GET /apis, elle, fusionne et ne masque jamais un groupe réel.)

Interaction avec security_config.allowed_resources

Quand une API virtuelle réécrit une requête, la liste d'autorisation est évaluée sur les deux chemins : celui tapé par le client et celui réellement atteint sur le cluster. Autoriser /apis/project.openshift.io/v1/projects ne donne donc pas un accès détourné à /api/v1/namespaces.

Pour autoriser explicitement l'usage des Projects, les deux chemins doivent figurer dans la liste :

security_config:
  enabled: true
  allowed_resources:
    - Path:
        path: /apis/project.openshift.io/v1/projects
    - Path:
        path: /api/v1/namespaces

Taille des réponses

Une réponse traduite doit être tenue en mémoire en entier (un watch fait exception : il est traduit évènement par évènement). La limite est de 32 Mio par défaut, réglable via PROXY_VIRTUAL_MAX_BODY_BYTES ; au-delà la requête est refusée avec un 502. La lecture est bornée au fil de l'eau (et coupe court si l'amont annonce un Content-Length trop grand), donc un corps surdimensionné n'est jamais bufferisé en entier avant d'être refusé. Côté watch, une même ligne d'évènement non terminée est plafonnée à cette limite.

Ajouter un mapper

Un mapper implémente le trait VirtualApiMapper du crate libs/server/virtual_api :

pub trait VirtualApiMapper: Send + Sync {
    fn group_version(&self) -> (&str, &str);
    fn api_resources(&self) -> APIResourceList;
    fn matches(&self, path: &str) -> Option<VirtualRoute>;
    fn map_request(&self, route: &VirtualRoute) -> UpstreamRequest;
    fn map_response(&self, body: serde_json::Value) -> serde_json::Value;
    fn map_watch_event(&self, event: serde_json::Value) -> serde_json::Value;
    fn map_request_body(&self, route: &VirtualRoute, body: serde_json::Value) -> serde_json::Value;
}

Tout y est de la traduction pure : aucune I/O, aucun accès au cluster. Une fois le mapper écrit, il reste à l'ajouter à l'énumération VirtualApiKind (crate crd) et à build_mapper.

On this page