Loading .gitlab-ci.yml +9 −4 Original line number Diff line number Diff line include: # $TBC_NAMESPACE is a group variable; can be globally overridden - project: "$TBC_NAMESPACE/docker" ref: "5.6" ref: "5.8.2" file: "templates/gitlab-ci-docker.yml" - project: "$TBC_NAMESPACE/python" ref: "6.5" ref: "6.7" file: "/templates/gitlab-ci-python.yml" stages: Loading Loading @@ -51,12 +51,17 @@ variables: function assert_eq() { local expected="$1" local actual="$2" local error_msg="$3" if [ "$expected" == "$actual" ]; then log_info "$expected == $actual" return 0 else if [ -z "$error_msg" ]; then fail "$expected == $actual" else fail "$expected == $actual msg: $error_msg" fi return 1 fi } Loading Loading @@ -125,7 +130,7 @@ test-token-succeeds: script: - | response_status=$(curl -s -o "resp.txt" -w "%{http_code}" "http://gcp-auth-provider/token") assert_eq "200" $response_status assert_eq "200" $response_status "$(cat resp.txt)" token=$(cat resp.txt) response_status=$(curl -s -o resp.txt -w "%{http_code}" -H "Authorization: Bearer $token" "https://cloudresourcemanager.googleapis.com/v1/projects/$GCP_PROJECT") Loading Dockerfile +6 −21 Original line number Diff line number Diff line FROM registry.hub.docker.com/library/python:3.12 as requirements-stage WORKDIR /tmp # hadolint ignore=DL3013 RUN pip install --no-cache-dir poetry COPY ./pyproject.toml ./poetry.lock* /tmp/ RUN poetry export -f requirements.txt --output requirements.txt --without-hashes ########### FROM registry.hub.docker.com/library/python:3.11-slim-buster FROM registry.hub.docker.com/library/python:3.12-alpine ENV PORT=80 WORKDIR /code COPY --from=requirements-stage /tmp/requirements.txt /code/requirements.txt RUN apt-get -y update && apt-get -y upgrade \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir --upgrade -r /code/requirements.txt COPY pyproject.toml poetry.lock README.md /code/ COPY ./gcp_auth_provider /code/gcp_auth_provider COPY ./app /code/app RUN apk upgrade --no-cache \ && pip install --no-cache-dir . EXPOSE ${PORT} # hadolint ignore=DL3025 CMD uvicorn app.main:app --host=0.0.0.0 --port=${PORT} CMD uvicorn gcp_auth_provider.main:app --host=0.0.0.0 --port=${PORT} app/__init__.py→gcp_auth_provider/__init__.py +0 −0 File moved. app/gcp_client.py→gcp_auth_provider/gcp_client.py +55 −0 Original line number Diff line number Diff line import requests, json, os from fastapi import HTTPException import os JWT_TOKEN = os.environ.get('GCP_JWT') or os.environ.get('CI_JOB_JWT_V2') import certifi import urllib3 from starlette.exceptions import HTTPException http = urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=certifi.where()) JWT_TOKEN = os.environ.get("GCP_JWT") or os.environ.get("CI_JOB_JWT_V2") def get_iam_credentials(service_account, federated_token): resp = requests.request( method='POST', url='https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken' % service_account, resp = urllib3.request( method="POST", url=f"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account}:generateAccessToken", headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % federated_token "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {federated_token}", }, data=json.dumps({ "scope": ["https://www.googleapis.com/auth/cloud-platform"] }) json={"scope": ["https://www.googleapis.com/auth/cloud-platform"]}, ) if resp.status_code != 200: if resp.status != 200: raise HTTPException( status_code=500, detail=f'Failed to get iam credential token: {resp.text}' detail=f"Failed to get iam credential token for service_account={service_account} msg: {resp.json()}", ) return resp.json()['accessToken'] return resp.json()["accessToken"] def get_sts_token(audience): if not JWT_TOKEN: raise HTTPException( status_code=401, detail='Missing $CI_JOB_JWT_V2 token' status_code=401, detail="Missing $CI_JOB_JWT_V2 or $GCP_JWT token" ) resp = requests.request( method='POST', url='https://sts.googleapis.com/v1/token', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json' }, data=json.dumps({ resp = urllib3.request( method="POST", url="https://sts.googleapis.com/v1/token", headers={"Accept": "application/json", "Content-Type": "application/json"}, json={ "audience": audience, "grantType": "urn:ietf:params:oauth:grant-type:token-exchange", "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", "scope": "https://www.googleapis.com/auth/cloud-platform", "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt", "subjectToken": JWT_TOKEN }) "subjectToken": JWT_TOKEN, }, ) if resp.status_code != 200: if resp.status != 200: raise HTTPException( status_code=500, detail=f'Failed to get sts token: {resp.text}' detail=f"Failed to get sts token for audience={audience} msg: {resp.json()}", ) return resp.json()['access_token'] return resp.json()["access_token"] app/main.py→gcp_auth_provider/main.py +35 −20 Original line number Diff line number Diff line import os import re from fastapi import FastAPI, HTTPException, Query from fastapi.responses import PlainTextResponse from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.routing import Route import uvicorn from app.gcp_client import get_iam_credentials, get_sts_token from gcp_auth_provider.gcp_client import get_iam_credentials, get_sts_token app = FastAPI() # app = FastAPI() def guess_env_type() -> str: env_type = os.getenv('ENV_TYPE') env_type = os.getenv("ENV_TYPE") if env_type: return env_type # guess from GitLab CI predefined vars Loading Loading @@ -52,19 +56,15 @@ def get_oidc_provider(var_prefix: str) -> str: ) @app.get("/ping", response_class=PlainTextResponse) def ping(): return "pong" def ping(request: Request): return PlainTextResponse("pong") @app.get("/token", response_class=PlainTextResponse) def token( workload_identity_provider: str = Query( default=None, alias="workloadIdentityProvider" ), service_account: str = Query(default=None, alias="serviceAccount"), env_type: str = Query(default=None, alias="envType"), ): def token(request: Request): workload_identity_provider = request.query_params.get("workloadIdentityProvider") service_account = request.query_params.get("serviceAccount") env_type = request.query_params.get("envType") # projects/%s/locations/global/workloadIdentityPools/%s/providers/%s if (not workload_identity_provider) or (not service_account): # retrieve from TBC standard variables Loading @@ -78,12 +78,27 @@ def token( if (not workload_identity_provider) or (not service_account): raise HTTPException( status_code=400, detail=f"Couldn't retrieve implicit OIDC provider/account from env for '{env_type}'", detail=f"Token couldn't retrieve implicit OIDC provider/account for env='{env_type}', workloadIdentityProvider={workload_identity_provider}, service=Account{service_account}", ) audience = "//iam.googleapis.com/%s" % workload_identity_provider audience = f"//iam.googleapis.com/{workload_identity_provider}" federated_token = get_sts_token(audience) gcloud_auth_token = get_iam_credentials(service_account, federated_token) return gcloud_auth_token return PlainTextResponse(gcloud_auth_token) def startup(): print("Ready to go") routes = [ Route("/ping", ping), Route("/token", token), ] app = Starlette(debug=False, routes=routes, on_startup=[startup]) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) No newline at end of file Loading
.gitlab-ci.yml +9 −4 Original line number Diff line number Diff line include: # $TBC_NAMESPACE is a group variable; can be globally overridden - project: "$TBC_NAMESPACE/docker" ref: "5.6" ref: "5.8.2" file: "templates/gitlab-ci-docker.yml" - project: "$TBC_NAMESPACE/python" ref: "6.5" ref: "6.7" file: "/templates/gitlab-ci-python.yml" stages: Loading Loading @@ -51,12 +51,17 @@ variables: function assert_eq() { local expected="$1" local actual="$2" local error_msg="$3" if [ "$expected" == "$actual" ]; then log_info "$expected == $actual" return 0 else if [ -z "$error_msg" ]; then fail "$expected == $actual" else fail "$expected == $actual msg: $error_msg" fi return 1 fi } Loading Loading @@ -125,7 +130,7 @@ test-token-succeeds: script: - | response_status=$(curl -s -o "resp.txt" -w "%{http_code}" "http://gcp-auth-provider/token") assert_eq "200" $response_status assert_eq "200" $response_status "$(cat resp.txt)" token=$(cat resp.txt) response_status=$(curl -s -o resp.txt -w "%{http_code}" -H "Authorization: Bearer $token" "https://cloudresourcemanager.googleapis.com/v1/projects/$GCP_PROJECT") Loading
Dockerfile +6 −21 Original line number Diff line number Diff line FROM registry.hub.docker.com/library/python:3.12 as requirements-stage WORKDIR /tmp # hadolint ignore=DL3013 RUN pip install --no-cache-dir poetry COPY ./pyproject.toml ./poetry.lock* /tmp/ RUN poetry export -f requirements.txt --output requirements.txt --without-hashes ########### FROM registry.hub.docker.com/library/python:3.11-slim-buster FROM registry.hub.docker.com/library/python:3.12-alpine ENV PORT=80 WORKDIR /code COPY --from=requirements-stage /tmp/requirements.txt /code/requirements.txt RUN apt-get -y update && apt-get -y upgrade \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir --upgrade -r /code/requirements.txt COPY pyproject.toml poetry.lock README.md /code/ COPY ./gcp_auth_provider /code/gcp_auth_provider COPY ./app /code/app RUN apk upgrade --no-cache \ && pip install --no-cache-dir . EXPOSE ${PORT} # hadolint ignore=DL3025 CMD uvicorn app.main:app --host=0.0.0.0 --port=${PORT} CMD uvicorn gcp_auth_provider.main:app --host=0.0.0.0 --port=${PORT}
app/gcp_client.py→gcp_auth_provider/gcp_client.py +55 −0 Original line number Diff line number Diff line import requests, json, os from fastapi import HTTPException import os JWT_TOKEN = os.environ.get('GCP_JWT') or os.environ.get('CI_JOB_JWT_V2') import certifi import urllib3 from starlette.exceptions import HTTPException http = urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=certifi.where()) JWT_TOKEN = os.environ.get("GCP_JWT") or os.environ.get("CI_JOB_JWT_V2") def get_iam_credentials(service_account, federated_token): resp = requests.request( method='POST', url='https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken' % service_account, resp = urllib3.request( method="POST", url=f"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account}:generateAccessToken", headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % federated_token "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {federated_token}", }, data=json.dumps({ "scope": ["https://www.googleapis.com/auth/cloud-platform"] }) json={"scope": ["https://www.googleapis.com/auth/cloud-platform"]}, ) if resp.status_code != 200: if resp.status != 200: raise HTTPException( status_code=500, detail=f'Failed to get iam credential token: {resp.text}' detail=f"Failed to get iam credential token for service_account={service_account} msg: {resp.json()}", ) return resp.json()['accessToken'] return resp.json()["accessToken"] def get_sts_token(audience): if not JWT_TOKEN: raise HTTPException( status_code=401, detail='Missing $CI_JOB_JWT_V2 token' status_code=401, detail="Missing $CI_JOB_JWT_V2 or $GCP_JWT token" ) resp = requests.request( method='POST', url='https://sts.googleapis.com/v1/token', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json' }, data=json.dumps({ resp = urllib3.request( method="POST", url="https://sts.googleapis.com/v1/token", headers={"Accept": "application/json", "Content-Type": "application/json"}, json={ "audience": audience, "grantType": "urn:ietf:params:oauth:grant-type:token-exchange", "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", "scope": "https://www.googleapis.com/auth/cloud-platform", "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt", "subjectToken": JWT_TOKEN }) "subjectToken": JWT_TOKEN, }, ) if resp.status_code != 200: if resp.status != 200: raise HTTPException( status_code=500, detail=f'Failed to get sts token: {resp.text}' detail=f"Failed to get sts token for audience={audience} msg: {resp.json()}", ) return resp.json()['access_token'] return resp.json()["access_token"]
app/main.py→gcp_auth_provider/main.py +35 −20 Original line number Diff line number Diff line import os import re from fastapi import FastAPI, HTTPException, Query from fastapi.responses import PlainTextResponse from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.routing import Route import uvicorn from app.gcp_client import get_iam_credentials, get_sts_token from gcp_auth_provider.gcp_client import get_iam_credentials, get_sts_token app = FastAPI() # app = FastAPI() def guess_env_type() -> str: env_type = os.getenv('ENV_TYPE') env_type = os.getenv("ENV_TYPE") if env_type: return env_type # guess from GitLab CI predefined vars Loading Loading @@ -52,19 +56,15 @@ def get_oidc_provider(var_prefix: str) -> str: ) @app.get("/ping", response_class=PlainTextResponse) def ping(): return "pong" def ping(request: Request): return PlainTextResponse("pong") @app.get("/token", response_class=PlainTextResponse) def token( workload_identity_provider: str = Query( default=None, alias="workloadIdentityProvider" ), service_account: str = Query(default=None, alias="serviceAccount"), env_type: str = Query(default=None, alias="envType"), ): def token(request: Request): workload_identity_provider = request.query_params.get("workloadIdentityProvider") service_account = request.query_params.get("serviceAccount") env_type = request.query_params.get("envType") # projects/%s/locations/global/workloadIdentityPools/%s/providers/%s if (not workload_identity_provider) or (not service_account): # retrieve from TBC standard variables Loading @@ -78,12 +78,27 @@ def token( if (not workload_identity_provider) or (not service_account): raise HTTPException( status_code=400, detail=f"Couldn't retrieve implicit OIDC provider/account from env for '{env_type}'", detail=f"Token couldn't retrieve implicit OIDC provider/account for env='{env_type}', workloadIdentityProvider={workload_identity_provider}, service=Account{service_account}", ) audience = "//iam.googleapis.com/%s" % workload_identity_provider audience = f"//iam.googleapis.com/{workload_identity_provider}" federated_token = get_sts_token(audience) gcloud_auth_token = get_iam_credentials(service_account, federated_token) return gcloud_auth_token return PlainTextResponse(gcloud_auth_token) def startup(): print("Ready to go") routes = [ Route("/ping", ping), Route("/token", token), ] app = Starlette(debug=False, routes=routes, on_startup=[startup]) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) No newline at end of file