From cd114a6221403921ccc91af294dfe557f290482a Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Tue, 28 Jul 2026 15:32:27 -0700 Subject: [PATCH 01/10] squash merge ghactions --- .github/workflows/.build.yml | 57 +++++++++++++++++ .../workflows/ci-build-provisioner-api.yaml | 63 ++++++------------- 2 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/.build.yml diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml new file mode 100644 index 000000000..e6b50f8a3 --- /dev/null +++ b/.github/workflows/.build.yml @@ -0,0 +1,57 @@ +on: + workflow_call: + inputs: + name: + required: true + type: string + context: + required: true + type: string + default: '.' + +jobs: + build-image: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v2 + + - name: Image meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }}/${{ inputs.name }} + + - name: Set DEPLOY_ID + run: | + echo '::set-output name=DEPLOY_ID::${{ steps.meta.outputs.version }}' + echo '::set-output name=APP_VERSION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}' + echo '::set-output name=APP_REVISION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}' + id: set-deploy-id + + - name: Get deploy ID + run: echo "The DEPLOY_ID is ${{ steps.set-deploy-id.outputs.DEPLOY_ID }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: false + + - name: Create image + uses: docker/build-push-action@v5 + with: + context: ${{ inputs.context }} + file: ${{ inputs.context }}/Dockerfile + push: true + build-args: | + APP_VERSION=${{ steps.set-deploy-id.outputs.APP_VERSION }} + APP_REVISION=${{ steps.set-deploy-id.outputs.APP_REVISION }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/ci-build-provisioner-api.yaml b/.github/workflows/ci-build-provisioner-api.yaml index f2438239a..a201a0db3 100644 --- a/.github/workflows/ci-build-provisioner-api.yaml +++ b/.github/workflows/ci-build-provisioner-api.yaml @@ -2,39 +2,40 @@ name: Build Provisioner API on: push: - branches: [feature/*, dev, test] + branches: [tmp/*, feature/*, dev, test] permissions: contents: read - packages: write - -env: - REGISTRY: ghcr.io - REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }} - REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} jobs: build: + uses: ./.github/workflows/.build.yml + permissions: + contents: read + packages: write # 🚀 Crucial permission needed to push to ghcr.io + with: + name: provisioner-svc + context: ./provisioner-api + + deploy: runs-on: ubuntu-latest + needs: [build] steps: - - name: Docker meta - id: docker_meta + - uses: actions/checkout@v2 + + - name: Image meta + id: meta uses: docker/metadata-action@v6 with: - images: ${{ env.REGISTRY }}/bcgov/api-services-portal/provisioner + images: ghcr.io/${{ github.repository }}/provisioner-svc - name: Set DEPLOY_ID run: | - echo '::set-output name=DEPLOY_ID::${{ steps.docker_meta.outputs.version }}' - echo '::set-output name=APP_VERSION::${{ fromJSON(steps.docker_meta.outputs.json).labels['org.opencontainers.image.version'] }}' - echo '::set-output name=APP_REVISION::${{ fromJSON(steps.docker_meta.outputs.json).labels['org.opencontainers.image.revision'] }}' + echo '::set-output name=DEPLOY_ID::${{ steps.meta.outputs.version }}' + echo '::set-output name=APP_VERSION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}' + echo '::set-output name=APP_REVISION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}' id: set-deploy-id - - name: Get deploy ID - run: echo "The DEPLOY_ID is ${{ steps.set-deploy-id.outputs.DEPLOY_ID }}" - - - uses: actions/checkout@v2 - - name: Install oc uses: redhat-actions/oc-installer@v1 with: @@ -51,30 +52,6 @@ jobs: namespace: ${{ env.OPENSHIFT_NAMESPACE }} - - name: Log in to Container Registry - uses: docker/login-action@v1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ env.REGISTRY_USERNAME }} - password: ${{ env.REGISTRY_PASSWORD }} - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - - - name: Build - uses: docker/build-push-action@v6 - with: - context: provisioner-api - file: provisioner-api/Dockerfile - push: true - build-args: | - GITHUB_API_TOKEN=${{ secrets.CONTAINER_REGISTRY_PASSWORD }} - APP_VERSION=${{ steps.set-deploy-id.outputs.APP_VERSION }} - APP_REVISION=${{ steps.set-deploy-id.outputs.APP_REVISION }} - tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.docker_meta.outputs.labels }} - - name: 'Get Helm' if: github.ref != 'refs/heads/dev' run: | @@ -97,7 +74,7 @@ jobs: maxSurge: 50% image: - repository: ${{ env.REGISTRY }}/bcgov/api-services-portal/provisioner + repository: ghcr.io/bcgov/api-services-portal/provisioner-svc tag: ${{ steps.set-deploy-id.outputs.DEPLOY_ID }} pullPolicy: Always From 4c7fa84679ef191de7688dce6cf2edcb680f7912 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:30:55 -0400 Subject: [PATCH 02/10] upd build only ghaction --- .github/workflows/ci-build-only.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build-only.yaml b/.github/workflows/ci-build-only.yaml index 860c0c39b..e087ad76e 100644 --- a/.github/workflows/ci-build-only.yaml +++ b/.github/workflows/ci-build-only.yaml @@ -10,7 +10,16 @@ env: REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} jobs: - build: + build-provisioner: + uses: ./.github/workflows/.build.yml + permissions: + contents: read + packages: write # 🚀 Crucial permission needed to push to ghcr.io + with: + name: provisioner-svc + context: ./provisioner-api + + build-others: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -84,4 +93,3 @@ jobs: GITHUB_API_TOKEN=${{ secrets.CONTAINER_REGISTRY_PASSWORD }} APP_VERSION=${{ steps.set-deploy-id.outputs.APP_VERSION }} APP_REVISION=${{ steps.set-deploy-id.outputs.APP_REVISION }} - From 52b5d056dea7d39ee3cb4617160fea66459de5d2 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:34:07 -0400 Subject: [PATCH 03/10] upd build template --- .github/workflows/.build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index e6b50f8a3..efa217674 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -15,7 +15,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Image meta id: meta @@ -34,10 +34,10 @@ jobs: run: echo "The DEPLOY_ID is ${{ steps.set-deploy-id.outputs.DEPLOY_ID }}" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -45,7 +45,7 @@ jobs: logout: false - name: Create image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: ${{ inputs.context }} file: ${{ inputs.context }}/Dockerfile From 92e037fc75ec680994f26954bc50b36aa77e1c3c Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:38:23 -0400 Subject: [PATCH 04/10] upd build template fix deployid --- .github/workflows/.build.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index efa217674..75937341c 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -25,9 +25,9 @@ jobs: - name: Set DEPLOY_ID run: | - echo '::set-output name=DEPLOY_ID::${{ steps.meta.outputs.version }}' - echo '::set-output name=APP_VERSION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}' - echo '::set-output name=APP_REVISION::${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}' + echo "DEPLOY_ID=${{ steps.meta.outputs.version }}" >> "$GITHUB_OUTPUT" + echo "APP_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" >> "$GITHUB_OUTPUT" + echo "APP_REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}" >> "$GITHUB_OUTPUT" id: set-deploy-id - name: Get deploy ID @@ -50,6 +50,7 @@ jobs: context: ${{ inputs.context }} file: ${{ inputs.context }}/Dockerfile push: true + platforms: linux/amd64,linux/arm64 build-args: | APP_VERSION=${{ steps.set-deploy-id.outputs.APP_VERSION }} APP_REVISION=${{ steps.set-deploy-id.outputs.APP_REVISION }} From 766b9eb092b3ec8c6850bedf0217b0038532340a Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:41:21 -0400 Subject: [PATCH 05/10] upd build template use qemu --- .github/workflows/.build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index 75937341c..06bb91c64 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -36,6 +36,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: From f5a9257beea8eb00e3de6d33f65389ba5e750863 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:44:17 -0400 Subject: [PATCH 06/10] upd build template try fix git error --- .github/workflows/.build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index 06bb91c64..e4f5f1561 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -13,6 +13,7 @@ jobs: build-image: runs-on: ubuntu-latest env: + GIT_CLONE_PROTECTION_ACTIVE: false GITHUB_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v7 From dce76a931a8c5b56cba1489bff2b06910829bd5d Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 10:52:34 -0400 Subject: [PATCH 07/10] fix the broken submodule reference --- .github/workflows/.build.yml | 1 - gwa-api | 1 - 2 files changed, 2 deletions(-) delete mode 160000 gwa-api diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index e4f5f1561..06bb91c64 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -13,7 +13,6 @@ jobs: build-image: runs-on: ubuntu-latest env: - GIT_CLONE_PROTECTION_ACTIVE: false GITHUB_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v7 diff --git a/gwa-api b/gwa-api deleted file mode 160000 index eed2c5196..000000000 --- a/gwa-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit eed2c5196cbc8b5a3aafe4d549cdd26d4247e947 From 199758593995c354836f01636895eedf75d73ca4 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Wed, 5 Aug 2026 11:05:52 -0400 Subject: [PATCH 08/10] allow task branches to build only --- .github/workflows/ci-build-only.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build-only.yaml b/.github/workflows/ci-build-only.yaml index e087ad76e..7a06cd53a 100644 --- a/.github/workflows/ci-build-only.yaml +++ b/.github/workflows/ci-build-only.yaml @@ -2,7 +2,7 @@ name: Build and Deploy Refactor on: push: - branches: [hotfix/*] + branches: [hotfix/*, task/*] env: REGISTRY: ghcr.io From b06a2f52f226e3a0805ba485a7982302ec89bdc4 Mon Sep 17 00:00:00 2001 From: ike thecoder Date: Fri, 7 Aug 2026 16:12:09 -0400 Subject: [PATCH 09/10] SP136: route runtime group cert-signing tokens through the provisioner (#1522) --- .env.local | 1 - docker-compose.yml | 29 ++++ e2e/cypress.config.ts | 1 + .../01-cert-token-provisioner-routing.ts | 145 +++++++++++++++++ local/mocks/step-token-api/main.ts | 17 +- local/provisioner/environments.json | 11 +- .../src/clients/ca-token/client.ts | 148 ++++++++++++++++++ provisioner-api/src/clients/ca-token/index.ts | 2 + provisioner-api/src/clients/ca-token/types.ts | 22 +++ provisioner-api/src/clients/index.ts | 3 + provisioner-api/src/config/environments.ts | 9 ++ .../controllers/runtime-groups-controller.ts | 21 +++ provisioner-api/src/openapi/path-summaries.ts | 5 + provisioner-api/src/routes/connections.ts | 2 +- provisioner-api/src/routes/gateways.ts | 2 +- .../src/routes/integrationAccess.ts | 2 +- provisioner-api/src/routes/patterns.ts | 2 +- .../src/routes/resource-servers.ts | 2 +- provisioner-api/src/routes/runtime-groups.ts | 47 +++++- provisioner-api/src/schemas/runtime-groups.ts | 19 ++- .../src/services/ca-token-service.ts | 71 +++++++++ provisioner-api/src/services/index.ts | 8 + .../sdx/v1/OrgRuntimeGroupController.ts | 2 +- src/services/batch/runtime-group.ts | 47 ++---- .../certificate-authority/step-token.ts | 35 ----- .../provisioner/provisioner-service.ts | 26 +++ 26 files changed, 600 insertions(+), 79 deletions(-) create mode 100644 e2e/cypress/tests/99-sp136/01-cert-token-provisioner-routing.ts create mode 100644 provisioner-api/src/clients/ca-token/client.ts create mode 100644 provisioner-api/src/clients/ca-token/index.ts create mode 100644 provisioner-api/src/clients/ca-token/types.ts create mode 100644 provisioner-api/src/services/ca-token-service.ts delete mode 100644 src/services/certificate-authority/step-token.ts diff --git a/.env.local b/.env.local index 8dfa32acf..d39f47089 100644 --- a/.env.local +++ b/.env.local @@ -27,7 +27,6 @@ OAS_VALIDATION_RULESET=basic-ruleset GWA_PROD_ENV_SLUG=E0000000 GWA_RES_SVR_CLIENT_ID=gwa-api GWA_RES_SVR_CLIENT_SECRET=18900468-3db1-43f7-a8af-e75f079eb742 -STEP_TOKEN_URL=http://step-token-api.localtest.me:2020 KEYCLOAK_AUTH_URL=http://keycloak.localtest.me:9081/auth KEYCLOAK_REALM=master EMAIL_ENABLED=true diff --git a/docker-compose.yml b/docker-compose.yml index 33be32fd1..12049c3e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -504,6 +504,7 @@ services: - --allow-write - --allow-read - /app/main.ts + - '2020' # Needed when running portal outside of docker compose ports: - 2020:2020 @@ -514,6 +515,34 @@ services: aliases: - step-token-api.localtest.me + # Second, independent instance of the same CA token mock so that + # per-environment routing through the provisioner (SP136) is observable + # locally: each environment's `ca_token_url` can point at a distinct CA. + # Runs on its own internal port (2022) rather than sharing 2020 with the + # first instance, since containers reached from inside the docker network + # (the provisioner) use the container's internal port, not the host + # port mapping. + mocks-step-token-api-2: + image: denoland/deno:2.7.12 + container_name: mocks-step-token-api-2 + command: + - deno + - run + - --allow-net=:2022,deno.land + - --allow-write + - --allow-read + - /app/main.ts + - '2022' + # Needed when running portal outside of docker compose + ports: + - 2022:2022 + volumes: + - ./local/mocks/step-token-api/main.ts:/app/main.ts + networks: + aps-net: + aliases: + - step-token-api-2.localtest.me + mocks-sdx-edge-api: image: denoland/deno:2.7.12 container_name: mocks-sdx-edge-api diff --git a/e2e/cypress.config.ts b/e2e/cypress.config.ts index 9734727f6..6308578b3 100644 --- a/e2e/cypress.config.ts +++ b/e2e/cypress.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ './cypress/tests/21-*/**/*.ts', './cypress/tests/22-*/*.ts', './cypress/tests/23-*/*.ts', + './cypress/tests/99-*/*.ts', ] return config }, diff --git a/e2e/cypress/tests/99-sp136/01-cert-token-provisioner-routing.ts b/e2e/cypress/tests/99-sp136/01-cert-token-provisioner-routing.ts new file mode 100644 index 000000000..5aaf7dadd --- /dev/null +++ b/e2e/cypress/tests/99-sp136/01-cert-token-provisioner-routing.ts @@ -0,0 +1,145 @@ +import { v4 as uuidv4 } from 'uuid' + +// SP136: Runtime group certificate-signing token generation must call +// step-ca through the provisioner (which resolves the target CA per +// environment) rather than the portal calling a single, global step-ca +// directly. +// +// The local stack runs two independent CA mocks so that "per-environment +// routing" is something this test can actually observe from the outside: +// - environment 'dev' -> step-token-api.localtest.me:2020 (internally) +// - environment 'cyp' -> step-token-api-2.localtest.me:2022 (internally) +// (see local/provisioner/environments.json - those internal hostname:port +// combinations are what the *provisioner* resolves and calls; each mock +// listens on its own internal port so the two are genuinely distinct +// endpoints on the docker network, not just distinguished by host port +// mapping). This test itself runs on the host, where both mocks resolve to +// 127.0.0.1 and are reached via their published host ports, which happen to +// match the internal ones (2020 and 2022, see docker-compose.yml). Each mock +// exposes `GET /tokens` returning how many tokens it has issued so far. +// +// Under the bug, the portal ignores the runtime group's environment and +// always calls a single global CA, so a token request for the 'cyp' +// runtime group never reaches the second mock - its count never moves. + +const DEV_CA_URL = 'http://localhost:2020' +const CYP_CA_URL = 'http://localhost:2022' + +function tokenCount(caUrl: string) { + return cy + .request({ url: `${caUrl}/tokens`, method: 'GET' }) + .then((res) => { + expect(res.status).to.be.equal(200) + return res.body.count as number + }) +} + +function shortId(): string { + return uuidv4().replace(/-/g, '').toLowerCase().substring(0, 6) +} + +describe('SP136 - Runtime group certificate-signing token routes through the provisioner', () => { + let workingData: any + const runtimeGroupDev = `d${shortId()}` + const runtimeGroupCyp = `c${shortId()}` + + before(() => { + cy.buildOrgGatewayDatasetAndProduct().then((data) => { + workingData = data + }) + }) + + it('creates runtime groups in two different environments', () => { + const { org } = workingData + + cy.setRequestBody({ name: runtimeGroupDev, environment: 'dev' }) + cy.callAPI(`ds/api/sdx/v1/organizations/${org.name}/runtime-groups`, 'PUT').then( + ({ apiRes: { status, body } }: any) => { + expect(status, JSON.stringify(body)).to.be.equal(200) + } + ) + + cy.setRequestBody({ name: runtimeGroupCyp, environment: 'cyp' }) + cy.callAPI(`ds/api/sdx/v1/organizations/${org.name}/runtime-groups`, 'PUT').then( + ({ apiRes: { status, body } }: any) => { + expect(status, JSON.stringify(body)).to.be.equal(200) + } + ) + }) + + it("routes the 'dev' runtime group's token request to the 'dev' environment's CA only", () => { + const { org } = workingData + + let devBaseline: number + let cypBaseline: number + + tokenCount(DEV_CA_URL).then((count) => { + devBaseline = count + }) + tokenCount(CYP_CA_URL).then((count) => { + cypBaseline = count + }) + + cy.clearRequestBody() + cy.setQueryString({}) + cy.callAPI( + `ds/api/sdx/v1/organizations/${org.name}/runtime-groups/${runtimeGroupDev}/environments/dev/tokens`, + 'POST' + ).then(({ apiRes: { status, body } }: any) => { + expect(status, JSON.stringify(body)).to.be.equal(200) + expect(body.token).to.be.a('string') + }) + + tokenCount(DEV_CA_URL).then((count) => { + expect(count, "dev CA should have issued the 'dev' runtime group's token").to.be.equal( + devBaseline + 1 + ) + }) + tokenCount(CYP_CA_URL).then((count) => { + expect( + count, + "cyp CA should not have been called for the 'dev' runtime group's token" + ).to.be.equal(cypBaseline) + }) + }) + + it("routes the 'cyp' runtime group's token request to the 'cyp' environment's CA only", () => { + const { org } = workingData + + let devBaseline: number + let cypBaseline: number + + tokenCount(DEV_CA_URL).then((count) => { + devBaseline = count + }) + tokenCount(CYP_CA_URL).then((count) => { + cypBaseline = count + }) + + cy.clearRequestBody() + cy.setQueryString({}) + cy.callAPI( + `ds/api/sdx/v1/organizations/${org.name}/runtime-groups/${runtimeGroupCyp}/environments/cyp/tokens`, + 'POST' + ).then(({ apiRes: { status, body } }: any) => { + expect(status, JSON.stringify(body)).to.be.equal(200) + expect(body.token).to.be.a('string') + }) + + // This is the assertion that fails on unfixed code: today every token + // request - regardless of the runtime group's environment - lands on the + // single global STEP_TOKEN_URL (the 'dev' CA), so the 'cyp' CA's count + // never moves and this expectation fails. + tokenCount(CYP_CA_URL).then((count) => { + expect(count, "cyp CA should have issued the 'cyp' runtime group's token").to.be.equal( + cypBaseline + 1 + ) + }) + tokenCount(DEV_CA_URL).then((count) => { + expect( + count, + "dev CA should not have been called for the 'cyp' runtime group's token" + ).to.be.equal(devBaseline) + }) + }) +}) diff --git a/local/mocks/step-token-api/main.ts b/local/mocks/step-token-api/main.ts index c53c38f16..a8fe197d8 100644 --- a/local/mocks/step-token-api/main.ts +++ b/local/mocks/step-token-api/main.ts @@ -17,6 +17,12 @@ db.execute(` const secret = new TextEncoder().encode('secret-that-no-one-knows'); +// Listen port, passed as the first CLI arg so a second instance of this same +// script (a second CA mock, for SP136's per-environment routing) can run on +// its own internal port rather than sharing 2020 and being distinguished +// only by the host port mapping. +const port = Number(Deno.args[0]) || 2020; + async function generateToken(payload: any): Promise { const jwt = await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) @@ -26,7 +32,7 @@ async function generateToken(payload: any): Promise { return jwt; } -Deno.serve({ port: 2020 }, async (req: Request) => { +Deno.serve({ port }, async (req: Request) => { const url = new URL(req.url); if (url.pathname === '/tokens' && req.method === 'POST') { @@ -63,6 +69,15 @@ Deno.serve({ port: 2020 }, async (req: Request) => { }); } + if (url.pathname === '/tokens' && req.method === 'GET') { + const [count] = db.query('SELECT COUNT(*) FROM tokens')[0] as [number]; + + return new Response(JSON.stringify({ count }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ error: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, diff --git a/local/provisioner/environments.json b/local/provisioner/environments.json index b5212cc97..ec95168c5 100644 --- a/local/provisioner/environments.json +++ b/local/provisioner/environments.json @@ -4,6 +4,13 @@ "oauth_token_url": "http://keycloak.localtest.me:9081/auth/realms/master/protocol/openid-connect/token", "gwa_api_url": "http://gwa-api.localtest.me:2000/v2", "kong_admin_url": "http://kong-sdx-admin0.localtest.me:9081", - "operator_edge_url": "http://kong-sdx-edge0.localtest.me:9080" + "operator_edge_url": "http://kong-sdx-edge0.localtest.me:9080", + "ca_token_url": "http://step-token-api.localtest.me:2020" + }, + "cyp": { + "client_id": "sdx-provisioner", + "oauth_token_url": "http://keycloak.localtest.me:9081/auth/realms/master/protocol/openid-connect/token", + "kong_admin_url": "http://kong-sdx-admin0.localtest.me:9081", + "ca_token_url": "http://step-token-api-2.localtest.me:2022" } -} \ No newline at end of file +} diff --git a/provisioner-api/src/clients/ca-token/client.ts b/provisioner-api/src/clients/ca-token/client.ts new file mode 100644 index 000000000..e179c2ab7 --- /dev/null +++ b/provisioner-api/src/clients/ca-token/client.ts @@ -0,0 +1,148 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { BadGatewayError, withDetails } from '../../errors/api-errors.js'; +import type { EnvironmentsConfig } from '../../config/environments.js'; +import type { CaTokenRequest, CaTokenResponse } from './types.js'; + +/** + * Client for the CA token endpoint (step-ca). Each environment (`dev`, + * `test`, `prod`, …) has its own step-ca instance, resolved per request from + * the environments config (`ca_token_url`). Unauthenticated — the CA is + * reached over an internal network. + */ +export class CaTokenApiClient { + constructor( + private readonly environments: EnvironmentsConfig, + private readonly logger?: FastifyBaseLogger + ) { + const configured = Object.entries(environments) + .filter(([, cfg]) => Boolean(cfg.ca_token_url)) + .map(([name]) => name); + this.logger?.info( + { environments: configured.length ? configured : 'none' }, + 'CaTokenApiClient initialized' + ); + } + + /** + * `Request token` — POST {ca_token_url}/tokens + * + * Asks the environment's step-ca instance for a one-time-use + * certificate-signing token for the given subject/SANs. + */ + requestToken( + environment: string, + input: CaTokenRequest + ): Promise { + this.logger?.debug({ environment, subject: input.subject }, 'Requesting CA token'); + return this.request(environment, 'POST', 'tokens', isCaTokenResponse, input); + } + + // --- transport ---------------------------------------------------------- + + /** + * Issues a request against the CA token endpoint for the given environment. + * `path` is resolved relative to that environment's `ca_token_url`. + */ + private async request( + environment: string, + method: string, + path: string, + isValid: (value: unknown) => value is T, + body?: unknown + ): Promise { + const baseUrl = this.environments[environment]?.ca_token_url; + if (!baseUrl) { + throw withDetails( + new BadGatewayError( + `CA token endpoint is not configured for environment '${environment}'` + ), + { environment, missing: 'ca_token_url' } + ); + } + + const url = `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + const headers: Record = {}; + let payload: string | undefined; + if (body !== undefined) { + payload = JSON.stringify(body); + headers['content-type'] = 'application/json'; + } + + this.logger?.debug({ environment, method, url }, 'CA token fetch'); + + const res = await fetch(url, { method, body: payload, headers }).catch( + (err) => { + this.logger?.error( + { err, environment, method, url }, + 'CA token request failed' + ); + throw withDetails( + new BadGatewayError('CA token request failed'), + { environment, method, url } + ); + } + ); + + if (!res.ok) { + const detail = await safeText(res); + this.logger?.error( + { environment, method, url, status: res.status, detail }, + 'CA token endpoint returned an error' + ); + throw withDetails( + new BadGatewayError(`CA token endpoint responded ${res.status}`), + { + environment, + method, + url, + status: res.status, + details: safeJson(detail), + } + ); + } + + const result = await parseJson(res); + if (!isValid(result)) { + this.logger?.error( + { environment, method, url, received: result }, + 'CA token endpoint returned an unexpected response shape' + ); + throw withDetails( + new BadGatewayError('CA token endpoint returned an unexpected response shape'), + { environment, method, url, received: result } + ); + } + return result; + } +} + +function isCaTokenResponse(value: unknown): value is CaTokenResponse { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { token?: unknown }).token === 'string' + ); +} + +async function safeText(res: Response): Promise { + return res.text().catch(() => ''); +} + +function safeJson(text: string): unknown { + if (text === '') return undefined; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function parseJson(res: Response): Promise { + const text = await safeText(res); + if (text === '') return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return text as T; + } +} diff --git a/provisioner-api/src/clients/ca-token/index.ts b/provisioner-api/src/clients/ca-token/index.ts new file mode 100644 index 000000000..10e6e0844 --- /dev/null +++ b/provisioner-api/src/clients/ca-token/index.ts @@ -0,0 +1,2 @@ +export { CaTokenApiClient } from './client.js'; +export type * from './types.js'; diff --git a/provisioner-api/src/clients/ca-token/types.ts b/provisioner-api/src/clients/ca-token/types.ts new file mode 100644 index 000000000..343be9965 --- /dev/null +++ b/provisioner-api/src/clients/ca-token/types.ts @@ -0,0 +1,22 @@ +/** + * Types for the CA token endpoint (step-ca). Hand-maintained to mirror the + * mock/production `/tokens` endpoint contract. + */ + +/** + * Body for a one-time-use certificate-signing token request. + */ +export interface CaTokenRequest { + /** Certificate subject (the runtime group's host). */ + subject: string; + /** Subject alternative names for the certificate. */ + san: string[]; +} + +/** + * The CA's response to a token request. + */ +export interface CaTokenResponse { + /** One-time-use token to present when submitting the CSR to step-ca. */ + token: string; +} diff --git a/provisioner-api/src/clients/index.ts b/provisioner-api/src/clients/index.ts index 1e913cb59..3f7bf94c6 100644 --- a/provisioner-api/src/clients/index.ts +++ b/provisioner-api/src/clients/index.ts @@ -12,6 +12,7 @@ import { } from './config.js'; import { FeedApiClient } from './feed/index.js'; import { SdxOperatorApiClient } from './sdx-operator/index.js'; +import { CaTokenApiClient } from './ca-token/index.js'; import { loadEnvironments, type EnvironmentsConfig, @@ -31,6 +32,7 @@ export interface Clients { css: OAuthClient; feed: FeedApiClient; sdxOperator: SdxOperatorApiClient; + caToken: CaTokenApiClient; } function childLogger( @@ -116,5 +118,6 @@ export function buildClients(logger?: FastifyBaseLogger): Clients { css: buildSecretClient('css', 'CSS', logger), feed: new FeedApiClient(process.env.FEED_URL, logger), sdxOperator: new SdxOperatorApiClient(environments, logger), + caToken: new CaTokenApiClient(environments, logger), }; } diff --git a/provisioner-api/src/config/environments.ts b/provisioner-api/src/config/environments.ts index 4777a9ad2..282e9a5fa 100644 --- a/provisioner-api/src/config/environments.ts +++ b/provisioner-api/src/config/environments.ts @@ -28,6 +28,13 @@ export type EnvironmentConfig = { * {@link SdxOperatorApiClient} for CSR generation. */ operator_edge_url?: string; + /** + * Base URL of this environment's CA token endpoint (step-ca), e.g. + * `https://ca.dev.example.gov.bc.ca`. Consumed by the + * {@link CaTokenApiClient} for runtime group certificate-signing token + * issuance. Each environment has its own step-ca instance. + */ + ca_token_url?: string; }; /** Map of environment name (`dev`, `test`, `prod`, `sbx`, …) to its config. */ @@ -107,12 +114,14 @@ function validate(parsed: unknown, path: string): EnvironmentsConfig { optionalString(entry, 'client_id', name, path); optionalString(entry, 'gwa_api_url', name, path); optionalString(entry, 'operator_edge_url', name, path); + optionalString(entry, 'ca_token_url', name, path); result[name] = { client_id: entry.client_id as string | undefined, oauth_token_url: entry.oauth_token_url as string, kong_admin_url: entry.kong_admin_url as string, gwa_api_url: entry.gwa_api_url as string | undefined, operator_edge_url: entry.operator_edge_url as string | undefined, + ca_token_url: entry.ca_token_url as string | undefined, }; } return result; diff --git a/provisioner-api/src/controllers/runtime-groups-controller.ts b/provisioner-api/src/controllers/runtime-groups-controller.ts index 2a2f17743..be7cef3e7 100644 --- a/provisioner-api/src/controllers/runtime-groups-controller.ts +++ b/provisioner-api/src/controllers/runtime-groups-controller.ts @@ -1,5 +1,6 @@ import type { Services } from '../services/index.js'; import type { CsrResponse } from '../clients/sdx-operator/index.js'; +import type { CaTokenResponse } from '../clients/ca-token/index.js'; import type { TCsrRequest } from '../schemas/runtime-groups.js'; import { FastifyBaseLogger } from 'fastify/types/logger.js'; @@ -10,6 +11,12 @@ export interface CreateCsrInput { request: TCsrRequest; } +export interface GenerateCertTokenInput { + org: string; + name: string; + environment: string; +} + export class RuntimeGroupsController { constructor( private readonly services: Services, @@ -24,4 +31,18 @@ export class RuntimeGroupsController { input.request ); } + + async generateCertToken( + input: GenerateCertTokenInput + ): Promise { + this.logger?.debug( + { org: input.org, name: input.name, environment: input.environment }, + 'RuntimeGroupsController.generateCertToken' + ); + return await this.services.caToken.generateCertToken( + input.org, + input.name, + input.environment + ); + } } diff --git a/provisioner-api/src/openapi/path-summaries.ts b/provisioner-api/src/openapi/path-summaries.ts index 88bfa01fd..7c6b485fd 100644 --- a/provisioner-api/src/openapi/path-summaries.ts +++ b/provisioner-api/src/openapi/path-summaries.ts @@ -42,4 +42,9 @@ export const pathSummaries: Record< description: "Requests a new key pair and CSR from the runtime group's edge server for the target environment.", }, + '/runtime-groups/{name}/environments/{env}/cert-token': { + summary: 'Runtime group certificate-signing token generation', + description: + "Requests a one-time-use certificate-signing token from the target environment's CA (step-ca).", + }, }; diff --git a/provisioner-api/src/routes/connections.ts b/provisioner-api/src/routes/connections.ts index da61f9bc8..705cb07ea 100644 --- a/provisioner-api/src/routes/connections.ts +++ b/provisioner-api/src/routes/connections.ts @@ -6,7 +6,7 @@ import { ConnectionChangeResponse, } from '../schemas/resources.js'; -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerResourcesRoutes: FastifyPluginAsyncTypebox = async ( app diff --git a/provisioner-api/src/routes/gateways.ts b/provisioner-api/src/routes/gateways.ts index 51cacc125..6f950dd84 100644 --- a/provisioner-api/src/routes/gateways.ts +++ b/provisioner-api/src/routes/gateways.ts @@ -8,7 +8,7 @@ const GATEWAY_ID_DESC = const ENVIRONMENT_DESC = 'Target environment (for example `dev`, `test`, or `prod`) whose GWA endpoint the request is routed to.'; -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerGatewaysRoutes: FastifyPluginAsyncTypebox = async ( app diff --git a/provisioner-api/src/routes/integrationAccess.ts b/provisioner-api/src/routes/integrationAccess.ts index e7dca932e..718446993 100644 --- a/provisioner-api/src/routes/integrationAccess.ts +++ b/provisioner-api/src/routes/integrationAccess.ts @@ -63,7 +63,7 @@ const AllowedServicesResponse = Type.Ref(IntegrationAccessRequest, { ], }); -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerIntegrationAccessRoutes: FastifyPluginAsyncTypebox = async (app) => { diff --git a/provisioner-api/src/routes/patterns.ts b/provisioner-api/src/routes/patterns.ts index 78344fc79..55f91bd14 100644 --- a/provisioner-api/src/routes/patterns.ts +++ b/provisioner-api/src/routes/patterns.ts @@ -10,7 +10,7 @@ import { ApplyResourcesResponse, } from '../schemas/resources.js'; -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerPatternsRoutes: FastifyPluginAsyncTypebox = async ( app diff --git a/provisioner-api/src/routes/resource-servers.ts b/provisioner-api/src/routes/resource-servers.ts index e5cffa1d5..880c561a2 100644 --- a/provisioner-api/src/routes/resource-servers.ts +++ b/provisioner-api/src/routes/resource-servers.ts @@ -63,7 +63,7 @@ const AllowedServicesResponse = Type.Array(Type.Ref(IntegrationAccessRequest), { ], }); -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerResourceServersRoutes: FastifyPluginAsyncTypebox = async ( app diff --git a/provisioner-api/src/routes/runtime-groups.ts b/provisioner-api/src/routes/runtime-groups.ts index f679ab346..1f8b18caa 100644 --- a/provisioner-api/src/routes/runtime-groups.ts +++ b/provisioner-api/src/routes/runtime-groups.ts @@ -1,14 +1,18 @@ import { Type } from '@sinclair/typebox'; import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; -import { CsrRequest, CsrResponse } from '../schemas/runtime-groups.js'; +import { + CsrRequest, + CsrResponse, + CertTokenResponse, +} from '../schemas/runtime-groups.js'; const RUNTIME_GROUP_DESC = 'Name of the runtime group the CSR is requested for.'; const ENVIRONMENT_DESC = 'Target environment (for example `dev`, `test`, or `prod`) whose edge server the request is routed to.'; -const security = [{ jwt: [] }]; +const security = [] as any[]; export const registerRuntimeGroupsRoutes: FastifyPluginAsyncTypebox = async ( app @@ -50,4 +54,43 @@ export const registerRuntimeGroupsRoutes: FastifyPluginAsyncTypebox = async ( request: req.body, }) ); + + app.post( + '/organizations/:org/runtime-groups/:name/environments/:env/cert-token', + { + schema: { + tags: ['Runtime Groups'], + summary: 'Generate runtime group certificate-signing token', + operationId: 'generateRuntimeGroupCertToken', + description: + 'Requests a one-time-use certificate-signing token from the ' + + "target environment's CA (step-ca), resolved from that " + + 'environment configuration rather than a single, global CA. ' + + 'The certificate subject and SANs are derived from the runtime ' + + 'group named in the path.', + security, + params: Type.Object({ + org: Type.String({ + description: 'Organization name the runtime group belongs to.', + examples: ['my-org'], + }), + name: Type.String({ + description: RUNTIME_GROUP_DESC, + examples: ['my-rg'], + }), + env: Type.String({ + description: ENVIRONMENT_DESC, + examples: ['dev'], + }), + }), + response: { 200: Type.Ref(CertTokenResponse) }, + }, + }, + async (req) => + app.controllers.runtimeGroups.generateCertToken({ + org: req.params.org, + name: req.params.name, + environment: req.params.env, + }) + ); }; diff --git a/provisioner-api/src/schemas/runtime-groups.ts b/provisioner-api/src/schemas/runtime-groups.ts index 79feaaa0c..2fa242d81 100644 --- a/provisioner-api/src/schemas/runtime-groups.ts +++ b/provisioner-api/src/schemas/runtime-groups.ts @@ -47,4 +47,21 @@ export const CsrResponse = Type.Object( export type TCsrRequest = Static; -export const runtimeGroupSchemas = [CsrRequest, CsrResponse]; +/** + * The CA's response to a certificate-signing token request. The subject and + * SANs are derived server-side from the runtime group named in the path, so + * no request body is needed. + */ +export const CertTokenResponse = Type.Object( + { + token: Type.String({ + description: 'One-time-use certificate-signing token issued by the CA.', + }), + }, + { + $id: 'CertTokenResponse', + additionalProperties: false, + } +); + +export const runtimeGroupSchemas = [CsrRequest, CsrResponse, CertTokenResponse]; diff --git a/provisioner-api/src/services/ca-token-service.ts b/provisioner-api/src/services/ca-token-service.ts new file mode 100644 index 000000000..ac6a24aaa --- /dev/null +++ b/provisioner-api/src/services/ca-token-service.ts @@ -0,0 +1,71 @@ +import type { FastifyBaseLogger } from 'fastify'; +import type { CaTokenApiClient } from '../clients/ca-token/index.js'; +import type { CaTokenResponse } from '../clients/ca-token/index.js'; +import type { SdxMemberApiClient } from '../clients/sdx-member/index.js'; +import { BadRequestError } from '../errors/api-errors.js'; + +/** + * Drives the CA token endpoint (step-ca). Keyed by environment because each + * environment has its own CA instance (`ca_token_url`). + */ +export class CaTokenService { + constructor( + private readonly sdxMember: SdxMemberApiClient, + private readonly api: CaTokenApiClient, + private readonly logger?: FastifyBaseLogger + ) {} + + /** + * Requests a one-time-use certificate-signing token from the environment's + * step-ca instance. The certificate subject and SANs are derived from the + * runtime group's host and SDX endpoint rather than accepted from the + * caller, since the runtime group named in the path is the only thing + * that's authoritative for them. + */ + async generateCertToken( + org: string, + runtimeGroup: string, + environment: string + ): Promise { + this.logger?.debug( + { org, runtimeGroup, environment }, + 'CaTokenService.generateCertToken' + ); + + // The certificate is being minted for a runtime group the org owns + // (unlike the CSR flow, which resolves against hosted/available runtime + // groups), so look it up with the 'owned' filter. + const rgList = await this.sdxMember.listRuntimeGroups(org, { + filter: 'owned', + }); + const rg = rgList.find( + (rg) => rg.name === runtimeGroup && rg.environment === environment + ); + + if (!rg || !rg.host) { + throw new BadRequestError( + `Runtime Group ${runtimeGroup} not found for org ${org} in environment ${environment}` + ); + } + + // The SANs should include the runtime group's host and the SDX + // endpoint's hostname (if different) to ensure the certificate is valid + // for both. + const san = [rg.host]; + if (rg.sdxEndpoint) { + try { + const hostname = new URL(rg.sdxEndpoint).hostname; + if (hostname !== rg.host) { + san.push(hostname); + } + } catch (err) { + this.logger?.warn( + { sdxEndpoint: rg.sdxEndpoint, runtimeGroup, environment }, + 'Invalid SDX endpoint URL for runtime group' + ); + } + } + + return this.api.requestToken(environment, { subject: rg.host, san }); + } +} diff --git a/provisioner-api/src/services/index.ts b/provisioner-api/src/services/index.ts index 0dac42609..6cfaeef63 100644 --- a/provisioner-api/src/services/index.ts +++ b/provisioner-api/src/services/index.ts @@ -10,6 +10,7 @@ import { IntegrationAccessService } from './integration-access-service.js'; import { ResourceDispatcher } from './resource-dispatcher.js'; import { ActivityService } from './activity-service.js'; import { SdxOperatorService } from './sdx-operator-service.js'; +import { CaTokenService } from './ca-token-service.js'; import { SdxMemberApiClient } from '../clients/sdx-member/index.js'; import { loadEnvironments } from '../config/environments.js'; @@ -21,6 +22,7 @@ export { CommonSsoService, PatternsEvaluatorService, SdxOperatorService, + CaTokenService, }; export interface Services { @@ -34,6 +36,7 @@ export interface Services { patternsEvaluator: PatternsEvaluatorService; resourceDispatcher: ResourceDispatcher; sdxOperator: SdxOperatorService; + caToken: CaTokenService; } function child( @@ -104,5 +107,10 @@ export function buildServices( clients.sdxOperator, child(logger, 'sdxOperator') ), + caToken: new CaTokenService( + new SdxMemberApiClient(clients.sdx, child(logger, 'sdxMember')), + clients.caToken, + child(logger, 'caToken') + ), }; } diff --git a/src/controllers/sdx/v1/OrgRuntimeGroupController.ts b/src/controllers/sdx/v1/OrgRuntimeGroupController.ts index e8afcc49d..d45f4772d 100644 --- a/src/controllers/sdx/v1/OrgRuntimeGroupController.ts +++ b/src/controllers/sdx/v1/OrgRuntimeGroupController.ts @@ -256,7 +256,7 @@ export class RuntimeGroupController extends Controller { 'Runtime Group not found for the specified environment' ); - const token = await service.generateCertSignRequestToken(rg); + const token = await service.generateCertSignRequestToken(org, rg); return { token }; } diff --git a/src/services/batch/runtime-group.ts b/src/services/batch/runtime-group.ts index 083c34b32..79e187723 100644 --- a/src/services/batch/runtime-group.ts +++ b/src/services/batch/runtime-group.ts @@ -14,7 +14,7 @@ import { RuntimeGroup as KeystoneRuntimeGroup } from '../keystone/types'; import { BatchResult } from '../../batch/types'; import { regExprValidation } from '../utils'; import { Logger } from '../../logger'; -import { StepTokenService } from '../certificate-authority/step-token'; +import { ProvisionerService } from '../provisioner'; import { assertIsDefined } from '../../controllers/ioc/assert'; const logger = Logger('batch.runtime-group'); @@ -284,43 +284,28 @@ class RuntimeGroupService { }; generateCertSignRequestToken = async ( + org: string, runtimeGroup: KeystoneRuntimeGroup ): Promise => { - let sdxEndpoint: URL; - try { - sdxEndpoint = new URL(runtimeGroup.sdxEndpoint); - } catch (err) { - logger.error( - "Invalid SDX endpoint URL '%s' for runtime group '%s'", - runtimeGroup.sdxEndpoint, - runtimeGroup.name - ); - } - assert.strictEqual( - sdxEndpoint !== undefined, - true, - 'A valid SDX Endpoint URL is required for requesting a token' - ); - - // The subject alternative names (SANs) for the certificate should include - // the runtime group's host and the SDX endpoint hostname (if different) - // to ensure the certificate is valid for both. - const san = [runtimeGroup.host]; - if (sdxEndpoint.hostname !== runtimeGroup.host) { - san.push(sdxEndpoint.hostname); - } - const stepTokenService = new StepTokenService(process.env.STEP_TOKEN_URL); + // The provisioner resolves the target step-ca instance from the runtime + // group's environment, rather than the portal calling a single, global + // step-ca directly. It also derives the certificate subject and SANs + // from the runtime group named in the path, so they don't need to be + // passed here. + const provisioner = new ProvisionerService(process.env.PROVISIONER_URL!); logger.debug( - "Requesting token for runtime group '%s' with SANs: %o", + "Requesting token for runtime group '%s' (environment '%s')", runtimeGroup.name, - san + runtimeGroup.environment ); - return await stepTokenService.requestOneTimeUseToken({ - subject: runtimeGroup.host, - san, - }); + const { token } = await provisioner.postCertToken( + org, + runtimeGroup.name, + runtimeGroup.environment + ); + return token; }; } diff --git a/src/services/certificate-authority/step-token.ts b/src/services/certificate-authority/step-token.ts deleted file mode 100644 index 750b11990..000000000 --- a/src/services/certificate-authority/step-token.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { checkStatus } from '../checkStatus'; -import fetch from 'node-fetch'; -import { Logger } from '../../logger'; - -const logger = Logger('StepTokenService'); - -export interface StepTokenRequest { - subject: string; - san: string[]; -} - -export class StepTokenService { - private stepTokenUrl: string; - - constructor(stepTokenUrl: string) { - this.stepTokenUrl = stepTokenUrl; - } - - public async requestOneTimeUseToken( - request: StepTokenRequest - ): Promise { - const url = `${this.stepTokenUrl}/tokens`; - logger.debug('[requestOneTimeUseToken]'); - return await fetch(url, { - method: 'post', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }) - .then(checkStatus) - .then((res) => res.json()) - .then((data) => data.token); - } -} diff --git a/src/services/provisioner/provisioner-service.ts b/src/services/provisioner/provisioner-service.ts index a7e8dc65f..7564fde3f 100644 --- a/src/services/provisioner/provisioner-service.ts +++ b/src/services/provisioner/provisioner-service.ts @@ -13,6 +13,10 @@ type CSRRequest = { requester_email: string; }; +type CertTokenResponse = { + token: string; +}; + type ConnectionRequestChangeEventResponse = { applied: number; failed: number; @@ -89,6 +93,28 @@ export class ProvisionerService { return res; } + public async postCertToken( + org: string, + runtimeGroup: string, + environment: string + ): Promise { + logger.debug( + 'Calling %s', + `${this.provisionerUrl}/organizations/${org}/runtime-groups/${runtimeGroup}/environments/${environment}/cert-token` + ); + + const res = await fetch( + `${this.provisionerUrl}/organizations/${org}/runtime-groups/${runtimeGroup}/environments/${environment}/cert-token`, + { + method: 'POST', + } + ) + .then(checkStatus) + .then((r) => r.json()); + + return res; + } + public async postConnectionRequestChangeEvent( connection: ConnectionRequest, action: 'apply' | 'delete' From 2e3ddee4389411e08fef324af2786e572889fabf Mon Sep 17 00:00:00 2001 From: ike thecoder Date: Fri, 7 Aug 2026 16:30:13 -0400 Subject: [PATCH 10/10] SP136: Return RBAC roles via includeAccess on /catalog/organizations (#1524) --- .../02-catalog-organizations-access.ts | 49 ++++++++++++++++++ src/controllers/sdx/v1/CatalogController.ts | 51 ++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 e2e/cypress/tests/99-sp136/02-catalog-organizations-access.ts diff --git a/e2e/cypress/tests/99-sp136/02-catalog-organizations-access.ts b/e2e/cypress/tests/99-sp136/02-catalog-organizations-access.ts new file mode 100644 index 000000000..fd9a51204 --- /dev/null +++ b/e2e/cypress/tests/99-sp136/02-catalog-organizations-access.ts @@ -0,0 +1,49 @@ +describe('SP136 - Catalog organizations includeAccess', () => { + let workingData: any + + before(() => { + cy.buildOrgGatewayDatasetAndProduct().then((data) => { + workingData = data + }) + }) + + it('GET /catalog/organizations/{name} - does not include access by default', () => { + const { org } = workingData + + cy.callAPI( + `ds/api/sdx/v1/catalog/organizations/${org.name}`, + 'GET' + ).then(({ apiRes: { status, body } }: any) => { + expect(status).to.be.equal(200) + expect(body.name).to.be.equal(org.name) + expect(body).to.not.have.property('access') + }) + }) + + it('GET /catalog/organizations/{name}?includeAccess=true - includes the RBAC roles granted on the organization', () => { + const { org } = workingData + + cy.callAPI( + `ds/api/sdx/v1/catalog/organizations/${org.name}?includeAccess=true`, + 'GET' + ).then(({ apiRes: { status, body } }: any) => { + expect(status).to.be.equal(200) + expect(body.name).to.be.equal(org.name) + expect(body.access).to.be.an('array') + + // buildOrgGatewayDatasetAndProduct() grants janis@testmail.com + // organization-admin + system-admin at the org level as a side effect + const creator = body.access.find( + (m: any) => + m.member.email === 'janis@testmail.com' && + ['organization-admin', 'system-admin'].every((role) => + m.roles.includes(role) + ) + ) + expect( + creator, + 'expected janis@testmail.com to hold organization-admin and system-admin on the organization' + ).to.exist + }) + }) +}) diff --git a/src/controllers/sdx/v1/CatalogController.ts b/src/controllers/sdx/v1/CatalogController.ts index c0caa9c84..4f9060aa3 100644 --- a/src/controllers/sdx/v1/CatalogController.ts +++ b/src/controllers/sdx/v1/CatalogController.ts @@ -1,4 +1,3 @@ -import { assertAndRaiseValidateError } from '../../../services/gateway-patterns/evaluator'; import { Controller, Example, @@ -43,6 +42,9 @@ import { import { KeystoneService } from '../../ioc/keystoneInjector'; import assert from 'assert'; import { ResourceScope } from '../../../services/workflow/openapi-spec-loader'; +import { GroupAccessService } from '../../../services/org-groups'; +import { getGwaProductEnvironment } from '../../../services/workflow'; +import { GroupMember } from '../../../services/org-groups/types'; interface MissingCredentialsJSON { code: 'credentials_required' | 'invalid_token'; @@ -108,6 +110,53 @@ export class CatalogController extends Controller { .map((o) => removeEmpty(o)); } + /** + * Retrieve details for a specific organization in the catalog by name. + * + * @summary Retrieve details for an organization + * @param name - Organization name + */ + @Get('/organizations/{name}') + @OperationId('organization-get') + public async getOrganization( + @Path('name') name: string, + @Query('includeAccess') includeAccess: boolean = false + ): Promise { + const ctx = this.keystone.sudo(); + const orgs = await getOrganizations(ctx); + const match = orgs.find((o) => o.name === name); + assert.strictEqual(typeof match === 'undefined', false, 'Organization not found'); + + const result: any = { + name: match!.name, + title: match!.title, + description: match!.description, + publicBodyId: match!.publicBodyId, + member: getOrganizationMemberDetails(match!.tags), + }; + + if (includeAccess) { + const prodEnv = await getGwaProductEnvironment(ctx, false); + const groupAccessService = new GroupAccessService(prodEnv.uma2); + await groupAccessService.login( + prodEnv.issuerEnvConfig.clientId!, + prodEnv.issuerEnvConfig.clientSecret! + ); + + const membership = await groupAccessService.getGroupMembership( + result.name + ); + result.access = membership + ? (removeKeys(membership.members as GroupMember[], [ + 'id', + 'username', + ]) as GroupMember[]) + : []; + } + + return removeEmpty(result); + } + /** * * @returns