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: truekind 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 cliente | Requête envoyée au cluster |
|---|---|
GET /apis/project.openshift.io/v1/projects | GET /api/v1/namespaces |
GET /apis/project.openshift.io/v1/projects?watch=true | GET /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/projectrequests | POST /api/v1/namespaces |
Traductions appliquées aux objets :
Namespace→Project,NamespaceList→ProjectList(kindetapiVersionréécrits, le reste de l'objet est conservé tel quel) ;- les annotations
openshift.io/display-nameetopenshift.io/descriptionsont conservées ; - à la création, les champs
displayNameetdescriptiond'unProjectRequestdeviennent ces mêmes annotations sur leNamespace; - chaque évènement d'un
watchest 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 /apisest 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.ioetGET /apis/project.openshift.io/v1sont 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 :
- le token — un jeton à privilège minimal,
listsurnamespaceset rien d'autre — sert à énumérer les namespaces candidats ; - 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-useraccorde déjàcreate selfsubjectaccessreviewsetcreate selfsubjectrulesreviewsà tout utilisateur authentifié par défaut ; - 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: tokenRBAC 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: defaultSi 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 :
- Un seul
SelfSubjectRulesReviewest tenté d'abord. Il renvoie en un appel l'ensemble des règles RBAC de l'appelant ; celles qui portent surnamespacesavec unresourceNamesexplicite (ou sans restriction du tout) résolvent directement la visibilité des candidats correspondants, sans aucunSelfSubjectAccessReview. CommeNamespaceest une ressource cluster-scoped, seuls desClusterRoleliés viaClusterRoleBindingpeuvent y donner accès — unRoleBindingnamespaced n'y change jamais rien — donc un résultat complet (incomplete: false) de cette review couvre exactement le même calcul qu'un vraiSelfSubjectAccessReviewpour 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. - Le résultat résolu est mis en cache (Redis) par
(cluster, identité de l'appelant)pendantPROXY_VIRTUAL_LIST_FALLBACK_CACHE_TTL_SECONDS(30s par défaut,0dé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/namespacesTaille 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.