diff --git a/hack/apiref-e2e/README.md b/hack/apiref-e2e/README.md new file mode 100644 index 0000000..7140bde --- /dev/null +++ b/hack/apiref-e2e/README.md @@ -0,0 +1,40 @@ +# apiRef end-to-end test (real authn + real snowplow on kind) + +Proves the CDC `apiRef` status source works against the **real** Krateo platform — not stubs: + +``` +projected SA token (file) + ─ authn.Client.Token ─▶ authn POST /serviceaccount/login (TokenReview → JWT + clientconfig) + ─ snowplow.Client.Resolve(Bearer JWT, extras) ─▶ snowplow GET /call (resolves a RESTAction) + ─▶ RESTAction echoes the request extras ─▶ .api.echo.args +``` + +The test exercises the actual CDC client code (`internal/authn`, `internal/snowplow`, +`internal/composition.SnowplowAPIResolver`) and asserts that both the **static** extras +(`region`, from the CompositionDefinition's `apiRef.extras`) and the **per-instance** extras +(`compositionName`/`compositionNamespace`/`compositionId`, injected by the resolver, request-wins) +round-trip through the authn-issued JWT and snowplow's RESTAction resolution. + +## Run + +```bash +# needs: docker, kind, kubectl, go; ../authn (main) and ../snowplow checkouts +hack/apiref-e2e/run.sh +``` + +`run.sh` creates a kind cluster, builds authn + snowplow:1.1.1 images from source, deploys them +(+ an in-cluster `go-httpbin` echo server) with a shared `JWT_SIGN_KEY`, applies the fixtures +(test ServiceAccount, its `serviceaccount.authn.krateo.io` allowlist mapping, the `status-sources` +RESTAction, group RBAC), mints an audience-`authn` token via TokenRequest, port-forwards both +services, and runs the tagged test: + +```bash +go test -tags e2e ./internal/composition/ -run TestE2E_ApiRefChain -v +``` + +## What success looks like + +- authn log: `serviceaccount auth succeeded username=cdc-e2e groups=krateo:cdc-e2e` +- snowplow log: `base dict for api resolver dict={compositionId,compositionName,compositionNamespace,region}` + then `RESTAction successfully resolved name=status-sources` +- test: `.api.echo.args` = `{cn: demo-app, cns: apps, cid: uid-e2e-123, region: eu}` diff --git a/hack/apiref-e2e/manifests/authn-deploy.yaml b/hack/apiref-e2e/manifests/authn-deploy.yaml new file mode 100644 index 0000000..20d4caf --- /dev/null +++ b/hack/apiref-e2e/manifests/authn-deploy.yaml @@ -0,0 +1,88 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: demo-system +--- +kind: ServiceAccount +apiVersion: v1 +metadata: + name: authn + namespace: demo-system +--- +apiVersion: v1 +kind: Service +metadata: + name: authn + namespace: demo-system +spec: + selector: + app: authn + type: NodePort + ports: + - name: http + port: 8082 + targetPort: http + protocol: TCP + nodePort: 30082 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: secrets-admin + namespace: demo-system +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: secrets-admin-binding + namespace: demo-system +subjects: +- kind: ServiceAccount + name: authn + namespace: demo-system +roleRef: + kind: Role + name: secrets-admin + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authn + namespace: demo-system + labels: + app: authn +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: authn + template: + metadata: + labels: + app: authn + spec: + serviceAccountName: authn + containers: + - name: authn + image: authn:e2e + imagePullPolicy: Never + args: + - --debug=true + - --kubeconfig-server-url=https://kubernetes.default.svc + - --namespace=demo-system + - --jwt-sign-key=AbbraCadabbra + - --serviceaccount-audience=authn + ports: + - name: http + containerPort: 8082 diff --git a/hack/apiref-e2e/manifests/authn-rbac.yaml b/hack/apiref-e2e/manifests/authn-rbac.yaml new file mode 100644 index 0000000..918a703 --- /dev/null +++ b/hack/apiref-e2e/manifests/authn-rbac.yaml @@ -0,0 +1,36 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: csr-admin +rules: +- apiGroups: ["certificates.k8s.io"] + resources: ["certificatesigningrequests"] + verbs: ["create", "get", "list", "watch", "approve", "delete", "update"] +- apiGroups: ["certificates.k8s.io"] + resources: ["certificatesigningrequests/approval"] + verbs: ["update"] +- apiGroups: ["certificates.k8s.io"] + resources: ["signers"] + resourceNames: ["kubernetes.io/kube-apiserver-client"] + verbs: ["approve"] +# Kubernetes intra-service auth (/serviceaccount/login): validate caller SA tokens. +- apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +# Resolve the ServiceAccount allowlist mapping for the presented SA. +- apiGroups: ["serviceaccount.authn.krateo.io"] + resources: ["serviceaccounts"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: csr-admin-binding +subjects: +- kind: ServiceAccount + name: authn + namespace: demo-system +roleRef: + kind: ClusterRole + name: csr-admin + apiGroup: rbac.authorization.k8s.io diff --git a/hack/apiref-e2e/manifests/fixtures.yaml b/hack/apiref-e2e/manifests/fixtures.yaml new file mode 100644 index 0000000..fa75734 --- /dev/null +++ b/hack/apiref-e2e/manifests/fixtures.yaml @@ -0,0 +1,106 @@ +--- +# In-cluster echo server (multi-arch). /get returns {"args": {...query...}, ...}, +# so extras templated into the query come back in the response — the assertion vehicle. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo + namespace: demo-system + labels: { app: echo } +spec: + replicas: 1 + selector: { matchLabels: { app: echo } } + template: + metadata: { labels: { app: echo } } + spec: + containers: + - name: echo + image: ghcr.io/mccutchen/go-httpbin:latest + imagePullPolicy: Never + ports: [ { name: http, containerPort: 8080 } ] +--- +apiVersion: v1 +kind: Service +metadata: + name: echo + namespace: demo-system +spec: + selector: { app: echo } + ports: [ { name: http, port: 8080, targetPort: http } ] +--- +# Endpoint the RESTAction calls (the in-cluster echo server). +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: echo-endpoint + namespace: demo-system +stringData: + server-url: http://echo.demo-system.svc.cluster.local:8080 +--- +# The RESTAction the CDC resolves via apiRef. Its path templates the request extras +# (.compositionName/.compositionNamespace/.compositionId injected per-instance by the CDC, +# plus the static .region) into the echo call, which reflects them back under .api.echo.args. +apiVersion: templates.krateo.io/v1 +kind: RESTAction +metadata: + name: status-sources + namespace: demo-system +spec: + api: + - name: echo + path: ${ "/get?cn=" + (.compositionName) + "&cns=" + (.compositionNamespace) + "&cid=" + (.compositionId) + "®ion=" + (.region) } + endpointRef: + name: echo-endpoint + namespace: demo-system +--- +# The CDC's own ServiceAccount (what core-provider would create per composition). +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cdc-e2e-sa + namespace: demo-system +--- +# authn allowlist mapping: authorizes cdc-e2e-sa to exchange its token; issues identity +# username=cdc-e2e, groups=[krateo:cdc-e2e]. Lives in the authn operator namespace. +apiVersion: serviceaccount.authn.krateo.io/v1alpha1 +kind: ServiceAccount +metadata: + name: cdc-e2e + namespace: demo-system +spec: + serviceAccountRef: + namespace: demo-system + name: cdc-e2e-sa + groups: + - krateo:cdc-e2e + displayName: "CDC e2e" +--- +# RBAC for the issued identity's group: read the RESTAction + endpoint Secret in demo-system, +# so snowplow authorizes the user to resolve status-sources. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: cdc-e2e-restaction-read + namespace: demo-system +rules: +- apiGroups: ["templates.krateo.io"] + resources: ["restactions"] + verbs: ["get", "list", "watch"] +- apiGroups: [""] + resources: ["secrets", "configmaps"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: cdc-e2e-restaction-read + namespace: demo-system +subjects: +- kind: Group + name: krateo:cdc-e2e + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: cdc-e2e-restaction-read + apiGroup: rbac.authorization.k8s.io diff --git a/hack/apiref-e2e/manifests/restaction-crd.yaml b/hack/apiref-e2e/manifests/restaction-crd.yaml new file mode 100644 index 0000000..2a16adb --- /dev/null +++ b/hack/apiref-e2e/manifests/restaction-crd.yaml @@ -0,0 +1,239 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: restactions.templates.krateo.io +spec: + group: templates.krateo.io + names: + categories: + - krateo + - rest + - actions + kind: RESTAction + listKind: RESTActionList + plural: restactions + shortNames: + - ra + singular: restaction + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1 + schema: + openAPIV3Schema: + description: RESTAction allows users to declaratively define calls to APIs + that may in turn depend on other calls. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: RESTActionSpec defines the api handler specifications. + properties: + api: + items: + description: |- + API represents a request to an HTTP service + + Stage-level admission guards (Ship S.1 hoist) — the Go markers are the + SINGLE SOURCE OF TRUTH for ALL CEL on this CRD. These three security + guards were historically hand-authored in the snowplow CHART CRD; they + are hoisted here verbatim so a future `scripts/gen.sh` regen can never + silently drop them. They sit on the API (stage) struct because each + rule reads sibling stage fields (self.verb / self.exportJwt) alongside + self.userAccessFilter — placement the UserAccessFilterSpec-level XOR + rule cannot reach. + properties: + continueOnError: + type: boolean + dependsOn: + description: DependsOn reference to another API on which this + depends + properties: + iterator: + description: Iterator defines a field on which iterate. + type: string + name: + description: Name of another API on which this depends + type: string + required: + - name + type: object + endpointRef: + description: EndpointRef a reference to an Endpoint + properties: + name: + description: Name of the referenced object. + type: string + namespace: + description: Namespace of the referenced object. + type: string + required: + - name + - namespace + type: object + errorKey: + type: string + exportJwt: + type: boolean + filter: + type: string + headers: + description: Headers is an array of custom request headers + items: + type: string + type: array + x-kubernetes-list-type: atomic + name: + description: Name is a (unique) identifier + type: string + path: + description: Path is the request URI path + type: string + payload: + description: Payload is the request body + type: string + userAccessFilter: + description: |- + UserAccessFilter declares that this API call dispatches via + the snowplow ServiceAccount (cluster-wide read) and that the + returned result set MUST be in-process-refiltered through + EvaluateRBAC before being returned to the caller. Added at + Tag 0.30.9 Sub-scope A — atomic ship: when present, both + ServiceAccount-dispatch AND refilter take effect; there is + no per-mechanism toggle. Optional — RestActions without this + field unchanged from 0.30.8 (per-user-token dispatch). + + Per Revision 2 (binding): even with UserAccessFilter set, + EvaluateRBAC continues to fire on the dispatch CR itself — + UserAccessFilter changes WHO dispatches the inner call, NOT + whether the outer dispatch is RBAC-gated. The refilter step + also calls EvaluateRBAC per object returned by the SA call. + properties: + group: + description: |- + Group is the API group of the checked resource. Empty string + = core group. Required (use "" explicitly for core). + type: string + namespaceFrom: + default: .metadata.namespace + description: |- + NamespaceFrom is a JQ path expression evaluated against each + returned object to derive the per-object namespace for the + EvaluateRBAC call. Typical values: + - ".metadata.name" when the returned objects ARE namespaces + (cluster-scoped check by name, returns namespace itself). + - ".metadata.namespace" when the returned objects live IN + namespaces (e.g. CustomResourceDefinitions don't, but + compositions do). + - "." when the items are bare namespace-name strings (the + namespaces-stage post-filter shape). + + Optional with a default of ".metadata.namespace": when the field + is ABSENT the refilter evaluates ".metadata.namespace" against + each object — the common namespaced-object shape — rather than + falling back to a cluster-scope (namespace="") RBAC check. The + cluster-scope check is the WRONG default for the dominant + namespaced-object case: it would deny a narrow dev who holds the + grant only in their own namespace. An explicit "." or + ".metadata.name" still overrides the default verbatim; the default + only fires when the field is omitted. + type: string + resource: + description: |- + Resource is the plural resource name (e.g. "namespaces"). + The STATIC resource. Required UNLESS ResourcesFrom is set — when + ResourcesFrom is set the resource plural set is derived at + dispatch time and Resource may be left empty. + type: string + resourcesFrom: + description: |- + ResourcesFrom is a JQ expression evaluated ONCE against the full + resolve dict, yielding a []string of resource plurals — Ship + 0.30.129. Symmetric with NamespaceFrom (which is jq-evaluated + per object): ResourcesFrom lets the checked resource set itself + be RUNTIME-DISCOVERED rather than a static literal. + + When set, the refilter keeps a namespace iff the user can perform + Verb on ANY plural in the set (OR semantics) in that namespace. + Group stays static (a single API group). When unset, behaviour is + byte-identical to pre-0.30.129 — the static Resource is checked. + + Use case: compositions-get-ns-and-crd discovers the composition + CRD plurals at runtime in dict["crds"]; resourcesFrom evaluates + "[ (.crds // [])[] | .plural ]" so the per-namespace RBAC prune + covers exactly the discovered composition CRDs — no hardcoded + plural literal. + type: string + verb: + description: |- + Verb is the Kubernetes RBAC verb checked per object. + Required. Lower-case ("get", "list", "watch", etc.). + type: string + required: + - group + - verb + type: object + x-kubernetes-validations: + - message: exactly one of resource or resourcesFrom must be + set + rule: has(self.resource) != has(self.resourcesFrom) + verb: + description: Verb is the request method (GET if omitempty) + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: userAccessFilter is only allowed on read-verb HTTP stages + (GET/HEAD, case-insensitive); CRUD verbs would expose mutation + under filter scope. + rule: '!has(self.userAccessFilter) || !has(self.verb) || self.verb + == '''' || self.verb in [''GET'', ''HEAD'', ''get'', ''head'']' + - message: 'userAccessFilter stages MUST NOT have exportJwt: true; + would leak the raw JWT through the user-facing filtered response.' + rule: '!has(self.userAccessFilter) || !has(self.exportJwt) || + !self.exportJwt' + - message: userAccessFilter must specify a non-empty verb and exactly + one of resource or resourcesFrom; a degenerate filter would + collapse the SubjectAccessReview check to a wildcard. + rule: '!has(self.userAccessFilter) || ((has(self.userAccessFilter.resource) + && size(self.userAccessFilter.resource) > 0) || (has(self.userAccessFilter.resourcesFrom) + && size(self.userAccessFilter.resourcesFrom) > 0)) && self.userAccessFilter.verb + != ''''' + type: array + x-kubernetes-list-type: atomic + filter: + type: string + type: object + status: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/hack/apiref-e2e/manifests/sa-mapping-crd.yaml b/hack/apiref-e2e/manifests/sa-mapping-crd.yaml new file mode 100644 index 0000000..4c2f17a --- /dev/null +++ b/hack/apiref-e2e/manifests/sa-mapping-crd.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: serviceaccounts.serviceaccount.authn.krateo.io +spec: + group: serviceaccount.authn.krateo.io + names: + categories: + - krateo + - authn + - serviceaccount + kind: ServiceAccount + listKind: ServiceAccountList + plural: serviceaccounts + singular: serviceaccount + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ServiceAccount is an AuthN service-identity mapping for Kubernetes intra-service auth. + metadata.name is the issued username (exactly like basic.User). + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ServiceAccountSpec maps a Kubernetes ServiceAccount to an authn service identity. It is + the basic.User pattern with the credential swapped from a password to a Kubernetes SA + token (verified via the TokenReview API instead of a password compare). + properties: + displayName: + description: DisplayName is a human-friendly name for the service + identity. + type: string + groups: + description: |- + Groups the issued service identity belongs to. They become the client certificate's + Organization (O=), so standard Kubernetes RBAC bound to these groups scopes the + identity. authn never authors RBAC. + items: + type: string + type: array + serviceAccountRef: + description: |- + ServiceAccountRef is the Kubernetes ServiceAccount allowed to exchange its (audience- + bound) token for this identity. The CR's existence is the allowlist; an SA with no + matching ServiceAccount CR cannot exchange. + properties: + name: + description: Name of the referenced object. + type: string + namespace: + description: Namespace of the referenced object. + type: string + required: + - name + - namespace + type: object + required: + - serviceAccountRef + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/hack/apiref-e2e/manifests/snowplow-deploy.yaml b/hack/apiref-e2e/manifests/snowplow-deploy.yaml new file mode 100644 index 0000000..d9a7da1 --- /dev/null +++ b/hack/apiref-e2e/manifests/snowplow-deploy.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: demo-system +--- +kind: ServiceAccount +apiVersion: v1 +metadata: + name: snowplow + namespace: demo-system +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: jq-custom-modules + namespace: demo-system +data: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: snowplow + namespace: demo-system +spec: + selector: + app: snowplow + type: NodePort + ports: + - name: http + port: 8081 + targetPort: http + protocol: TCP + nodePort: 30081 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: snowplow + namespace: demo-system + labels: + app: snowplow +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: snowplow + template: + metadata: + labels: + app: snowplow + spec: + serviceAccountName: snowplow + volumes: + - name: jq-modules + configMap: + name: jq-custom-modules + containers: + - name: snowplow + image: snowplow:e2e + imagePullPolicy: Never + args: + - --debug=false + - --blizzard=false + - --port=8081 + - --authn-namespace=demo-system + - --jwt-sign-key=AbbraCadabbra + - --pretty-log=false + - --jq-modules-path=/jq-modules + ports: + - name: http + containerPort: 8081 + volumeMounts: + - name: jq-modules + mountPath: /jq-modules + readOnly: true +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: snowplow +rules: +- apiGroups: + - core.krateo.io + resources: + - compositiondefinitions + - schemadefinitions + verbs: + - get + - list +- apiGroups: + - templates.krateo.io + resources: + - "*" + verbs: + - get + - list +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list +- apiGroups: + - "" + resources: + - namespaces + - configmaps + - secrets + verbs: + - get + - list +- apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + - rolebindings + - clusterroles + - clusterrolebindings + verbs: + - get + - list + - watch +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: snowplow +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: snowplow +subjects: +- kind: ServiceAccount + name: snowplow + namespace: demo-system diff --git a/hack/apiref-e2e/run.sh b/hack/apiref-e2e/run.sh new file mode 100755 index 0000000..69fcc36 --- /dev/null +++ b/hack/apiref-e2e/run.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Real end-to-end test of the CDC apiRef chain against a live kind cluster running real authn +# and real snowplow. Proves: projected SA token -> authn /serviceaccount/login (TokenReview -> +# JWT) -> snowplow /call (Bearer JWT) resolving a RESTAction whose request echoes the extras +# (static + per-instance, request-wins) -> .api.echo.args. +# +# Requirements: docker, kind, kubectl, go, and local checkouts of authn + snowplow next to this +# repo (../authn on its main branch with the serviceaccount strategy; ../snowplow checked out at +# the snowplow tag under test, default 1.1.1). +# +# Usage: hack/apiref-e2e/run.sh # full setup + test +# SKIP_BUILD=1 hack/apiref-e2e/run.sh # reuse already-built/loaded images +set -euo pipefail + +CLUSTER=${CLUSTER:-apiref-e2e} +SNOWPLOW_TAG=${SNOWPLOW_TAG:-1.1.1} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +AUTHN_SRC=${AUTHN_SRC:-$REPO/../authn} +SNOWPLOW_SRC=${SNOWPLOW_SRC:-$REPO/../snowplow} +KUBECONFIG_FILE="$HERE/.kubeconfig" +TOKEN_FILE="$HERE/.sa-token" + +echo "==> kind cluster $CLUSTER" +kind get clusters | grep -qx "$CLUSTER" || kind create cluster --name "$CLUSTER" --wait 90s +kind get kubeconfig --name "$CLUSTER" > "$KUBECONFIG_FILE" +export KUBECONFIG="$KUBECONFIG_FILE" + +if [[ "${SKIP_BUILD:-}" != "1" ]]; then + echo "==> build images (authn, snowplow:$SNOWPLOW_TAG)" + ( cd "$SNOWPLOW_SRC" && git checkout -q "$SNOWPLOW_TAG" && docker build -q -t snowplow:e2e --build-arg COMMIT_HASH="$SNOWPLOW_TAG" . ) + ( cd "$AUTHN_SRC" && docker build -q -t authn:e2e . ) + docker pull -q ghcr.io/mccutchen/go-httpbin:latest +fi +echo "==> load images into kind" +kind load docker-image snowplow:e2e authn:e2e ghcr.io/mccutchen/go-httpbin:latest --name "$CLUSTER" + +echo "==> CRDs + fixtures" +kubectl apply -f "$HERE/manifests/restaction-crd.yaml" -f "$HERE/manifests/sa-mapping-crd.yaml" +kubectl create namespace demo-system --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -f "$HERE/manifests/fixtures.yaml" + +echo "==> deploy authn + snowplow" +kubectl apply -f "$HERE/manifests/authn-rbac.yaml" +kubectl apply -f "$HERE/manifests/authn-deploy.yaml" +kubectl apply -f "$HERE/manifests/snowplow-deploy.yaml" +kubectl -n demo-system rollout status deploy/echo --timeout=120s +kubectl -n demo-system rollout status deploy/authn --timeout=120s +kubectl -n demo-system rollout status deploy/snowplow --timeout=120s + +echo "==> mint an audience-authn token for the CDC ServiceAccount" +kubectl -n demo-system create token cdc-e2e-sa --audience=authn --duration=3600s > "$TOKEN_FILE" + +echo "==> port-forward authn:8082 and snowplow:8081" +kubectl -n demo-system port-forward svc/authn 18082:8082 >/tmp/pf-authn.log 2>&1 & +PF_AUTHN=$! +kubectl -n demo-system port-forward svc/snowplow 18081:8081 >/tmp/pf-snowplow.log 2>&1 & +PF_SNOWPLOW=$! +trap 'kill $PF_AUTHN $PF_SNOWPLOW 2>/dev/null || true' EXIT +sleep 3 + +echo "==> run the e2e test" +cd "$REPO" +APIREF_E2E_AUTHN_URL="http://127.0.0.1:18082" \ +APIREF_E2E_SNOWPLOW_URL="http://127.0.0.1:18081" \ +APIREF_E2E_TOKEN_PATH="$TOKEN_FILE" \ +APIREF_E2E_APIREF_NAME="status-sources" \ +APIREF_E2E_APIREF_NAMESPACE="demo-system" \ + go test -tags e2e ./internal/composition/ -run TestE2E_ApiRefChain -v -count=1 diff --git a/internal/composition/apiref_kind_e2e_test.go b/internal/composition/apiref_kind_e2e_test.go new file mode 100644 index 0000000..f2f17eb --- /dev/null +++ b/internal/composition/apiref_kind_e2e_test.go @@ -0,0 +1,111 @@ +//go:build e2e + +// This is a real end-to-end test of the CDC apiRef chain against a live kind cluster running +// real authn and real snowplow (see hack/apiref-e2e). It exercises the actual CDC client code: +// +// projected SA token (file) --authn.Client.Token--> /serviceaccount/login (TokenReview -> JWT) +// --snowplow.Client.Resolve(Bearer JWT, extras)--> snowplow /call (resolves a RESTAction) +// --> the RESTAction echoes the request extras back --> .api.echo.args +// +// proving the JWT and the extras (static + per-instance, request-wins) flow through end to end. +// +// Driven by env (set by the harness): +// APIREF_E2E_AUTHN_URL, APIREF_E2E_SNOWPLOW_URL, APIREF_E2E_TOKEN_PATH, +// APIREF_E2E_APIREF_NAME, APIREF_E2E_APIREF_NAMESPACE +// +// Run: go test -tags e2e ./internal/composition/ -run TestE2E_ApiRefChain -v +package composition + +import ( + "context" + "os" + "testing" + "time" + + "github.com/krateoplatformops/composition-dynamic-controller/internal/authn" + "github.com/krateoplatformops/composition-dynamic-controller/internal/snowplow" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func envOrSkip(t *testing.T, key string) string { + t.Helper() + v := os.Getenv(key) + if v == "" { + t.Skipf("%s not set; run via the apiref-e2e harness", key) + } + return v +} + +func TestE2E_ApiRefChain_AuthnJWT_Snowplow_Extras(t *testing.T) { + authnURL := envOrSkip(t, "APIREF_E2E_AUTHN_URL") + snowplowURL := envOrSkip(t, "APIREF_E2E_SNOWPLOW_URL") + tokenPath := envOrSkip(t, "APIREF_E2E_TOKEN_PATH") + refName := envOrSkip(t, "APIREF_E2E_APIREF_NAME") + refNamespace := envOrSkip(t, "APIREF_E2E_APIREF_NAMESPACE") + + // Real CDC clients: authn token provider feeds the snowplow client's Bearer. + authnClient := authn.New(authnURL, tokenPath) + snowplowClient := snowplow.New(snowplowURL, authnClient.Token) + resolver := NewSnowplowAPIResolver( + snowplowClient, + snowplow.ApiRef{Name: refName, Namespace: refNamespace}, + map[string]any{"region": "eu"}, // static extras (CompositionDefinition apiRef.extras) + ) + + // A composition instance: its name/namespace/uid become per-instance extras (request-wins). + mg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "composition.krateo.io/v1-0-0", + "kind": "FireworksApp", + "metadata": map[string]any{ + "name": "demo-app", + "namespace": "apps", + "uid": "uid-e2e-123", + }, + }} + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + api, err := resolver.Resolve(ctx, mg) + if err != nil { + t.Fatalf("Resolve (real authn JWT + real snowplow): %v", err) + } + t.Logf("resolved .api = %#v", api) + + // .api.echo is the echo server's response; .args reflects the templated request extras. + echo, ok := api["echo"].(map[string]any) + if !ok { + t.Fatalf("api.echo missing or wrong type: %#v", api) + } + args, ok := echo["args"].(map[string]any) + if !ok { + t.Fatalf("api.echo.args missing or wrong type: %#v", echo) + } + + // go-httpbin returns single query values as strings; tolerate []any too. + get := func(k string) string { + switch v := args[k].(type) { + case string: + return v + case []any: + if len(v) > 0 { + if s, ok := v[0].(string); ok { + return s + } + } + } + return "" + } + + want := map[string]string{ + "cn": "demo-app", // per-instance: compositionName + "cns": "apps", // per-instance: compositionNamespace + "cid": "uid-e2e-123", // per-instance: compositionId + "region": "eu", // static apiRef.extras + } + for k, w := range want { + if got := get(k); got != w { + t.Errorf("extra %q round-trip: got %q, want %q (full args: %#v)", k, got, w, args) + } + } +}