From aec26cbdbb1960f8f9a1bf684e43356cf8f50558 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Tue, 28 Jul 2026 09:47:38 -0700 Subject: [PATCH 01/11] Add gateway CredentialIssuer.Generate APIs to issue and regenerate consumer credentials --- src/auth/scope-role-utils.ts | 3 + .../v3/GatewayConsumersController.ts | 109 +++++ src/controllers/v3/openapi.yaml | 200 ++++++++ src/controllers/v3/routes.ts | 129 +++++ src/controllers/v3/types-extra.ts | 79 ++++ src/jest.config.js | 2 +- src/lists/Application.js | 7 +- .../namespace-access-dialog.tsx | 2 + src/services/keycloak/namespace-details.ts | 25 +- src/services/keystone/application.ts | 67 +++ src/services/keystone/gateway-consumer.ts | 2 + src/services/keystone/index.ts | 5 +- src/services/keystone/product-environment.ts | 35 ++ src/services/keystone/service-access.ts | 59 +++ .../uma2/resource-registration-service.ts | 39 ++ src/services/workflow/apply.ts | 4 +- src/services/workflow/create-namespace.ts | 1 + src/services/workflow/index.ts | 4 + .../workflow/issue-gateway-credential.ts | 445 ++++++++++++++++++ .../workflow/regenerate-gateway-credential.ts | 149 ++++++ ...scope-role-utils-credential-issuer.test.ts | 19 + .../workflow/issue-gateway-credential.test.ts | 272 +++++++++++ .../regenerate-gateway-credential.test.ts | 143 ++++++ src/tsoa-v3.json | 4 + 24 files changed, 1789 insertions(+), 15 deletions(-) create mode 100644 src/controllers/v3/GatewayConsumersController.ts create mode 100644 src/services/workflow/issue-gateway-credential.ts create mode 100644 src/services/workflow/regenerate-gateway-credential.ts create mode 100644 src/test/auth/scope-role-utils-credential-issuer.test.ts create mode 100644 src/test/services/workflow/issue-gateway-credential.test.ts create mode 100644 src/test/services/workflow/regenerate-gateway-credential.test.ts diff --git a/src/auth/scope-role-utils.ts b/src/auth/scope-role-utils.ts index 935ff974f..56744b693 100644 --- a/src/auth/scope-role-utils.ts +++ b/src/auth/scope-role-utils.ts @@ -16,6 +16,9 @@ export function scopesToRoles( if (scopes.includes('CredentialIssuer.Admin')) { _roles.push('credential-admin'); } + if (scopes.includes('CredentialIssuer.Generate')) { + _roles.push('credential-issuer'); + } if (scopes.includes('Access.Manage')) { _roles.push('access-manager'); } diff --git a/src/controllers/v3/GatewayConsumersController.ts b/src/controllers/v3/GatewayConsumersController.ts new file mode 100644 index 000000000..e6c85af1c --- /dev/null +++ b/src/controllers/v3/GatewayConsumersController.ts @@ -0,0 +1,109 @@ +import { + Body, + Controller, + OperationId, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, + SuccessResponse, +} from 'tsoa'; +import { inject, injectable } from 'tsyringe'; +import { KeystoneService } from '../ioc/keystoneInjector'; +import { Logger } from '../../logger'; +import { + issueGatewayCredential, + regenerateGatewayCredential, +} from '../../services/workflow'; +import { + GatewayConsumerCredential, + IssueGatewayConsumerRequest, +} from './types-extra'; +import { strict as assert } from 'assert'; + +const logger = Logger('controllers.GatewayConsumers'); + +@injectable() +@Route('/gateways/{gatewayId}/consumers') +@Tags('Gateway Consumers') +export class GatewayConsumersController extends Controller { + private keystone: KeystoneService; + constructor(@inject('KeystoneService') private _keystone: KeystoneService) { + super(); + this.keystone = _keystone; + } + + /** + * Issue a new consumer credential for a product environment in this gateway. + * + * Creates Application (owner optional), Consumer and ServiceAccess records. + * Applications can be reused across environments by passing `application.appId`. + * + * > `Required Scope:` CredentialIssuer.Generate + * + * @summary Issue consumer credential + */ + @Post() + @OperationId('issue-gateway-consumer') + @SuccessResponse('201', 'Created') + @Security('jwt', ['CredentialIssuer.Generate']) + public async issue( + @Path() gatewayId: string, + @Body() body: IssueGatewayConsumerRequest, + @Request() request: any + ): Promise { + logger.debug('[issue] gateway=%s body=%j', gatewayId, body); + + const ctx = this.keystone.createContext(request, true); + const credential = await issueGatewayCredential(ctx, gatewayId, { + environmentAppId: body.environmentAppId, + application: body.application || {}, + labels: body.labels, + controls: body.controls as any, + }); + + this.setStatus(201); + return credential; + } + + /** + * Regenerate credentials in place for an existing consumer (same clientId). + * + * Currently the only supported action is `regenerate`. + * DELETE / revoke via API is a follow-up; revoke via the Consumers page for now. + * + * > `Required Scope:` CredentialIssuer.Generate + * + * @summary Regenerate consumer credential + * @param action Must be `regenerate` + */ + @Put('{clientId}') + @OperationId('regenerate-gateway-consumer') + @Security('jwt', ['CredentialIssuer.Generate']) + public async regenerate( + @Path() gatewayId: string, + @Path() clientId: string, + @Query() action: 'regenerate', + @Request() request: any + ): Promise { + assert.strictEqual( + action, + 'regenerate', + `Unsupported action '${action}'. Only 'regenerate' is supported.` + ); + + logger.debug( + '[regenerate] gateway=%s clientId=%s action=%s', + gatewayId, + clientId, + action + ); + + const ctx = this.keystone.createContext(request, true); + return regenerateGatewayCredential(ctx, gatewayId, clientId); + } +} diff --git a/src/controllers/v3/openapi.yaml b/src/controllers/v3/openapi.yaml index 3d8273496..2bfc9782e 100644 --- a/src/controllers/v3/openapi.yaml +++ b/src/controllers/v3/openapi.yaml @@ -260,6 +260,128 @@ components: parameters: service_name: my-service service_url: 'https://httpbun.com' + GatewayConsumerCredential: + description: "Credential response modeled on NewCredential.\nFields present depend on flow / authenticator." + properties: + flow: + type: string + clientId: + type: string + clientSecret: + type: string + issuer: + type: string + tokenEndpoint: + type: string + apiKey: + type: string + clientPublicKey: + type: string + clientPrivateKey: + type: string + required: + - flow + type: object + additionalProperties: false + example: + flow: kong-api-key-acl + clientId: 23C4F461-A1B2C3D4E5F + apiKey: abcdef0123456789 + IssueGatewayConsumerApplication: + properties: + appId: + type: string + description: "Reuse an existing Application in this gateway (multi-env).\nWhen set, name/description are ignored." + name: + type: string + description: 'Required when creating a new Application' + description: + type: string + type: object + additionalProperties: false + IssueGatewayConsumerPlugin: + properties: + name: + type: string + config: + properties: {} + additionalProperties: {} + type: object + service: + properties: + name: + type: string + type: object + route: + properties: + name: + type: string + type: object + required: + - name + type: object + additionalProperties: false + IssueGatewayConsumerControls: + properties: + defaultClientScopes: + items: + type: string + type: array + defaultOptionalScopes: + items: + type: string + type: array + roles: + items: + type: string + type: array + aclGroups: + items: + type: string + type: array + clientGenCertificate: + type: boolean + clientCertificate: + type: string + jwksUrl: + type: string + plugins: + items: + $ref: '#/components/schemas/IssueGatewayConsumerPlugin' + type: array + type: object + additionalProperties: false + IssueGatewayConsumerRequest: + properties: + environmentAppId: + type: string + description: 'Environment.appId from GET /gateways/{gateway}/products' + application: + $ref: '#/components/schemas/IssueGatewayConsumerApplication' + labels: + properties: {} + additionalProperties: + type: string + type: object + description: 'Optional labels for filtering on the Consumers page, e.g. { "issued-by": "notify" }' + controls: + $ref: '#/components/schemas/IssueGatewayConsumerControls' + description: 'Optional controls; validity depends on the environment flow' + required: + - environmentAppId + - application + type: object + additionalProperties: false + example: + environmentAppId: 23C4F461 + application: + name: notify-tenant-a + description: 'Tenant A' + labels: + issued-by: notify + controls: + aclGroups: + - notify-tenant-a Gateway: properties: gatewayId: @@ -594,6 +716,8 @@ components: items: type: string type: array + permRuntimeGroup: + type: string updatedAt: type: number format: double @@ -670,6 +794,8 @@ components: items: $ref: '#/components/schemas/Environment' type: array + organization: + $ref: '#/components/schemas/OrganizationRefID' type: object additionalProperties: false example: @@ -1006,6 +1132,77 @@ paths: application/json: schema: $ref: '#/components/schemas/GatewayPatternConfigRequest' + '/gateways/{gatewayId}/consumers': + post: + operationId: issue-gateway-consumer + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayConsumerCredential' + description: "Issue a new consumer credential for a product environment in this gateway.\n\nCreates Application (owner optional), Consumer and ServiceAccess records.\nApplications can be reused across environments by passing `application.appId`.\n\n> `Required Scope:` CredentialIssuer.Generate" + summary: 'Issue consumer credential' + tags: + - 'Gateway Consumers' + security: + - + jwt: + - CredentialIssuer.Generate + parameters: + - + in: path + name: gatewayId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IssueGatewayConsumerRequest' + '/gateways/{gatewayId}/consumers/{clientId}': + put: + operationId: regenerate-gateway-consumer + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayConsumerCredential' + description: "Regenerate credentials in place for an existing consumer (same clientId).\n\nCurrently the only supported action is `regenerate`.\nDELETE / revoke via API is a follow-up; revoke via the Consumers page for now.\n\n> `Required Scope:` CredentialIssuer.Generate" + summary: 'Regenerate consumer credential' + tags: + - 'Gateway Consumers' + security: + - + jwt: + - CredentialIssuer.Generate + parameters: + - + in: path + name: gatewayId + required: true + schema: + type: string + - + in: path + name: clientId + required: true + schema: + type: string + - + description: 'Must be `regenerate`' + in: query + name: action + required: true + schema: + type: string + enum: + - regenerate /gateways/report: get: operationId: report @@ -1870,3 +2067,6 @@ tags: - name: 'Authorization Profiles' description: 'Configure the integration to external Identity Providers' + - + name: 'Gateway Consumers' + description: 'Issue and regenerate consumer credentials for services in a gateway' diff --git a/src/controllers/v3/routes.ts b/src/controllers/v3/routes.ts index 7d704a294..0a22127f3 100644 --- a/src/controllers/v3/routes.ts +++ b/src/controllers/v3/routes.ts @@ -13,6 +13,8 @@ import { EndpointsController } from './EndpointsController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { GatewayConfigController } from './GatewayConfigController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { GatewayConsumersController } from './GatewayConsumersController'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { NamespaceController } from './GatewayController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { GatewayDirectoryController } from './GatewayDirectoryController'; @@ -169,6 +171,68 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "GatewayConsumerCredential": { + "dataType": "refObject", + "properties": { + "flow": {"dataType":"string","required":true}, + "clientId": {"dataType":"string"}, + "clientSecret": {"dataType":"string"}, + "issuer": {"dataType":"string"}, + "tokenEndpoint": {"dataType":"string"}, + "apiKey": {"dataType":"string"}, + "clientPublicKey": {"dataType":"string"}, + "clientPrivateKey": {"dataType":"string"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "IssueGatewayConsumerApplication": { + "dataType": "refObject", + "properties": { + "appId": {"dataType":"string"}, + "name": {"dataType":"string"}, + "description": {"dataType":"string"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "IssueGatewayConsumerPlugin": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "config": {"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"any"}}, + "service": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string"}}}, + "route": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string"}}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "IssueGatewayConsumerControls": { + "dataType": "refObject", + "properties": { + "defaultClientScopes": {"dataType":"array","array":{"dataType":"string"}}, + "defaultOptionalScopes": {"dataType":"array","array":{"dataType":"string"}}, + "roles": {"dataType":"array","array":{"dataType":"string"}}, + "aclGroups": {"dataType":"array","array":{"dataType":"string"}}, + "clientGenCertificate": {"dataType":"boolean"}, + "clientCertificate": {"dataType":"string"}, + "jwksUrl": {"dataType":"string"}, + "plugins": {"dataType":"array","array":{"dataType":"refObject","ref":"IssueGatewayConsumerPlugin"}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "IssueGatewayConsumerRequest": { + "dataType": "refObject", + "properties": { + "environmentAppId": {"dataType":"string","required":true}, + "application": {"ref":"IssueGatewayConsumerApplication","required":true}, + "labels": {"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"string"}}, + "controls": {"ref":"IssueGatewayConsumerControls"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "Gateway": { "dataType": "refObject", "properties": { @@ -384,6 +448,7 @@ const models: TsoaRoute.Models = { "enabled": {"dataType":"boolean","required":true}, "permDataPlane": {"dataType":"string"}, "permDomains": {"dataType":"array","array":{"dataType":"string"}}, + "permRuntimeGroup": {"dataType":"string"}, "updatedAt": {"dataType":"double","required":true}, }, "additionalProperties": false, @@ -429,6 +494,7 @@ const models: TsoaRoute.Models = { "gatewayId": {"dataType":"string"}, "dataset": {"ref":"DraftDatasetRefID"}, "environments": {"dataType":"array","array":{"dataType":"refObject","ref":"Environment"}}, + "organization": {"ref":"OrganizationRefID"}, }, "additionalProperties": false, }, @@ -744,6 +810,69 @@ export function RegisterRoutes(app: express.Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/ds/api/v3/gateways/:gatewayId/consumers', + authenticateMiddleware([{"jwt":["CredentialIssuer.Generate"]}]), + + async function GatewayConsumersController_issue(request: any, response: any, next: any) { + const args = { + gatewayId: {"in":"path","name":"gatewayId","required":true,"dataType":"string"}, + body: {"in":"body","name":"body","required":true,"ref":"IssueGatewayConsumerRequest"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const container: IocContainer = typeof iocContainer === 'function' ? (iocContainer as IocContainerFactory)(request) : iocContainer; + + const controller: any = await container.get(GatewayConsumersController); + if (typeof controller['setStatus'] === 'function') { + controller.setStatus(undefined); + } + + + const promise = controller.issue.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, undefined, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.put('/ds/api/v3/gateways/:gatewayId/consumers/:clientId', + authenticateMiddleware([{"jwt":["CredentialIssuer.Generate"]}]), + + async function GatewayConsumersController_regenerate(request: any, response: any, next: any) { + const args = { + gatewayId: {"in":"path","name":"gatewayId","required":true,"dataType":"string"}, + clientId: {"in":"path","name":"clientId","required":true,"dataType":"string"}, + action: {"in":"query","name":"action","required":true,"dataType":"enum","enums":["regenerate"]}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const container: IocContainer = typeof iocContainer === 'function' ? (iocContainer as IocContainerFactory)(request) : iocContainer; + + const controller: any = await container.get(GatewayConsumersController); + if (typeof controller['setStatus'] === 'function') { + controller.setStatus(undefined); + } + + + const promise = controller.regenerate.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, undefined, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/ds/api/v3/gateways/report', authenticateMiddleware([{"jwt":[]}]), diff --git a/src/controllers/v3/types-extra.ts b/src/controllers/v3/types-extra.ts index 438ddde0d..b2d7328ac 100644 --- a/src/controllers/v3/types-extra.ts +++ b/src/controllers/v3/types-extra.ts @@ -19,3 +19,82 @@ export interface PublishResult { results?: string; error?: string; } + +/** + * @tsoaModel + * @example { + * "environmentAppId": "23C4F461", + * "application": { "name": "notify-tenant-a", "description": "Tenant A" }, + * "labels": { "issued-by": "notify" }, + * "controls": { "aclGroups": ["notify-tenant-a"] } + * } + */ +export interface IssueGatewayConsumerRequest { + /** Environment.appId from GET /gateways/{gateway}/products */ + environmentAppId: string; + application: IssueGatewayConsumerApplication; + /** Optional labels for filtering on the Consumers page, e.g. { "issued-by": "notify" } */ + labels?: { [key: string]: string }; + /** Optional controls; validity depends on the environment flow */ + controls?: IssueGatewayConsumerControls; +} + +/** + * @tsoaModel + */ +export interface IssueGatewayConsumerApplication { + /** + * Reuse an existing Application in this gateway (multi-env). + * When set, name/description are ignored. + */ + appId?: string; + /** Required when creating a new Application */ + name?: string; + description?: string; +} + +/** + * @tsoaModel + */ +export interface IssueGatewayConsumerControls { + defaultClientScopes?: string[]; + defaultOptionalScopes?: string[]; + roles?: string[]; + aclGroups?: string[]; + clientGenCertificate?: boolean; + clientCertificate?: string; + jwksUrl?: string; + plugins?: IssueGatewayConsumerPlugin[]; +} + +/** + * @tsoaModel + */ +export interface IssueGatewayConsumerPlugin { + name: string; + config?: { [key: string]: any }; + service?: { name?: string }; + route?: { name?: string }; +} + +/** + * Credential response modeled on NewCredential. + * Fields present depend on flow / authenticator. + * + * @tsoaModel + * @example { + * "flow": "kong-api-key-acl", + * "clientId": "23C4F461-A1B2C3D4E5F", + * "apiKey": "abcdef0123456789" + * } + */ +export interface GatewayConsumerCredential { + flow: string; + clientId?: string; + clientSecret?: string; + issuer?: string; + tokenEndpoint?: string; + apiKey?: string; + clientPublicKey?: string; + clientPrivateKey?: string; +} diff --git a/src/jest.config.js b/src/jest.config.js index 91c1949c5..a9c6f43b8 100644 --- a/src/jest.config.js +++ b/src/jest.config.js @@ -1,7 +1,7 @@ module.exports = { verbose: true, testEnvironment: 'node', - testMatch: ['**/?(*.)+(test.{js,jsx})'], + testMatch: ['**/?(*.)+(test.{js,jsx,ts,tsx})'], collectCoverageFrom: ['services/**/*.js', 'services/**/*.ts'], coveragePathIgnorePatterns: ['.*/__mocks__/.*', '.*/@types/.*'], coverageDirectory: '__coverage__', diff --git a/src/lists/Application.js b/src/lists/Application.js index 41b470c1e..3237bda66 100644 --- a/src/lists/Application.js +++ b/src/lists/Application.js @@ -46,7 +46,7 @@ module.exports = { organizationUnit: { type: Relationship, ref: 'OrganizationUnit' }, owner: { type: Relationship, - isRequired: true, + isRequired: false, ref: 'User', access: { update: false }, }, @@ -67,7 +67,10 @@ module.exports = { } else { resolvedData['appId'] = newApplicationID(); } - resolvedData['owner'] = context.authedItem.userId; + // Portal users get owner auto-set; gateway issuer / service accounts omit owner + if (context.authedItem?.userId) { + resolvedData['owner'] = context.authedItem.userId; + } } return resolvedData; }, diff --git a/src/nextapp/components/namespace-access/namespace-access-dialog.tsx b/src/nextapp/components/namespace-access/namespace-access-dialog.tsx index f0d9b299c..9ce05f13a 100644 --- a/src/nextapp/components/namespace-access/namespace-access-dialog.tsx +++ b/src/nextapp/components/namespace-access/namespace-access-dialog.tsx @@ -199,6 +199,8 @@ const permissionHelpTextLookup = { 'Content.Publish': 'Can update the documentation on the portal.', 'CredentialIssuer.Admin': 'Can create Authorization Profiles so that they are available to be used when configuring Product Environments.', + 'CredentialIssuer.Generate': + 'Can issue and regenerate consumer credentials for services in this gateway via the Credential Issuer API.', 'GatewayConfig.Publish': 'Can publish gateway configuration to Kong and to view the status of the upstreams.', 'Namespace.Manage': diff --git a/src/services/keycloak/namespace-details.ts b/src/services/keycloak/namespace-details.ts index c55126185..34850e586 100644 --- a/src/services/keycloak/namespace-details.ts +++ b/src/services/keycloak/namespace-details.ts @@ -178,15 +178,22 @@ export async function getResource( ); const namespaces = await resourcesApi.listResourcesByIdList(resourceIds); - return namespaces - .filter((ns) => ns.name === selectedNS) - .map((ns: ResourceSet) => ({ - id: ns.id, - name: ns.name, - displayName: ns.displayName || `Gateway ${ns.name}`, - scopes: ns.resource_scopes, - })) - .pop(); + const match = namespaces.find((ns) => ns.name === selectedNS); + if (!match) { + return undefined; + } + + // Lazily add CredentialIssuer.Generate to existing gateway UMA resources + const updated = await resourcesApi.ensureResourceScopes(match, [ + 'CredentialIssuer.Generate', + ]); + + return { + id: updated.id, + name: updated.name, + displayName: updated.displayName || `Gateway ${updated.name}`, + scopes: updated.resource_scopes, + }; } export function generateDisplayName(context: any, gatewayId: string): string { diff --git a/src/services/keystone/application.ts b/src/services/keystone/application.ts index 67d49037f..737ff28bd 100644 --- a/src/services/keystone/application.ts +++ b/src/services/keystone/application.ts @@ -14,6 +14,7 @@ export async function lookupApplication( id appId name + namespace owner { name } @@ -25,6 +26,72 @@ export async function lookupApplication( return result.data.allApplications[0]; } +export async function lookupApplicationByAppId( + context: any, + appId: string, + namespace?: string +): Promise { + const where: any = { appId }; + if (namespace) { + where.namespace = namespace; + } + const result = await context.executeGraphQL({ + query: `query GetApplicationByAppId($where: ApplicationWhereInput!) { + allApplications(where: $where) { + id + appId + name + namespace + description + owner { + name + } + } + }`, + variables: { where }, + }); + logger.debug('[lookupApplicationByAppId] result %j', result); + return result.data.allApplications[0]; +} + +export async function addApplication( + context: any, + data: { + name: string; + description?: string; + namespace?: string; + appId?: string; + } +): Promise { + const result = await context.executeGraphQL({ + query: `mutation CreateApplication($data: ApplicationCreateInput!) { + createApplication(data: $data) { + id + appId + name + namespace + description + } + }`, + variables: { + data: { + name: data.name, + description: data.description || '', + namespace: data.namespace, + ...(data.appId ? { appId: data.appId } : {}), + }, + }, + }); + logger.debug('[addApplication] result %j', result); + assertEqual( + 'errors' in result, + false, + 'application', + `Failed to create Application ${JSON.stringify(result.errors || result)}` + ); + return result.data.createApplication; +} + export async function lookupMyApplicationsById( context: any, id: string diff --git a/src/services/keystone/gateway-consumer.ts b/src/services/keystone/gateway-consumer.ts index 99756e2fa..52f7d0cc5 100644 --- a/src/services/keystone/gateway-consumer.ts +++ b/src/services/keystone/gateway-consumer.ts @@ -146,6 +146,8 @@ export async function lookupKongConsumerByCustomId( query: `query FindConsumerByUsername($where: GatewayConsumerWhereInput) { allGatewayConsumers(where: $where) { id + username + customId extForeignKey } }`, diff --git a/src/services/keystone/index.ts b/src/services/keystone/index.ts index 497df96f0..8451f432b 100644 --- a/src/services/keystone/index.ts +++ b/src/services/keystone/index.ts @@ -10,7 +10,7 @@ export { export { recordActivity, recordActivityWithBlob } from './activity'; -export { lookupApplication, lookupMyApplicationsById } from './application'; +export { lookupApplication, lookupMyApplicationsById, lookupApplicationByAppId, addApplication } from './application'; export { deleteRecord, deleteRecords } from './common-delete-record'; @@ -44,6 +44,8 @@ export { lookupEnvironmentAndIssuerById, lookupProductEnvironmentServices, lookupProductEnvironmentServicesBySlug, + lookupEnvironmentsByNS, + lookupEnvironmentByAppIdInNamespace, lookupProduct, lookupProductDataset, } from './product-environment'; @@ -53,6 +55,7 @@ export { deleteServiceAccess, linkCredRefsToServiceAccess, lookupCredentialReferenceByServiceAccess, + lookupServiceAccessByName, lookupServiceAccessesByConsumer, lookupServiceAccessesByNamespace, lookupServiceAccessesByEnvironment, diff --git a/src/services/keystone/product-environment.ts b/src/services/keystone/product-environment.ts index e710d0744..a744f61e8 100644 --- a/src/services/keystone/product-environment.ts +++ b/src/services/keystone/product-environment.ts @@ -185,6 +185,7 @@ export async function lookupEnvironmentsByNS( product { id name + namespace } legal { reference @@ -199,6 +200,25 @@ export async function lookupEnvironmentsByNS( resourceType resourceAccessScope environmentDetails + clientAuthenticator + clientId + inheritFrom { + environmentDetails + } + } + services { + name + plugins { + name + config + } + routes { + name + plugins { + name + config + } + } } } }`, @@ -211,6 +231,21 @@ export async function lookupEnvironmentsByNS( return result.data.allEnvironments; } +export async function lookupEnvironmentByAppIdInNamespace( + context: any, + environmentAppId: string, + namespace: string +): Promise { + const envs = await lookupEnvironmentsByNS(context, namespace); + const match = envs.find((e) => e.appId === environmentAppId); + assert.strictEqual( + match != null, + true, + `Environment not found for appId ${environmentAppId} in gateway ${namespace}` + ); + return match; +} + export async function lookupEnvironmentAndIssuerById( context: any, id: string diff --git a/src/services/keystone/service-access.ts b/src/services/keystone/service-access.ts index 092f71204..77aad50d0 100644 --- a/src/services/keystone/service-access.ts +++ b/src/services/keystone/service-access.ts @@ -64,6 +64,65 @@ export async function lookupCredentialReferenceByServiceAccess( return result.data.allServiceAccesses[0]; } +export async function lookupServiceAccessByName( + context: any, + name: string, + namespace: string +): Promise { + const result = await context.executeGraphQL({ + query: `query GetServiceAccessByName($name: String!, $ns: String!) { + allServiceAccesses(where: { + name: $name, + productEnvironment: { product: { namespace: $ns } } + }) { + id + name + consumerType + namespace + productEnvironment { + id + name + appId + flow + product { + namespace + } + credentialIssuer { + id + clientAuthenticator + } + } + application { + id + appId + name + } + consumer { + id + username + customId + extForeignKey + namespace + } + credentialReference + } + }`, + variables: { name, ns: namespace }, + }); + logger.debug('[lookupServiceAccessByName] result %j', result); + assert.strictEqual( + result.data.allServiceAccesses.length, + 1, + `ServiceAccess not found for clientId ${name} in gateway ${namespace}` + ); + + const access = result.data.allServiceAccesses[0]; + if (access.credentialReference) { + access.credentialReference = JSON.parse(access.credentialReference); + } + return access; +} + export async function lookupDetailedServiceAccessesByNS( context: any, ns: string diff --git a/src/services/uma2/resource-registration-service.ts b/src/services/uma2/resource-registration-service.ts index 65a8e65d6..db43ef9bc 100644 --- a/src/services/uma2/resource-registration-service.ts +++ b/src/services/uma2/resource-registration-service.ts @@ -118,6 +118,45 @@ export class UMAResourceRegistrationService { }); } + /** + * Ensure the resource has the given scopes (adds any that are missing). + * Returns the refreshed ResourceSet when scopes were added; otherwise the original. + */ + public async ensureResourceScopes( + resource: ResourceSet, + requiredScopes: string[] + ): Promise { + const current = (resource.resource_scopes || []).map((s) => s.name); + const missing = requiredScopes.filter((s) => !current.includes(s)); + if (missing.length === 0) { + return resource; + } + + const scopes = [...current, ...missing]; + logger.info( + '[ensureResourceScopes] Adding scopes %j to resource %s', + missing, + resource.name + ); + await this.updateResourceSet({ + _id: resource.id, + name: resource.name, + displayName: resource.displayName, + type: resource.type, + uris: resource.uris, + icon_uri: resource.icon_uri, + scopes, + owner: resource.owner, + ownerManagedAccess: resource.ownerManagedAccess, + }); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + return this.getResourceSet(resource.id); + } + public async deleteResourceSet(rid: string) { const url = `${this.resourceRegistrationEndpoint}/${rid}`; logger.debug('[deleteResourceSet] URL %s', url); diff --git a/src/services/workflow/apply.ts b/src/services/workflow/apply.ts index 752ef9720..8e93e269d 100644 --- a/src/services/workflow/apply.ts +++ b/src/services/workflow/apply.ts @@ -282,7 +282,7 @@ export const Apply = async ( } }; -interface SetupAuthorizationInput { +export interface SetupAuthorizationInput { flow: string; namespace: string; controls: RequestControls; @@ -293,7 +293,7 @@ interface SetupAuthorizationInput { consumer: GatewayConsumer; } -async function setupAuthorizationAndEnable( +export async function setupAuthorizationAndEnable( subjectContext: any, context: any, prodEnv: Environment, diff --git a/src/services/workflow/create-namespace.ts b/src/services/workflow/create-namespace.ts index b6513dcca..54c8e0d82 100644 --- a/src/services/workflow/create-namespace.ts +++ b/src/services/workflow/create-namespace.ts @@ -85,6 +85,7 @@ export async function CreateNamespace( 'Access.Manage', 'Content.Publish', 'CredentialIssuer.Admin', + 'CredentialIssuer.Generate', ]; if (args.includeSDXScopes) { scopes.push('Connection.Manage'); diff --git a/src/services/workflow/index.ts b/src/services/workflow/index.ts index c6f57aa2d..311e4bc54 100644 --- a/src/services/workflow/index.ts +++ b/src/services/workflow/index.ts @@ -63,3 +63,7 @@ export { export { MigrateAuthzUser, MigratePortalUser } from './migrate-user'; export { UpdateCredentials } from './update-credential'; + +export { issueGatewayCredential } from './issue-gateway-credential'; +export { regenerateGatewayCredential } from './regenerate-gateway-credential'; +export { setupAuthorizationAndEnable } from './apply'; diff --git a/src/services/workflow/issue-gateway-credential.ts b/src/services/workflow/issue-gateway-credential.ts new file mode 100644 index 000000000..abd3a6ef8 --- /dev/null +++ b/src/services/workflow/issue-gateway-credential.ts @@ -0,0 +1,445 @@ +import crypto from 'crypto'; +import { strict as assert } from 'assert'; +import { + addApplication, + addServiceAccess, + lookupApplicationByAppId, + lookupCredentialIssuerById, + lookupEnvironmentByAppIdInNamespace, + lookupKongConsumerByCustomId, + lookupProductEnvironmentServices, +} from '../keystone'; +import { FeederService } from '../feeder'; +import { KongConsumerService } from '../kong'; +import { Logger } from '../../logger'; +import { + Application, + Environment, +} from '../keystone/types'; +import { + CredentialReference, + ConsumerLabel, + getIssuerEnvironmentConfig, + IssuerEnvironmentConfig, + NewCredential, + RequestControls, +} from './types'; +import { registerClient } from './client-credentials'; +import { registerApiKey } from './kong-api-key'; +import { AddClientConsumer } from './add-client-consumer'; +import { IsCertificateValid, IsJWKSURLValid } from './update-credential'; +import { getOpenidFromIssuer } from '../keycloak'; +import { isBlank } from './common'; +import { setupAuthorizationAndEnable } from './apply'; +import { saveConsumerLabels } from './consumer-management'; +import { parsePluginConfig } from '../keystone/gateway-service'; + +const logger = Logger('wf.IssueGatewayCred'); + +const ISSUABLE_FLOWS = [ + 'kong-api-key-acl', + 'kong-api-key-only', + 'client-credentials', +]; + +export interface IssueGatewayCredentialApplication { + appId?: string; + name?: string; + description?: string; +} + +export interface IssueGatewayCredentialInput { + environmentAppId: string; + application: IssueGatewayCredentialApplication; + labels?: Record; + controls?: RequestControls; +} + +/** + * Issue a consumer credential for a product environment in the caller's gateway. + * Creates or reuses an Application (owner optional, namespace = gateway), then + * creates Consumer + ServiceAccess and enables access immediately. + */ +export async function issueGatewayCredential( + context: any, + gatewayId: string, + input: IssueGatewayCredentialInput +): Promise { + assert.strictEqual( + Boolean(input?.environmentAppId), + true, + 'environmentAppId is required' + ); + assert.strictEqual( + Boolean(input?.application), + true, + 'application is required' + ); + + const controls: RequestControls = { ...(input.controls || {}) }; + const noauthContext = + typeof context.sudo === 'function' + ? context.sudo() + : context; + + const environment = await lookupEnvironmentByAppIdInNamespace( + noauthContext, + input.environmentAppId, + gatewayId + ); + + // Ensure plugin config is parsed (lookupEnvironmentsByNS returns raw JSON strings) + if (environment.services) { + parsePluginConfig(environment.services); + } + + // Prefer full product-environment lookup for credential generation parity + const productEnvironment = await lookupProductEnvironmentServices( + noauthContext, + environment.id + ); + + assert.strictEqual( + productEnvironment.product?.namespace === gatewayId, + true, + `Environment does not belong to gateway ${gatewayId}` + ); + + assert.strictEqual( + ISSUABLE_FLOWS.includes(productEnvironment.flow), + true, + `Flow '${productEnvironment.flow}' does not support credential issuance` + ); + + await validateIssuerForFlow(noauthContext, productEnvironment); + await validateControls(controls, productEnvironment); + + const application = await resolveApplication( + noauthContext, + gatewayId, + input.application + ); + + const clientId = `${productEnvironment.appId}-${application.appId}`; + + const existingConsumer = await lookupKongConsumerByCustomId( + noauthContext, + clientId, + false + ); + assert.strictEqual( + typeof existingConsumer === 'undefined', + true, + 'This application already has access to this environment' + ); + + const { newCredential, serviceAccessId, consumer } = await createCredential( + noauthContext, + productEnvironment, + application, + clientId, + controls + ); + + await setupAuthorizationAndEnable( + context, + noauthContext, + productEnvironment, + { + flow: productEnvironment.flow, + namespace: gatewayId, + controls, + environmentName: productEnvironment.name, + environmentAppId: productEnvironment.appId, + credentialIssuerId: productEnvironment.credentialIssuer?.id, + serviceAccessId, + consumer, + } + ); + + if (input.labels && Object.keys(input.labels).length > 0) { + const labels: ConsumerLabel[] = Object.entries(input.labels).map( + ([labelGroup, value]) => ({ + labelGroup, + values: [value], + }) + ); + await saveConsumerLabels(noauthContext, gatewayId, consumer.id, labels); + } + + logger.info( + '[issueGatewayCredential] Issued %s for gateway %s', + clientId, + gatewayId + ); + + return newCredential; +} + +async function resolveApplication( + context: any, + gatewayId: string, + applicationInput: IssueGatewayCredentialApplication +): Promise { + if (applicationInput.appId) { + const existing = await lookupApplicationByAppId( + context, + applicationInput.appId, + gatewayId + ); + assert.strictEqual( + existing != null, + true, + `Application ${applicationInput.appId} not found in gateway ${gatewayId}` + ); + return existing; + } + + assert.strictEqual( + Boolean(applicationInput.name), + true, + 'application.name is required when creating a new Application' + ); + + return addApplication(context, { + name: applicationInput.name, + description: applicationInput.description, + namespace: gatewayId, + }); +} + +async function validateIssuerForFlow( + context: any, + productEnvironment: Environment +) { + if (productEnvironment.flow !== 'client-credentials') { + return; + } + + assert.strictEqual( + productEnvironment.credentialIssuer != null, + true, + 'Credential Issuer not configured for this Product Environment' + ); + + const issuer = await lookupCredentialIssuerById( + context, + productEnvironment.credentialIssuer.id + ); + assert.strictEqual(issuer != null, true, 'Invalid Credential Issuer'); + + if (issuer.mode == 'manual') { + throw new Error('Manual credential issuing not supported'); + } + + const issuerEnvConfig: IssuerEnvironmentConfig = getIssuerEnvironmentConfig( + issuer, + productEnvironment.name + ); + + if ( + issuer.flow == 'client-credentials' && + issuerEnvConfig.clientRegistration == 'anonymous' + ) { + throw new Error('Anonymous client registration not supported'); + } + + const openid = await getOpenidFromIssuer(issuerEnvConfig.issuerUrl); + assert.strictEqual(openid != null, true, 'Discovery URL invalid for Credential Issuer'); + + const clientRegistration = issuerEnvConfig.clientRegistration; + assert.strictEqual( + ['anonymous', 'managed', 'iat'].includes(clientRegistration), + true, + 'Client Registration setting is missing from the Issuer' + ); + assert.strictEqual( + clientRegistration == 'managed' && + (isBlank(issuerEnvConfig.clientId) || + isBlank(issuerEnvConfig.clientSecret)), + false, + 'Managed Client Registration requires a Client ID and Secret' + ); + assert.strictEqual( + clientRegistration == 'iat' && isBlank(issuerEnvConfig.initialAccessToken), + false, + 'Initial Access Token is required when doing client registration via an IAT' + ); +} + +async function validateControls( + controls: RequestControls, + productEnvironment: Environment +) { + if (controls.jwksUrl) { + assert.strictEqual( + await IsJWKSURLValid(controls.jwksUrl), + true, + 'JWKS Url failed validation' + ); + } else if (controls.clientCertificate) { + assert.strictEqual( + IsCertificateValid(controls.clientCertificate), + true, + 'Certificate failed validation' + ); + } + + if ( + productEnvironment.flow === 'client-credentials' && + productEnvironment.credentialIssuer?.clientAuthenticator === + 'client-jwt-jwks-url' + ) { + // Caller may supply jwksUrl or certificate depending on authenticator; soft check only + } +} + +async function createCredential( + context: any, + productEnvironment: Environment, + application: Application, + clientId: string, + controls: RequestControls +): Promise<{ + newCredential: NewCredential; + serviceAccessId: string; + consumer: any; +}> { + const feederApi = new FeederService(process.env.FEEDER_URL); + const flow = productEnvironment.flow; + const nickname = clientId; + + if (flow == 'kong-api-key-acl' || flow == 'kong-api-key-only') { + const newApiKey = await registerApiKey( + context, + clientId, + nickname, + application + ); + + await feederApi.forceSync('kong', 'consumer', newApiKey.consumer.id); + + const credentialReference: CredentialReference = { + keyAuthPK: newApiKey.apiKey.keyAuthPK, + clientId, + }; + + const aclEnabled = flow == 'kong-api-key-acl'; + const serviceAccessId = await addServiceAccess( + context, + clientId, + false, + aclEnabled, + 'client', + credentialReference, + null, + newApiKey.consumerPK, + productEnvironment, + application + ); + + const consumer = await lookupKongConsumerByCustomId(context, clientId); + + return { + newCredential: { + flow, + apiKey: newApiKey.apiKey.apiKey, + clientId, + } as NewCredential, + serviceAccessId, + consumer, + }; + } + + if (flow == 'client-credentials') { + const clientSigning: any = { publicKey: null, privateKey: null }; + + if (controls.clientGenCertificate) { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + clientSigning.publicKey = publicKey; + clientSigning.privateKey = privateKey; + controls.clientCertificate = clientSigning.publicKey; + } + + const newClient = await registerClient( + context, + productEnvironment.name, + productEnvironment.credentialIssuer.id, + controls, + clientId + ); + + const kongApi = new KongConsumerService(process.env.KONG_URL); + const kongConsumer = await kongApi.createKongConsumer( + nickname, + clientId, + application + ); + const consumerPK = await AddClientConsumer( + context, + nickname, + clientId, + kongConsumer.id + ); + + await feederApi.forceSync('kong', 'consumer', kongConsumer.id); + + const credentialReference: CredentialReference = { + id: newClient.client.id, + clientId: newClient.client.clientId, + clientCertificate: controls.clientCertificate, + jwksUrl: controls.jwksUrl, + issuer: + controls.jwksUrl || controls.clientCertificate + ? newClient.openid.issuer + : null, + tokenEndpoint: newClient.openid.token_endpoint, + }; + + const serviceAccessId = await addServiceAccess( + context, + clientId, + false, + false, + 'client', + credentialReference, + null, + consumerPK, + productEnvironment, + application + ); + + const consumer = await lookupKongConsumerByCustomId(context, clientId); + + return { + newCredential: { + flow: productEnvironment.flow, + clientId: newClient.client.clientId, + clientSecret: controls.clientGenCertificate + ? null + : newClient.client.clientSecret, + issuer: + controls.jwksUrl || controls.clientCertificate + ? newClient.openid.issuer + : null, + tokenEndpoint: newClient.openid.token_endpoint, + clientPublicKey: clientSigning.publicKey, + clientPrivateKey: clientSigning.privateKey, + } as NewCredential, + serviceAccessId, + consumer, + }; + } + + throw new Error(`Unsupported flow: ${flow}`); +} diff --git a/src/services/workflow/regenerate-gateway-credential.ts b/src/services/workflow/regenerate-gateway-credential.ts new file mode 100644 index 000000000..15ac20233 --- /dev/null +++ b/src/services/workflow/regenerate-gateway-credential.ts @@ -0,0 +1,149 @@ +import crypto from 'crypto'; +import { strict as assert } from 'assert'; +import { + linkCredRefsToServiceAccess, + lookupServiceAccessByName, +} from '../keystone'; +import { + ClientAuthenticator, + KeycloakClientService, +} from '../keycloak'; +import { Logger } from '../../logger'; +import { + CredentialReference, + NewCredential, +} from './types'; +import { getEnvironmentContext } from './get-namespaces'; +import { replaceApiKey } from './kong-api-key-replace'; + +const logger = Logger('wf.RegenGatewayCred'); + +/** + * Regenerate credentials in place for an existing consumer (same clientId). + * Mirrors GraphQL regenerateCredentials, scoped to a gateway. + */ +export async function regenerateGatewayCredential( + context: any, + gatewayId: string, + clientId: string +): Promise { + assert.strictEqual( + Boolean(clientId), + true, + 'clientId is required' + ); + + const noauthContext = + typeof context.sudo === 'function' ? context.sudo() : context; + + const serviceAccess = await lookupServiceAccessByName( + noauthContext, + clientId, + gatewayId + ); + + assert.strictEqual( + serviceAccess.productEnvironment?.product?.namespace === gatewayId || + serviceAccess.namespace === gatewayId, + true, + `Consumer ${clientId} does not belong to gateway ${gatewayId}` + ); + + const flow = serviceAccess.productEnvironment.flow; + const clientAuthenticator = serviceAccess.productEnvironment + ?.credentialIssuer?.clientAuthenticator as ClientAuthenticator; + + if (flow === 'kong-api-key-acl' || flow === 'kong-api-key-only') { + const newApiKey = await replaceApiKey( + clientId, + (serviceAccess.credentialReference as CredentialReference).keyAuthPK + ); + + const credentialReference: CredentialReference = { + keyAuthPK: newApiKey.apiKey.keyAuthPK, + clientId, + }; + + await linkCredRefsToServiceAccess( + noauthContext, + serviceAccess.id, + credentialReference + ); + + logger.info( + '[regenerateGatewayCredential] Rotated API key for %s in %s', + clientId, + gatewayId + ); + + return { + flow, + clientId, + apiKey: newApiKey.apiKey.apiKey, + } as NewCredential; + } + + if (flow === 'client-credentials') { + const envCtx = await getEnvironmentContext( + noauthContext, + serviceAccess.productEnvironment.id, + {}, + false + ); + + const kcClientService = new KeycloakClientService( + envCtx.issuerEnvConfig.issuerUrl + ); + await kcClientService.login( + envCtx.issuerEnvConfig.clientId, + envCtx.issuerEnvConfig.clientSecret + ); + + const client = await kcClientService.findByClientId( + serviceAccess.consumer.customId + ); + + const newCredential = { + flow, + clientId: serviceAccess.consumer.customId, + issuer: envCtx.openid.issuer, + tokenEndpoint: envCtx.openid.token_endpoint, + } as NewCredential; + + if (clientAuthenticator === 'client-secret') { + newCredential.clientSecret = await kcClientService.regenerateSecret( + client.id + ); + } else if (clientAuthenticator === 'client-jwt') { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + await kcClientService.uploadCertificate(client.id, publicKey); + newCredential.clientPrivateKey = privateKey; + newCredential.clientPublicKey = publicKey; + } else { + throw new Error( + `Regenerate not supported for authenticator '${clientAuthenticator}'` + ); + } + + logger.info( + '[regenerateGatewayCredential] Rotated client credentials for %s in %s', + clientId, + gatewayId + ); + + return newCredential; + } + + throw new Error(`Invalid Service Access Action for flow '${flow}'`); +} diff --git a/src/test/auth/scope-role-utils-credential-issuer.test.ts b/src/test/auth/scope-role-utils-credential-issuer.test.ts new file mode 100644 index 000000000..e7372b666 --- /dev/null +++ b/src/test/auth/scope-role-utils-credential-issuer.test.ts @@ -0,0 +1,19 @@ +import { scopesToRoles } from '../../auth/scope-role-utils'; + +describe('scopesToRoles CredentialIssuer.Generate', function () { + it('maps CredentialIssuer.Generate to credential-issuer role', function () { + const roles = scopesToRoles('idir', ['CredentialIssuer.Generate']); + expect(roles).toContain('credential-issuer'); + expect(roles).toContain('portal-user'); + expect(roles).toContain('idir-user'); + }); + + it('maps both Admin and Generate independently', function () { + const roles = scopesToRoles('idir', [ + 'CredentialIssuer.Admin', + 'CredentialIssuer.Generate', + ]); + expect(roles).toContain('credential-admin'); + expect(roles).toContain('credential-issuer'); + }); +}); diff --git a/src/test/services/workflow/issue-gateway-credential.test.ts b/src/test/services/workflow/issue-gateway-credential.test.ts new file mode 100644 index 000000000..4aebd216a --- /dev/null +++ b/src/test/services/workflow/issue-gateway-credential.test.ts @@ -0,0 +1,272 @@ +import { + issueGatewayCredential, + IssueGatewayCredentialInput, +} from '../../../services/workflow/issue-gateway-credential'; +import * as keystone from '../../../services/keystone'; +import * as apply from '../../../services/workflow/apply'; +import * as consumerMgmt from '../../../services/workflow/consumer-management'; +import * as kongApiKey from '../../../services/workflow/kong-api-key'; +import * as clientCredentials from '../../../services/workflow/client-credentials'; +import * as updateCredential from '../../../services/workflow/update-credential'; +import { FeederService } from '../../../services/feeder'; +import { KongConsumerService } from '../../../services/kong'; +import { AddClientConsumer } from '../../../services/workflow/add-client-consumer'; + +jest.mock('../../../services/keystone', () => ({ + lookupEnvironmentByAppIdInNamespace: jest.fn(), + lookupProductEnvironmentServices: jest.fn(), + lookupApplicationByAppId: jest.fn(), + addApplication: jest.fn(), + lookupKongConsumerByCustomId: jest.fn(), + lookupCredentialIssuerById: jest.fn(), + addServiceAccess: jest.fn(), +})); + +jest.mock('../../../services/workflow/apply', () => ({ + setupAuthorizationAndEnable: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../services/workflow/consumer-management', () => ({ + saveConsumerLabels: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../services/workflow/kong-api-key', () => ({ + registerApiKey: jest.fn(), +})); + +jest.mock('../../../services/workflow/client-credentials', () => ({ + registerClient: jest.fn(), +})); + +jest.mock('../../../services/workflow/update-credential', () => ({ + IsCertificateValid: jest.fn().mockReturnValue(true), + IsJWKSURLValid: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../../../services/workflow/add-client-consumer', () => ({ + AddClientConsumer: jest.fn().mockResolvedValue('consumer-pk-1'), +})); + +jest.mock('../../../services/feeder', () => ({ + FeederService: jest.fn().mockImplementation(() => ({ + forceSync: jest.fn().mockResolvedValue(undefined), + })), +})); + +jest.mock('../../../services/kong', () => ({ + KongConsumerService: jest.fn().mockImplementation(() => ({ + createKongConsumer: jest.fn().mockResolvedValue({ id: 'kong-1' }), + })), +})); + +jest.mock('../../../services/keycloak', () => ({ + getOpenidFromIssuer: jest.fn().mockResolvedValue({ + issuer: 'https://idp/realms/x', + token_endpoint: 'https://idp/token', + }), +})); + +jest.mock('../../../services/keystone/gateway-service', () => ({ + parsePluginConfig: jest.fn(), +})); + +jest.mock('../../../services/workflow/types', () => { + const actual = jest.requireActual('../../../services/workflow/types'); + return { + ...actual, + getIssuerEnvironmentConfig: jest.fn().mockReturnValue({ + exists: true, + environment: 'dev', + issuerUrl: 'https://idp', + clientRegistration: 'managed', + clientId: 'admin', + clientSecret: 'secret', + }), + }; +}); + +const lookupEnvironmentByAppIdInNamespace = + keystone.lookupEnvironmentByAppIdInNamespace as jest.Mock; +const lookupProductEnvironmentServices = + keystone.lookupProductEnvironmentServices as jest.Mock; +const lookupApplicationByAppId = + keystone.lookupApplicationByAppId as jest.Mock; +const addApplication = keystone.addApplication as jest.Mock; +const lookupKongConsumerByCustomId = + keystone.lookupKongConsumerByCustomId as jest.Mock; +const addServiceAccess = keystone.addServiceAccess as jest.Mock; +const setupAuthorizationAndEnable = + apply.setupAuthorizationAndEnable as jest.Mock; +const saveConsumerLabels = consumerMgmt.saveConsumerLabels as jest.Mock; +const registerApiKey = kongApiKey.registerApiKey as jest.Mock; + +const GATEWAY = 'notify'; +const ENV_APP_ID = '23C4F461'; +const APP_APP_ID = 'A1B2C3D4E5F'; + +function apiKeyEnvironment(overrides: any = {}) { + return { + id: 'env-1', + appId: ENV_APP_ID, + name: 'dev', + flow: 'kong-api-key-acl', + product: { namespace: GATEWAY, name: 'Notify' }, + credentialIssuer: null, + services: [], + ...overrides, + }; +} + +function buildContext() { + const sudoCtx = { sudo: undefined as undefined }; + return { + authedItem: { namespace: GATEWAY }, + sudo: () => sudoCtx, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + lookupEnvironmentByAppIdInNamespace.mockResolvedValue(apiKeyEnvironment()); + lookupProductEnvironmentServices.mockResolvedValue(apiKeyEnvironment()); + addApplication.mockResolvedValue({ + id: 'app-1', + appId: APP_APP_ID, + name: 'notify-tenant-a', + namespace: GATEWAY, + }); + lookupKongConsumerByCustomId + .mockResolvedValueOnce(undefined) // duplicate check + .mockResolvedValue({ + id: 'consumer-1', + customId: `${ENV_APP_ID}-${APP_APP_ID}`, + extForeignKey: 'kong-1', + }); + registerApiKey.mockResolvedValue({ + apiKey: { apiKey: 'secret-api-key', keyAuthPK: 'key-pk' }, + consumer: { id: 'kong-1' }, + consumerPK: 'consumer-1', + }); + addServiceAccess.mockResolvedValue('sa-1'); +}); + +describe('issueGatewayCredential', function () { + it('creates application, issues API key, enables access, and saves labels', async function () { + const input: IssueGatewayCredentialInput = { + environmentAppId: ENV_APP_ID, + application: { name: 'notify-tenant-a', description: 'Tenant A' }, + labels: { 'issued-by': 'notify' }, + controls: { aclGroups: ['notify-tenant-a'] }, + }; + + const result = await issueGatewayCredential( + buildContext(), + GATEWAY, + input + ); + + expect(addApplication).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + name: 'notify-tenant-a', + description: 'Tenant A', + namespace: GATEWAY, + }) + ); + expect(registerApiKey).toHaveBeenCalled(); + expect(setupAuthorizationAndEnable).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + flow: 'kong-api-key-acl', + namespace: GATEWAY, + environmentAppId: ENV_APP_ID, + controls: expect.objectContaining({ + aclGroups: ['notify-tenant-a'], + }), + }) + ); + expect(saveConsumerLabels).toHaveBeenCalledWith( + expect.anything(), + GATEWAY, + 'consumer-1', + [{ labelGroup: 'issued-by', values: ['notify'] }] + ); + expect(result).toEqual({ + flow: 'kong-api-key-acl', + clientId: `${ENV_APP_ID}-${APP_APP_ID}`, + apiKey: 'secret-api-key', + }); + }); + + it('reuses an existing application in the same gateway for another environment', async function () { + lookupApplicationByAppId.mockResolvedValue({ + id: 'app-1', + appId: APP_APP_ID, + name: 'notify-tenant-a', + namespace: GATEWAY, + }); + + const result = await issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { appId: APP_APP_ID }, + }); + + expect(addApplication).not.toHaveBeenCalled(); + expect(lookupApplicationByAppId).toHaveBeenCalledWith( + expect.anything(), + APP_APP_ID, + GATEWAY + ); + expect(result.clientId).toBe(`${ENV_APP_ID}-${APP_APP_ID}`); + }); + + it('rejects when application already has access to the environment', async function () { + lookupKongConsumerByCustomId.mockReset(); + lookupKongConsumerByCustomId.mockResolvedValue({ + id: 'existing', + customId: `${ENV_APP_ID}-${APP_APP_ID}`, + }); + + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { name: 'notify-tenant-a' }, + }) + ).rejects.toThrow(/already has access/); + }); + + it('rejects unsupported flows', async function () { + const env = apiKeyEnvironment({ flow: 'public' }); + lookupEnvironmentByAppIdInNamespace.mockResolvedValue(env); + lookupProductEnvironmentServices.mockResolvedValue(env); + + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { name: 'x' }, + }) + ).rejects.toThrow(/does not support credential issuance/); + }); + + it('requires application.name when creating', async function () { + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: {}, + }) + ).rejects.toThrow(/application.name is required/); + }); + + it('rejects reuse when application is not in the gateway', async function () { + lookupApplicationByAppId.mockResolvedValue(undefined); + + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { appId: 'MISSINGAPPID' }, + }) + ).rejects.toThrow(/not found in gateway/); + }); +}); diff --git a/src/test/services/workflow/regenerate-gateway-credential.test.ts b/src/test/services/workflow/regenerate-gateway-credential.test.ts new file mode 100644 index 000000000..83ac0b57e --- /dev/null +++ b/src/test/services/workflow/regenerate-gateway-credential.test.ts @@ -0,0 +1,143 @@ +import { regenerateGatewayCredential } from '../../../services/workflow/regenerate-gateway-credential'; +import * as keystone from '../../../services/keystone'; +import * as kongReplace from '../../../services/workflow/kong-api-key-replace'; +import * as getNamespaces from '../../../services/workflow/get-namespaces'; +import { KeycloakClientService } from '../../../services/keycloak'; + +jest.mock('../../../services/keystone', () => ({ + lookupServiceAccessByName: jest.fn(), + linkCredRefsToServiceAccess: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../services/workflow/kong-api-key-replace', () => ({ + replaceApiKey: jest.fn(), +})); + +jest.mock('../../../services/workflow/get-namespaces', () => ({ + getEnvironmentContext: jest.fn(), +})); + +jest.mock('../../../services/keycloak', () => ({ + KeycloakClientService: jest.fn(), +})); + +const lookupServiceAccessByName = + keystone.lookupServiceAccessByName as jest.Mock; +const linkCredRefsToServiceAccess = + keystone.linkCredRefsToServiceAccess as jest.Mock; +const replaceApiKey = kongReplace.replaceApiKey as jest.Mock; +const getEnvironmentContext = + getNamespaces.getEnvironmentContext as jest.Mock; +const KeycloakClientServiceMock = KeycloakClientService as jest.Mock; + +const GATEWAY = 'notify'; +const CLIENT_ID = '23C4F461-A1B2C3D4E5F'; + +function buildContext() { + return { + sudo: () => ({}), + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('regenerateGatewayCredential', function () { + it('rotates an API key in place and returns NewCredential', async function () { + lookupServiceAccessByName.mockResolvedValue({ + id: 'sa-1', + namespace: GATEWAY, + productEnvironment: { + id: 'env-1', + flow: 'kong-api-key-acl', + product: { namespace: GATEWAY }, + }, + consumer: { customId: CLIENT_ID }, + credentialReference: { keyAuthPK: 'old-key', clientId: CLIENT_ID }, + }); + replaceApiKey.mockResolvedValue({ + apiKey: { apiKey: 'new-api-key', keyAuthPK: 'new-key' }, + }); + + const result = await regenerateGatewayCredential( + buildContext(), + GATEWAY, + CLIENT_ID + ); + + expect(replaceApiKey).toHaveBeenCalledWith(CLIENT_ID, 'old-key'); + expect(linkCredRefsToServiceAccess).toHaveBeenCalledWith( + expect.anything(), + 'sa-1', + { keyAuthPK: 'new-key', clientId: CLIENT_ID } + ); + expect(result).toEqual({ + flow: 'kong-api-key-acl', + clientId: CLIENT_ID, + apiKey: 'new-api-key', + }); + }); + + it('rotates client-secret credentials', async function () { + lookupServiceAccessByName.mockResolvedValue({ + id: 'sa-1', + productEnvironment: { + id: 'env-1', + flow: 'client-credentials', + product: { namespace: GATEWAY }, + credentialIssuer: { clientAuthenticator: 'client-secret' }, + }, + consumer: { customId: CLIENT_ID }, + credentialReference: { clientId: CLIENT_ID }, + }); + getEnvironmentContext.mockResolvedValue({ + issuerEnvConfig: { + issuerUrl: 'https://idp', + clientId: 'admin', + clientSecret: 'secret', + }, + openid: { + issuer: 'https://idp/realms/x', + token_endpoint: 'https://idp/token', + }, + }); + KeycloakClientServiceMock.mockImplementation(() => ({ + login: jest.fn().mockResolvedValue(undefined), + findByClientId: jest.fn().mockResolvedValue({ id: 'kc-1' }), + regenerateSecret: jest.fn().mockResolvedValue('new-secret'), + })); + + const result = await regenerateGatewayCredential( + buildContext(), + GATEWAY, + CLIENT_ID + ); + + expect(result).toEqual({ + flow: 'client-credentials', + clientId: CLIENT_ID, + issuer: 'https://idp/realms/x', + tokenEndpoint: 'https://idp/token', + clientSecret: 'new-secret', + }); + }); + + it('rejects when consumer is not in the gateway', async function () { + lookupServiceAccessByName.mockResolvedValue({ + id: 'sa-1', + namespace: 'other', + productEnvironment: { + id: 'env-1', + flow: 'kong-api-key-only', + product: { namespace: 'other' }, + }, + consumer: { customId: CLIENT_ID }, + credentialReference: { keyAuthPK: 'k' }, + }); + + await expect( + regenerateGatewayCredential(buildContext(), GATEWAY, CLIENT_ID) + ).rejects.toThrow(/does not belong to gateway/); + }); +}); diff --git a/src/tsoa-v3.json b/src/tsoa-v3.json index 304cafe65..81fd2f376 100644 --- a/src/tsoa-v3.json +++ b/src/tsoa-v3.json @@ -60,6 +60,10 @@ { "name": "Authorization Profiles", "description": "Configure the integration to external Identity Providers" + }, + { + "name": "Gateway Consumers", + "description": "Issue and regenerate consumer credentials for services in a gateway" } ] }, From eeef82c80a13f19c01c4e3008987ffb40b947924 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Tue, 28 Jul 2026 10:15:17 -0700 Subject: [PATCH 02/11] Fix credential issuance cleanup and activation ordering --- .../workflow/issue-gateway-credential.ts | 58 ++++++++++++------- .../workflow/issue-gateway-credential.test.ts | 43 ++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/services/workflow/issue-gateway-credential.ts b/src/services/workflow/issue-gateway-credential.ts index abd3a6ef8..5b66d88bc 100644 --- a/src/services/workflow/issue-gateway-credential.ts +++ b/src/services/workflow/issue-gateway-credential.ts @@ -3,6 +3,7 @@ import { strict as assert } from 'assert'; import { addApplication, addServiceAccess, + deleteServiceAccess, lookupApplicationByAppId, lookupCredentialIssuerById, lookupEnvironmentByAppIdInNamespace, @@ -141,30 +142,45 @@ export async function issueGatewayCredential( controls ); - await setupAuthorizationAndEnable( - context, - noauthContext, - productEnvironment, - { - flow: productEnvironment.flow, - namespace: gatewayId, - controls, - environmentName: productEnvironment.name, - environmentAppId: productEnvironment.appId, - credentialIssuerId: productEnvironment.credentialIssuer?.id, - serviceAccessId, - consumer, + try { + if (input.labels && Object.keys(input.labels).length > 0) { + const labels: ConsumerLabel[] = Object.entries(input.labels).map( + ([labelGroup, value]) => ({ + labelGroup, + values: [value], + }) + ); + await saveConsumerLabels(noauthContext, gatewayId, consumer.id, labels); } - ); - if (input.labels && Object.keys(input.labels).length > 0) { - const labels: ConsumerLabel[] = Object.entries(input.labels).map( - ([labelGroup, value]) => ({ - labelGroup, - values: [value], - }) + await setupAuthorizationAndEnable( + context, + noauthContext, + productEnvironment, + { + flow: productEnvironment.flow, + namespace: gatewayId, + controls, + environmentName: productEnvironment.name, + environmentAppId: productEnvironment.appId, + credentialIssuerId: productEnvironment.credentialIssuer?.id, + serviceAccessId, + consumer, + } ); - await saveConsumerLabels(noauthContext, gatewayId, consumer.id, labels); + } catch (error) { + try { + // Deleting the inactive ServiceAccess invokes the existing cleanup hooks + // for its Keystone consumer and external Kong/IdP credentials. + await deleteServiceAccess(noauthContext, serviceAccessId); + } catch (cleanupError) { + logger.error( + '[issueGatewayCredential] Failed to clean up %s after issuance error: %s', + clientId, + cleanupError + ); + } + throw error; } logger.info( diff --git a/src/test/services/workflow/issue-gateway-credential.test.ts b/src/test/services/workflow/issue-gateway-credential.test.ts index 4aebd216a..505f101d8 100644 --- a/src/test/services/workflow/issue-gateway-credential.test.ts +++ b/src/test/services/workflow/issue-gateway-credential.test.ts @@ -20,6 +20,7 @@ jest.mock('../../../services/keystone', () => ({ lookupKongConsumerByCustomId: jest.fn(), lookupCredentialIssuerById: jest.fn(), addServiceAccess: jest.fn(), + deleteServiceAccess: jest.fn(), })); jest.mock('../../../services/workflow/apply', () => ({ @@ -95,6 +96,7 @@ const addApplication = keystone.addApplication as jest.Mock; const lookupKongConsumerByCustomId = keystone.lookupKongConsumerByCustomId as jest.Mock; const addServiceAccess = keystone.addServiceAccess as jest.Mock; +const deleteServiceAccess = keystone.deleteServiceAccess as jest.Mock; const setupAuthorizationAndEnable = apply.setupAuthorizationAndEnable as jest.Mock; const saveConsumerLabels = consumerMgmt.saveConsumerLabels as jest.Mock; @@ -135,6 +137,7 @@ beforeEach(() => { name: 'notify-tenant-a', namespace: GATEWAY, }); + lookupKongConsumerByCustomId.mockReset(); lookupKongConsumerByCustomId .mockResolvedValueOnce(undefined) // duplicate check .mockResolvedValue({ @@ -193,6 +196,10 @@ describe('issueGatewayCredential', function () { 'consumer-1', [{ labelGroup: 'issued-by', values: ['notify'] }] ); + expect(saveConsumerLabels.mock.invocationCallOrder[0]).toBeLessThan( + setupAuthorizationAndEnable.mock.invocationCallOrder[0] + ); + expect(deleteServiceAccess).not.toHaveBeenCalled(); expect(result).toEqual({ flow: 'kong-api-key-acl', clientId: `${ENV_APP_ID}-${APP_APP_ID}`, @@ -269,4 +276,40 @@ describe('issueGatewayCredential', function () { }) ).rejects.toThrow(/not found in gateway/); }); + + it('cleans up created records when authorization setup fails', async function () { + setupAuthorizationAndEnable.mockRejectedValueOnce( + new Error('authorization failed') + ); + + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { name: 'notify-tenant-a' }, + }) + ).rejects.toThrow('authorization failed'); + + expect(deleteServiceAccess).toHaveBeenCalledWith( + expect.anything(), + 'sa-1' + ); + }); + + it('cleans up without activating access when label persistence fails', async function () { + saveConsumerLabels.mockRejectedValueOnce(new Error('labels failed')); + + await expect( + issueGatewayCredential(buildContext(), GATEWAY, { + environmentAppId: ENV_APP_ID, + application: { name: 'notify-tenant-a' }, + labels: { 'issued-by': 'notify' }, + }) + ).rejects.toThrow('labels failed'); + + expect(setupAuthorizationAndEnable).not.toHaveBeenCalled(); + expect(deleteServiceAccess).toHaveBeenCalledWith( + expect.anything(), + 'sa-1' + ); + }); }); From 886c86c3f46cc99dd7f962b0e2573fa4750e476b Mon Sep 17 00:00:00 2001 From: Elson9 Date: Tue, 28 Jul 2026 10:38:41 -0700 Subject: [PATCH 03/11] Convert credential issuer tests to JS and restore Jest testMatch --- src/jest.config.js | 2 +- ...cope-role-utils-credential-issuer.test.js} | 0 ...st.ts => issue-gateway-credential.test.js} | 39 +++++++------------ ... => regenerate-gateway-credential.test.js} | 13 +++---- 4 files changed, 20 insertions(+), 34 deletions(-) rename src/test/auth/{scope-role-utils-credential-issuer.test.ts => scope-role-utils-credential-issuer.test.js} (100%) rename src/test/services/workflow/{issue-gateway-credential.test.ts => issue-gateway-credential.test.js} (86%) rename src/test/services/workflow/{regenerate-gateway-credential.test.ts => regenerate-gateway-credential.test.js} (91%) diff --git a/src/jest.config.js b/src/jest.config.js index a9c6f43b8..91c1949c5 100644 --- a/src/jest.config.js +++ b/src/jest.config.js @@ -1,7 +1,7 @@ module.exports = { verbose: true, testEnvironment: 'node', - testMatch: ['**/?(*.)+(test.{js,jsx,ts,tsx})'], + testMatch: ['**/?(*.)+(test.{js,jsx})'], collectCoverageFrom: ['services/**/*.js', 'services/**/*.ts'], coveragePathIgnorePatterns: ['.*/__mocks__/.*', '.*/@types/.*'], coverageDirectory: '__coverage__', diff --git a/src/test/auth/scope-role-utils-credential-issuer.test.ts b/src/test/auth/scope-role-utils-credential-issuer.test.js similarity index 100% rename from src/test/auth/scope-role-utils-credential-issuer.test.ts rename to src/test/auth/scope-role-utils-credential-issuer.test.js diff --git a/src/test/services/workflow/issue-gateway-credential.test.ts b/src/test/services/workflow/issue-gateway-credential.test.js similarity index 86% rename from src/test/services/workflow/issue-gateway-credential.test.ts rename to src/test/services/workflow/issue-gateway-credential.test.js index 505f101d8..06d1ff3b9 100644 --- a/src/test/services/workflow/issue-gateway-credential.test.ts +++ b/src/test/services/workflow/issue-gateway-credential.test.js @@ -1,16 +1,8 @@ -import { - issueGatewayCredential, - IssueGatewayCredentialInput, -} from '../../../services/workflow/issue-gateway-credential'; +import { issueGatewayCredential } from '../../../services/workflow/issue-gateway-credential'; import * as keystone from '../../../services/keystone'; import * as apply from '../../../services/workflow/apply'; import * as consumerMgmt from '../../../services/workflow/consumer-management'; import * as kongApiKey from '../../../services/workflow/kong-api-key'; -import * as clientCredentials from '../../../services/workflow/client-credentials'; -import * as updateCredential from '../../../services/workflow/update-credential'; -import { FeederService } from '../../../services/feeder'; -import { KongConsumerService } from '../../../services/kong'; -import { AddClientConsumer } from '../../../services/workflow/add-client-consumer'; jest.mock('../../../services/keystone', () => ({ lookupEnvironmentByAppIdInNamespace: jest.fn(), @@ -87,26 +79,23 @@ jest.mock('../../../services/workflow/types', () => { }); const lookupEnvironmentByAppIdInNamespace = - keystone.lookupEnvironmentByAppIdInNamespace as jest.Mock; + keystone.lookupEnvironmentByAppIdInNamespace; const lookupProductEnvironmentServices = - keystone.lookupProductEnvironmentServices as jest.Mock; -const lookupApplicationByAppId = - keystone.lookupApplicationByAppId as jest.Mock; -const addApplication = keystone.addApplication as jest.Mock; -const lookupKongConsumerByCustomId = - keystone.lookupKongConsumerByCustomId as jest.Mock; -const addServiceAccess = keystone.addServiceAccess as jest.Mock; -const deleteServiceAccess = keystone.deleteServiceAccess as jest.Mock; -const setupAuthorizationAndEnable = - apply.setupAuthorizationAndEnable as jest.Mock; -const saveConsumerLabels = consumerMgmt.saveConsumerLabels as jest.Mock; -const registerApiKey = kongApiKey.registerApiKey as jest.Mock; + keystone.lookupProductEnvironmentServices; +const lookupApplicationByAppId = keystone.lookupApplicationByAppId; +const addApplication = keystone.addApplication; +const lookupKongConsumerByCustomId = keystone.lookupKongConsumerByCustomId; +const addServiceAccess = keystone.addServiceAccess; +const deleteServiceAccess = keystone.deleteServiceAccess; +const setupAuthorizationAndEnable = apply.setupAuthorizationAndEnable; +const saveConsumerLabels = consumerMgmt.saveConsumerLabels; +const registerApiKey = kongApiKey.registerApiKey; const GATEWAY = 'notify'; const ENV_APP_ID = '23C4F461'; const APP_APP_ID = 'A1B2C3D4E5F'; -function apiKeyEnvironment(overrides: any = {}) { +function apiKeyEnvironment(overrides = {}) { return { id: 'env-1', appId: ENV_APP_ID, @@ -120,7 +109,7 @@ function apiKeyEnvironment(overrides: any = {}) { } function buildContext() { - const sudoCtx = { sudo: undefined as undefined }; + const sudoCtx = { sudo: undefined }; return { authedItem: { namespace: GATEWAY }, sudo: () => sudoCtx, @@ -155,7 +144,7 @@ beforeEach(() => { describe('issueGatewayCredential', function () { it('creates application, issues API key, enables access, and saves labels', async function () { - const input: IssueGatewayCredentialInput = { + const input = { environmentAppId: ENV_APP_ID, application: { name: 'notify-tenant-a', description: 'Tenant A' }, labels: { 'issued-by': 'notify' }, diff --git a/src/test/services/workflow/regenerate-gateway-credential.test.ts b/src/test/services/workflow/regenerate-gateway-credential.test.js similarity index 91% rename from src/test/services/workflow/regenerate-gateway-credential.test.ts rename to src/test/services/workflow/regenerate-gateway-credential.test.js index 83ac0b57e..a34721003 100644 --- a/src/test/services/workflow/regenerate-gateway-credential.test.ts +++ b/src/test/services/workflow/regenerate-gateway-credential.test.js @@ -21,14 +21,11 @@ jest.mock('../../../services/keycloak', () => ({ KeycloakClientService: jest.fn(), })); -const lookupServiceAccessByName = - keystone.lookupServiceAccessByName as jest.Mock; -const linkCredRefsToServiceAccess = - keystone.linkCredRefsToServiceAccess as jest.Mock; -const replaceApiKey = kongReplace.replaceApiKey as jest.Mock; -const getEnvironmentContext = - getNamespaces.getEnvironmentContext as jest.Mock; -const KeycloakClientServiceMock = KeycloakClientService as jest.Mock; +const lookupServiceAccessByName = keystone.lookupServiceAccessByName; +const linkCredRefsToServiceAccess = keystone.linkCredRefsToServiceAccess; +const replaceApiKey = kongReplace.replaceApiKey; +const getEnvironmentContext = getNamespaces.getEnvironmentContext; +const KeycloakClientServiceMock = KeycloakClientService; const GATEWAY = 'notify'; const CLIENT_ID = '23C4F461-A1B2C3D4E5F'; From a5d5153faf7452bae7f319430635e2c76c04b4d2 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Thu, 6 Aug 2026 08:37:20 -0700 Subject: [PATCH 04/11] Add Cypress coverage for gateway self-issuing credentials and fix JWT regenerate certificate updates --- e2e/cypress.config.ts | 1 + .../00-setup.cy.ts | 305 ++++++++++++ .../01-api-key-flows.cy.ts | 142 ++++++ .../02-client-credential-flows.cy.ts | 431 +++++++++++++++++ .../03-authz-labels-revoke.cy.ts | 156 +++++++ .../24-self-issuing-credentials/helpers.ts | 438 ++++++++++++++++++ .../workflow/regenerate-gateway-credential.ts | 4 +- 7 files changed, 1476 insertions(+), 1 deletion(-) create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/00-setup.cy.ts create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/01-api-key-flows.cy.ts create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/02-client-credential-flows.cy.ts create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/03-authz-labels-revoke.cy.ts create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/helpers.ts diff --git a/e2e/cypress.config.ts b/e2e/cypress.config.ts index 9734727f6..feae9f811 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/24-*/*.cy.ts', ] return config }, diff --git a/e2e/cypress/tests/24-self-issuing-credentials/00-setup.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/00-setup.cy.ts new file mode 100644 index 000000000..3ff06c9d1 --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/00-setup.cy.ts @@ -0,0 +1,305 @@ +import LoginPage from '../../pageObjects/login' +import ServiceAccountsPage from '../../pageObjects/serviceAccounts' +import { + FLOW_KEYS, + FlowConfig, + SuiteState, + SUITE_FIXTURE_DIR, + buildServicesYaml, + configureGwaHost, + extractServiceAccountCredsFromUi, + getProducts, + issuerEnvDetails, + jwtKeycloakPluginYaml, + keyAuthAclPluginYaml, + keyAuthPluginYaml, + publishConfigWithToken, + putIssuer, + putProduct, + saveSuiteState, + serviceYamlItem, + useBearerToken, +} from './helpers' + +const { v4: uuidv4 } = require('uuid') + +/** + * Independent bootstrap for self-issuing credentials. + * Creates gateway, service accounts, issuers, products/envs, Kong services/plugins. + * Writes suite state to fixtures/state/24-self-issue.json. + */ +describe('24 Self-issuing credentials — setup', () => { + const login = new LoginPage() + const sa = new ServiceAccountsPage() + + let gatewayId = '' + let displayName = '' + let publishSa: { clientId: string; clientSecret: string } + let issuerSa: { clientId: string; clientSecret: string } + let controlSa: { clientId: string; clientSecret: string } + let ownerToken = '' + + const suffix = uuidv4().replace(/-/g, '').substring(0, 6).toLowerCase() + + const closeSaCredsDialog = () => { + cy.contains('button', 'Close').click({ force: true }) + } + + before(() => { + cy.visit('/') + cy.deleteAllCookies() + cy.reload(true) + }) + + beforeEach(() => { + cy.preserveCookies() + cy.fixture('apiowner').as('apiowner') + }) + + it('logs in as Janis (API owner)', () => { + cy.visit(login.path) + cy.get('@apiowner').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + }) + + it('creates and activates a dedicated gateway', () => { + cy.createGateway().then((gw: any) => { + gatewayId = gw.gatewayId + displayName = gw.displayName + cy.activateGateway(gatewayId) + }) + }) + + it('captures owner session token for v3 management APIs', () => { + cy.getUserSession().then(() => { + cy.get('@login').then((xhr: any) => { + ownerToken = xhr.headers['x-auth-request-access-token'] + expect(ownerToken, 'owner session token').to.be.a('string') + useBearerToken(ownerToken) + }) + }) + }) + + it('creates a publish service account', () => { + cy.visit(sa.path) + sa.createServiceAccount(['GatewayConfig.Publish']) + extractServiceAccountCredsFromUi().then((creds) => { + publishSa = creds + closeSaCredsDialog() + }) + }) + + it('creates an issuer service account with CredentialIssuer.Generate', () => { + cy.visit(sa.path) + sa.createServiceAccount(['CredentialIssuer.Generate']) + extractServiceAccountCredsFromUi().then((creds) => { + issuerSa = creds + closeSaCredsDialog() + }) + }) + + it('creates a control service account without CredentialIssuer.Generate', () => { + cy.visit(sa.path) + sa.createServiceAccount(['GatewayConfig.Publish']) + extractServiceAccountCredsFromUi().then((creds) => { + controlSa = creds + closeSaCredsDialog() + }) + }) + + it('creates authorization profiles for client-credentials flows', () => { + useBearerToken(ownerToken) + + const issuers = [ + { + name: `sic-secret-${suffix}`, + clientAuthenticator: 'client-secret', + }, + { + name: `sic-jwt-${suffix}`, + clientAuthenticator: 'client-jwt', + }, + { + name: `sic-jwks-${suffix}`, + clientAuthenticator: 'client-jwt-jwks-url', + }, + ] + + issuers.forEach((issuer) => { + putIssuer(gatewayId, { + name: issuer.name, + description: `Self-issuing ${issuer.clientAuthenticator}`, + flow: 'client-credentials', + clientAuthenticator: issuer.clientAuthenticator, + mode: 'auto', + environmentDetails: issuerEnvDetails(), + }).then(({ apiRes }: any) => { + expect(apiRes.status, `issuer ${issuer.name}`).to.be.oneOf([200, 201]) + }) + }) + }) + + it('creates products with inactive environments to capture environmentAppIds', () => { + useBearerToken(ownerToken) + + const productDefs = [ + { + key: FLOW_KEYS.apiKeyOnly, + name: `SIC API Key Only ${suffix}`, + flow: 'kong-api-key-only' as const, + }, + { + key: FLOW_KEYS.apiKeyAcl, + name: `SIC API Key ACL ${suffix}`, + flow: 'kong-api-key-acl' as const, + }, + { + key: FLOW_KEYS.clientSecret, + name: `SIC CC Secret ${suffix}`, + flow: 'client-credentials' as const, + issuerName: `sic-secret-${suffix}`, + authenticator: 'client-secret' as const, + }, + { + key: FLOW_KEYS.clientJwt, + name: `SIC CC JWT ${suffix}`, + flow: 'client-credentials' as const, + issuerName: `sic-jwt-${suffix}`, + authenticator: 'client-jwt' as const, + }, + { + key: FLOW_KEYS.clientJwks, + name: `SIC CC JWKS ${suffix}`, + flow: 'client-credentials' as const, + issuerName: `sic-jwks-${suffix}`, + authenticator: 'client-jwt-jwks-url' as const, + }, + ] + + productDefs.forEach((def) => { + const environments = ['dev', 'test'].map((name) => { + const env: any = { + name, + active: false, + approval: false, + flow: def.flow, + } + if (def.issuerName) { + env.credentialIssuer = def.issuerName + } + return env + }) + + putProduct(gatewayId, { + name: def.name, + environments, + }).then(({ apiRes }: any) => { + expect(apiRes.status, `product ${def.name}`).to.eq(200) + }) + }) + + Cypress.env('sicProductDefs', productDefs) + }) + + it('publishes Kong services/plugins and saves suite state', () => { + useBearerToken(ownerToken) + configureGwaHost() + + const productDefs = Cypress.env('sicProductDefs') as any[] + const flows: Record = {} + + getProducts(gatewayId).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(200) + const products = apiRes.body as any[] + + const serviceItems: string[] = [] + + productDefs.forEach((def) => { + const product = products.find((p) => p.name === def.name) + expect(product, `product ${def.name} exists`).to.exist + + const envs = ['dev', 'test'].map((envName) => { + const env = product.environments.find((e: any) => e.name === envName) + expect(env?.appId, `${def.name}/${envName} appId`).to.be.a('string') + const serviceName = `sic-${def.key}-${envName}-${suffix}`.toLowerCase() + return { + name: envName as 'dev' | 'test', + environmentAppId: env.appId as string, + serviceName, + host: `${serviceName}.api.gov.bc.ca`, + } + }) + + envs.forEach((env) => { + let pluginYaml = '' + if (def.flow === 'kong-api-key-only') { + pluginYaml = keyAuthPluginYaml(gatewayId) + } else if (def.flow === 'kong-api-key-acl') { + pluginYaml = keyAuthAclPluginYaml(gatewayId, env.environmentAppId) + } else { + pluginYaml = jwtKeycloakPluginYaml( + gatewayId, + Cypress.env('OIDC_ISSUER') + ) + } + serviceItems.push( + serviceYamlItem(env.serviceName, gatewayId, pluginYaml) + ) + }) + + flows[def.key] = { + key: def.key, + productName: def.name, + flow: def.flow, + authenticator: def.authenticator, + issuerName: def.issuerName, + envs, + } + }) + + const fixturePath = `${SUITE_FIXTURE_DIR}/gateway-services-${suffix}.yml` + cy.writeFile( + `cypress/fixtures/${fixturePath}`, + buildServicesYaml(serviceItems) + ).then(() => { + cy.request({ + method: 'POST', + url: Cypress.env('TOKEN_URL'), + form: true, + body: { + grant_type: 'client_credentials', + scope: 'openid', + client_id: publishSa.clientId, + client_secret: publishSa.clientSecret, + }, + }).then((tokenRes) => { + expect(tokenRes.status).to.eq(200) + publishConfigWithToken( + gatewayId, + tokenRes.body.access_token, + fixturePath + ) + + // Issuance does not require portal env activation or service linkage; + // Kong plugins published above are enough for upstream checks. + const suiteState: SuiteState = { + gatewayId, + displayName, + issuerSa, + controlSa, + publishSa, + flows, + } + saveSuiteState(suiteState) + }) + }) + }) + }) + + after(() => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + }) +}) diff --git a/e2e/cypress/tests/24-self-issuing-credentials/01-api-key-flows.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/01-api-key-flows.cy.ts new file mode 100644 index 000000000..23704b9ee --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/01-api-key-flows.cy.ts @@ -0,0 +1,142 @@ +import { + FLOW_KEYS, + SuiteState, + applicationAppIdFromClientId, + callProtectedApiKey, + issueConsumer, + loadSuiteState, + regenerateConsumer, + withIssuerToken, +} from './helpers' + +/** + * kong-api-key-only and kong-api-key-acl issuance, reuse, duplicate, regenerate, upstream use. + * Depends on 00-setup.cy.ts having written fixtures/state/24-self-issue.json. + */ +describe('24 Self-issuing credentials — API key flows', () => { + let state: SuiteState + + before(() => { + loadSuiteState().then((s) => { + state = s + }) + }) + + ;[FLOW_KEYS.apiKeyOnly, FLOW_KEYS.apiKeyAcl].forEach((flowKey) => { + describe(flowKey, () => { + let firstCredential: any + let applicationAppId = '' + let oldApiKey = '' + + it('issues a new credential (create application)', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `tenant-${flowKey}-a`, + description: `Self-issued ${flowKey}`, + }, + labels: { + 'issued-by': 'cypress-suite-24', + flow: flowKey, + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.flow).to.eq(flow.flow) + expect(apiRes.body.clientId).to.be.a('string') + expect(apiRes.body.apiKey).to.be.a('string') + expect(apiRes.body.clientId).to.match( + new RegExp(`^${env.environmentAppId}-`) + ) + + firstCredential = apiRes.body + oldApiKey = apiRes.body.apiKey + applicationAppId = applicationAppIdFromClientId( + apiRes.body.clientId, + env.environmentAppId + ) + }) + }) + }) + + it('uses the issued API key against the protected upstream', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + callProtectedApiKey(env.serviceName, firstCredential.apiKey).then( + (res) => { + expect(res.status).to.eq(200) + } + ) + }) + + it('reuses the application on a second environment', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'test')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + appId: applicationAppId, + }, + labels: { + 'issued-by': 'cypress-suite-24', + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.eq( + `${env.environmentAppId}-${applicationAppId}` + ) + expect(apiRes.body.apiKey).to.be.a('string') + }) + }) + }) + + it('rejects duplicate access for the same environment + application', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + appId: applicationAppId, + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.be.oneOf([400, 422, 500]) + const bodyText = JSON.stringify(apiRes.body) + expect(bodyText).to.match(/already has access/i) + }) + }) + }) + + it('regenerates the credential in place', () => { + withIssuerToken(state.issuerSa, () => { + regenerateConsumer(state.gatewayId, firstCredential.clientId).then( + ({ apiRes }: any) => { + expect(apiRes.status).to.eq(200) + expect(apiRes.body.clientId).to.eq(firstCredential.clientId) + expect(apiRes.body.apiKey).to.be.a('string') + expect(apiRes.body.apiKey).to.not.eq(oldApiKey) + + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + callProtectedApiKey(env.serviceName, oldApiKey).then((oldRes) => { + expect(oldRes.status).to.be.oneOf([401, 403]) + }) + callProtectedApiKey(env.serviceName, apiRes.body.apiKey).then( + (newRes) => { + expect(newRes.status).to.eq(200) + } + ) + } + ) + }) + }) + }) + }) +}) diff --git a/e2e/cypress/tests/24-self-issuing-credentials/02-client-credential-flows.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/02-client-credential-flows.cy.ts new file mode 100644 index 000000000..9ff85e2a1 --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/02-client-credential-flows.cy.ts @@ -0,0 +1,431 @@ +import { + FLOW_KEYS, + SuiteState, + applicationAppIdFromClientId, + callProtectedBearer, + getClientCredentialsToken, + getTokenUsingPrivateKey, + issueConsumer, + loadSuiteState, + regenerateConsumer, + withIssuerToken, +} from './helpers' + +const jose = require('node-jose') + +/** + * client-credentials authenticators: client-secret, client-jwt, client-jwt-jwks-url. + * Depends on 00-setup.cy.ts suite state. + * + * Note: REST regenerate is not supported for client-jwt-jwks-url (server throws); + * that case is skipped intentionally. + */ +describe('24 Self-issuing credentials — client-credentials flows', () => { + let state: SuiteState + + before(() => { + loadSuiteState().then((s) => { + state = s + }) + }) + + describe('client-secret', () => { + const flowKey = FLOW_KEYS.clientSecret + let firstCredential: any + let applicationAppId = '' + let oldSecret = '' + + it('issues a client-secret credential', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `tenant-${flowKey}-a`, + description: 'Self-issued client-secret', + }, + labels: { 'issued-by': 'cypress-suite-24' }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.flow).to.eq('client-credentials') + expect(apiRes.body.clientId).to.be.a('string') + expect(apiRes.body.clientSecret).to.be.a('string') + expect(apiRes.body.tokenEndpoint).to.be.a('string') + + firstCredential = apiRes.body + oldSecret = apiRes.body.clientSecret + applicationAppId = applicationAppIdFromClientId( + apiRes.body.clientId, + env.environmentAppId + ) + }) + }) + }) + + it('exchanges client credentials and calls the upstream', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + getClientCredentialsToken( + firstCredential.clientId, + firstCredential.clientSecret + ).then((accessToken) => { + callProtectedBearer(env.serviceName, accessToken).then((res) => { + expect(res.status).to.eq(200) + }) + }) + }) + + it('reuses the application on the second environment', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'test')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId: applicationAppId }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.eq( + `${env.environmentAppId}-${applicationAppId}` + ) + expect(apiRes.body.clientSecret).to.be.a('string') + }) + }) + }) + + it('rejects duplicate environment access', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId: applicationAppId }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.be.oneOf([400, 422, 500]) + expect(JSON.stringify(apiRes.body)).to.match(/already has access/i) + }) + }) + }) + + it('regenerates the client secret', () => { + withIssuerToken(state.issuerSa, () => { + regenerateConsumer(state.gatewayId, firstCredential.clientId).then( + ({ apiRes }: any) => { + expect(apiRes.status).to.eq(200) + expect(apiRes.body.clientId).to.eq(firstCredential.clientId) + expect(apiRes.body.clientSecret).to.be.a('string') + expect(apiRes.body.clientSecret).to.not.eq(oldSecret) + + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + cy.request({ + method: 'POST', + url: Cypress.env('TOKEN_URL'), + form: true, + failOnStatusCode: false, + body: { + grant_type: 'client_credentials', + scope: 'openid', + client_id: firstCredential.clientId, + client_secret: oldSecret, + }, + }).then((oldTok) => { + expect(oldTok.status).to.eq(401) + }) + + getClientCredentialsToken( + firstCredential.clientId, + apiRes.body.clientSecret + ).then((accessToken) => { + callProtectedBearer(env.serviceName, accessToken).then((res) => { + expect(res.status).to.eq(200) + }) + }) + } + ) + }) + }) + }) + + describe('client-jwt (generated key pair)', () => { + const flowKey = FLOW_KEYS.clientJwt + let firstCredential: any + let applicationAppId = '' + let oldPrivateKey = '' + + it('issues a client-jwt credential with generated keys', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `tenant-${flowKey}-a`, + description: 'Self-issued client-jwt', + }, + controls: { + clientGenCertificate: true, + }, + labels: { 'issued-by': 'cypress-suite-24' }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.be.a('string') + expect(apiRes.body.clientPrivateKey).to.be.a('string') + expect(apiRes.body.clientPublicKey).to.be.a('string') + expect(apiRes.body.tokenEndpoint).to.be.a('string') + expect(apiRes.body.issuer).to.be.a('string') + + firstCredential = apiRes.body + oldPrivateKey = apiRes.body.clientPrivateKey + applicationAppId = applicationAppIdFromClientId( + apiRes.body.clientId, + env.environmentAppId + ) + }) + }) + }) + + it('obtains a token with the private key and calls the upstream', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + getTokenUsingPrivateKey( + firstCredential.clientId, + firstCredential.tokenEndpoint, + firstCredential.clientPrivateKey + ).then((accessToken) => { + callProtectedBearer(env.serviceName, accessToken).then((res) => { + expect(res.status).to.eq(200) + }) + }) + }) + + it('reuses the application on the second environment', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'test')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId: applicationAppId }, + controls: { clientGenCertificate: true }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.eq( + `${env.environmentAppId}-${applicationAppId}` + ) + }) + }) + }) + + it('rejects duplicate environment access', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId: applicationAppId }, + controls: { clientGenCertificate: true }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.be.oneOf([400, 422, 500]) + expect(JSON.stringify(apiRes.body)).to.match(/already has access/i) + }) + }) + }) + + it('regenerates JWT key material', () => { + withIssuerToken(state.issuerSa, () => { + regenerateConsumer(state.gatewayId, firstCredential.clientId).then( + ({ apiRes }: any) => { + expect( + apiRes.status, + `regenerate jwt: ${JSON.stringify(apiRes.body)}` + ).to.eq(200) + expect(apiRes.body.clientId).to.eq(firstCredential.clientId) + expect(apiRes.body.clientPrivateKey).to.be.a('string') + expect(apiRes.body.clientPrivateKey).to.not.eq(oldPrivateKey) + + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const njwt = require('njwt') + const now = Math.floor(Date.now() / 1000) + const badJwt = njwt + .create({ aud: Cypress.env('OIDC_ISSUER') }, oldPrivateKey, 'RS256') + .setIssuedAt(now) + .setExpiration(new Date((now + 300) * 1000)) + .setIssuer(firstCredential.clientId) + .setSubject(firstCredential.clientId) + .compact() + + cy.request({ + url: firstCredential.tokenEndpoint, + method: 'POST', + form: true, + failOnStatusCode: false, + body: { + grant_type: 'client_credentials', + client_id: firstCredential.clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: badJwt, + }, + }).then((oldTok) => { + expect(oldTok.status).to.be.oneOf([400, 401]) + }) + + getTokenUsingPrivateKey( + firstCredential.clientId, + apiRes.body.tokenEndpoint || firstCredential.tokenEndpoint, + apiRes.body.clientPrivateKey + ).then((accessToken) => { + callProtectedBearer(env.serviceName, accessToken).then((res) => { + expect(res.status).to.eq(200) + }) + }) + } + ) + }) + }) + }) + + describe('client-jwt-jwks-url', () => { + const flowKey = FLOW_KEYS.clientJwks + let firstCredential: any + let applicationAppId = '' + let privateKeyPem = '' + let publicKeyPem = '' + + it('issues a credential using clientCertificate controls', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + cy.generateKeyPair() + cy.readFile('cypress/fixtures/state/jwtReGenPrivateKey_new.pem').then( + (priv) => { + privateKeyPem = priv + cy.readFile('cypress/fixtures/state/jwtReGenPublicKey_new.pub').then( + (pub) => { + publicKeyPem = pub + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `tenant-${flowKey}-cert`, + description: 'Self-issued jwks authenticator via cert', + }, + controls: { + clientCertificate: publicKeyPem, + }, + labels: { 'issued-by': 'cypress-suite-24' }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.be.a('string') + expect(apiRes.body.tokenEndpoint).to.be.a('string') + expect(apiRes.body.issuer).to.be.a('string') + expect(apiRes.body.clientPrivateKey).to.not.exist + + firstCredential = apiRes.body + applicationAppId = applicationAppIdFromClientId( + apiRes.body.clientId, + env.environmentAppId + ) + }) + }) + } + ) + } + ) + }) + + it('obtains a token with the supplied certificate key and calls the upstream', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + getTokenUsingPrivateKey( + firstCredential.clientId, + firstCredential.tokenEndpoint, + privateKeyPem, + firstCredential.issuer + ).then((accessToken) => { + callProtectedBearer(env.serviceName, accessToken).then((res) => { + expect(res.status).to.eq(200) + }) + }) + }) + + it('also accepts jwksUrl controls when issuing', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'test')! + + cy.generateKeystore().then((keystoreJson: any) => { + const parsed = + typeof keystoreJson === 'string' + ? JSON.parse(keystoreJson) + : keystoreJson + return jose.JWK.asKeyStore(parsed).then((keyStore: any) => { + return cy + .request({ + url: Cypress.env('JWKS_URL'), + method: 'POST', + body: keyStore.toJSON(), + form: true, + failOnStatusCode: false, + }) + .then((jwksRes) => { + expect(jwksRes.status).to.eq(200) + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `tenant-${flowKey}-jwks`, + description: 'Self-issued via jwksUrl', + }, + controls: { + jwksUrl: Cypress.env('JWKS_URL'), + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + expect(apiRes.body.clientId).to.be.a('string') + expect(apiRes.body.tokenEndpoint).to.be.a('string') + expect(apiRes.body.issuer).to.be.a('string') + }) + }) + }) + }) + }) + }) + + it('rejects duplicate environment access', () => { + const flow = state.flows[flowKey] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId: applicationAppId }, + controls: { clientCertificate: publicKeyPem }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.be.oneOf([400, 422, 500]) + expect(JSON.stringify(apiRes.body)).to.match(/already has access/i) + }) + }) + }) + + it('skips regenerate for jwks-url (not supported by API)', () => { + cy.log( + 'Regenerate is intentionally unsupported for client-jwt-jwks-url; covered for other flows.' + ) + }) + }) +}) diff --git a/e2e/cypress/tests/24-self-issuing-credentials/03-authz-labels-revoke.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/03-authz-labels-revoke.cy.ts new file mode 100644 index 000000000..bcfc3af01 --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/03-authz-labels-revoke.cy.ts @@ -0,0 +1,156 @@ +import LoginPage from '../../pageObjects/login' +import ConsumersPage from '../../pageObjects/consumers' +import { + FLOW_KEYS, + SuiteState, + issueConsumer, + loadSuiteState, + regenerateConsumer, + useBearerToken, + withIssuerToken, + getClientCredentialsToken, +} from './helpers' + +/** + * Authorization (missing CredentialIssuer.Generate → 403), + * Consumers UI visibility + label filter, and UI-only revoke/delete. + * Depends on 00-setup.cy.ts suite state. + */ +describe('24 Self-issuing credentials — authz, labels, revoke', () => { + const login = new LoginPage() + const consumers = new ConsumersPage() + + let state: SuiteState + let labeledClientId = '' + let labeledApiKey = '' + + before(() => { + loadSuiteState().then((s) => { + state = s + }) + }) + + beforeEach(() => { + cy.preserveCookies() + cy.fixture('apiowner').as('apiowner') + }) + + describe('Authorization', () => { + it('returns 403 when issuing without CredentialIssuer.Generate', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.controlSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: 'should-be-forbidden', + description: 'Missing Generate scope', + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(403) + }) + }) + }) + + it('returns 403 when regenerating without CredentialIssuer.Generate', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + + // First create a credential with the issuer SA so we have a real clientId + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `authz-regen-target-${Date.now()}`, + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + const clientId = apiRes.body.clientId + + getClientCredentialsToken( + state.controlSa.clientId, + state.controlSa.clientSecret + ).then((token) => { + useBearerToken(token) + regenerateConsumer(state.gatewayId, clientId).then( + ({ apiRes: regenRes }: any) => { + expect(regenRes.status).to.eq(403) + } + ) + }) + }) + }) + }) + }) + + describe('Consumers UI — labels and revoke', () => { + it('issues a labeled API-key consumer for UI checks', () => { + const flow = state.flows[FLOW_KEYS.apiKeyAcl] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `ui-labeled-tenant-${Date.now()}`, + description: 'Visible on Consumers page', + }, + labels: { + 'issued-by': 'my-service', + team: 'suite-24', + }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + labeledClientId = apiRes.body.clientId + labeledApiKey = apiRes.body.apiKey + }) + }) + }) + + it('logs in as Janis and activates the gateway', () => { + cy.visit('/') + cy.get('@apiowner').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + cy.activateGateway(state.gatewayId) + }) + + it('shows the consumer on the Consumers page and filters by label', () => { + cy.visit(consumers.path) + cy.wait(2000) + + // Filter by Labels: issued-by = my-service + consumers.verifyFilterResults('Labels', 'issued-by', '1', 'my-service') + + cy.get(consumers.allConsumerTable) + .contains(labeledClientId) + .should('exist') + }) + + it('revokes/deletes the consumer via Consumers UI (no DELETE API yet)', () => { + cy.visit(consumers.path) + cy.wait(1000) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', 'my-service') + cy.wait(1000) + + consumers.deleteConsumer(labeledClientId) + cy.contains('This action cannot be undone').should('be.visible') + cy.contains('Yes, Delete').click() + cy.verifyToastMessage('Consumer deleted') + + cy.wait(2000) + cy.visit(consumers.path) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', 'my-service') + cy.get(consumers.allConsumerTable).then(($tbl) => { + expect($tbl.text()).to.not.include(labeledClientId) + }) + }) + }) + + after(() => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + }) +}) diff --git a/e2e/cypress/tests/24-self-issuing-credentials/helpers.ts b/e2e/cypress/tests/24-self-issuing-credentials/helpers.ts new file mode 100644 index 000000000..01240f137 --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/helpers.ts @@ -0,0 +1,438 @@ +/** + * Shared helpers for the independent 24-self-issuing-credentials suite. + * Runtime state is kept in fixtures/state/24-self-issue.json (not shared store.json). + */ + +export const SUITE_STATE_PATH = 'cypress/fixtures/state/24-self-issue.json' +export const SUITE_FIXTURE_DIR = '24-self-issuing-credentials' + +export type SuiteCredentials = { + clientId: string + clientSecret: string +} + +export type FlowEnv = { + name: 'dev' | 'test' + environmentAppId: string + serviceName: string + host: string +} + +export type FlowConfig = { + key: string + productName: string + flow: 'kong-api-key-only' | 'kong-api-key-acl' | 'client-credentials' + authenticator?: 'client-secret' | 'client-jwt' | 'client-jwt-jwks-url' + issuerName?: string + envs: FlowEnv[] +} + +export type SuiteState = { + gatewayId: string + displayName?: string + issuerSa: SuiteCredentials + controlSa: SuiteCredentials + publishSa: SuiteCredentials + flows: Record +} + +export const FLOW_KEYS = { + apiKeyOnly: 'apiKeyOnly', + apiKeyAcl: 'apiKeyAcl', + clientSecret: 'clientSecret', + clientJwt: 'clientJwt', + clientJwks: 'clientJwks', +} as const + +const AUTH_PROFILE_CLIENT = { + clientId: 'cypress-auth-profile', + clientSecret: '43badfc1-c06f-4bec-bab6-ccdc764071ac', +} + +export function saveSuiteState(state: SuiteState): Cypress.Chainable { + return cy.writeFile(SUITE_STATE_PATH, state, { log: true }) +} + +export function loadSuiteState(): Cypress.Chainable { + return cy.readFile(SUITE_STATE_PATH) as Cypress.Chainable +} + +export function applicationAppIdFromClientId( + clientId: string, + environmentAppId: string +): string { + const prefix = `${environmentAppId}-` + expect(clientId.startsWith(prefix), `clientId ${clientId} should start with ${prefix}`).to.eq( + true + ) + return clientId.slice(prefix.length) +} + +export function issuerEnvDetails() { + return ['dev', 'test'].map((environment) => ({ + environment, + issuerUrl: Cypress.env('OIDC_ISSUER'), + clientRegistration: 'managed', + clientId: AUTH_PROFILE_CLIENT.clientId, + clientSecret: AUTH_PROFILE_CLIENT.clientSecret, + })) +} + +export function keyAuthPluginYaml(namespace: string): string { + return ` + plugins: + - name: key-auth + tags: [ ns.${namespace} ] + protocols: [ http, https ] + config: + key_names: ["X-API-KEY"] + run_on_preflight: true + hide_credentials: true + key_in_body: false +` +} + +export function keyAuthAclPluginYaml(namespace: string, appId: string): string { + return ` + plugins: + - name: key-auth + tags: [ ns.${namespace} ] + protocols: [ http, https ] + config: + key_names: ["X-API-KEY"] + run_on_preflight: true + hide_credentials: true + key_in_body: false + - name: acl + tags: [ ns.${namespace} ] + config: + hide_groups_header: true + allow: [ "${appId}" ] +` +} + +export function jwtKeycloakPluginYaml( + namespace: string, + issuerUrl: string +): string { + return ` + plugins: + - name: jwt-keycloak + tags: [ ns.${namespace} ] + enabled: true + config: + allowed_iss: + - ${issuerUrl} + run_on_preflight: true + iss_key_grace_period: 10 + maximum_expiration: 0 + algorithm: RS256 + claims_to_verify: + - exp + uri_param_names: + - jwt + cookie_names: [] + scope: null + roles: null + realm_roles: null + client_roles: null + anonymous: null + consumer_match: true + consumer_match_claim: azp + consumer_match_claim_custom_id: true + consumer_match_ignore_not_found: false +` +} + +/** Returns a single Kong service document fragment (list item under `services:`). */ +export function serviceYamlItem( + serviceName: string, + namespace: string, + pluginYaml: string +): string { + // pluginYaml already includes a correctly indented `plugins:` block + return `- name: ${serviceName} + host: httpbun.com + tags: [ns.${namespace}] + port: 443 + protocol: https + retries: 0 + routes: + - name: ${serviceName}-route + tags: [ns.${namespace}] + hosts: + - ${serviceName}.api.gov.bc.ca + paths: + - / + methods: + - GET + strip_path: false + https_redirect_status_code: 426 + path_handling: v0 +${pluginYaml.trimEnd()} +` +} + +export function buildServicesYaml(serviceItems: string[]): string { + return `services:\n${serviceItems.join('\n')}` +} + +/** Set Authorization bearer for subsequent cy.callAPI calls. */ +export function useBearerToken(token: string): void { + cy.setHeaders({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }) + cy.setAuthorizationToken(token) +} + +export function getClientCredentialsToken( + clientId: string, + clientSecret: string +): Cypress.Chainable { + return cy + .request({ + method: 'POST', + url: Cypress.env('TOKEN_URL'), + form: true, + failOnStatusCode: false, + body: { + grant_type: 'client_credentials', + scope: 'openid', + client_id: clientId, + client_secret: clientSecret, + }, + }) + .then((res) => { + expect(res.status, 'token endpoint status').to.eq(200) + expect(res.body.access_token, 'access_token').to.be.a('string') + return res.body.access_token as string + }) +} + +export function withIssuerToken( + creds: SuiteCredentials, + fn: (token: string) => void +): void { + getClientCredentialsToken(creds.clientId, creds.clientSecret).then((token) => { + useBearerToken(token) + cy.then(() => { + fn(token) + }) + }) +} + +export function issueConsumer( + gatewayId: string, + body: Record +): Cypress.Chainable { + cy.setRequestBody(body) + return cy.callAPI(`ds/api/v3/gateways/${gatewayId}/consumers`, 'POST') +} + +export function regenerateConsumer( + gatewayId: string, + clientId: string +): Cypress.Chainable { + cy.setQueryString({ action: 'regenerate' }) + cy.clearRequestBody() + // callAPI clears queryString after the request + return cy.callAPI( + `ds/api/v3/gateways/${gatewayId}/consumers/${encodeURIComponent(clientId)}`, + 'PUT' + ) +} + +export function callProtectedApiKey( + serviceName: string, + apiKey: string +): Cypress.Chainable> { + return cy.request({ + url: Cypress.env('KONG_URL'), + method: 'GET', + headers: { + 'x-api-key': apiKey, + Host: `${serviceName}.api.gov.bc.ca`, + }, + failOnStatusCode: false, + }) +} + +export function callProtectedBearer( + serviceName: string, + accessToken: string +): Cypress.Chainable> { + return cy.request({ + url: Cypress.env('KONG_URL'), + method: 'GET', + headers: { + Host: `${serviceName}.api.gov.bc.ca`, + }, + auth: { bearer: accessToken }, + failOnStatusCode: false, + }) +} + +export function getTokenUsingPrivateKey( + clientId: string, + tokenEndpoint: string, + privateKeyPem: string, + audience?: string +): Cypress.Chainable { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const njwt = require('njwt') + const now = Math.floor(Date.now() / 1000) + const plus5Minutes = new Date((now + 5 * 60) * 1000) + const claims = { + aud: audience || Cypress.env('OIDC_ISSUER'), + } + const jwt = njwt + .create(claims, privateKeyPem, 'RS256') + .setIssuedAt(now) + .setExpiration(plus5Minutes) + .setIssuer(clientId) + .setSubject(clientId) + .compact() + + return cy + .request({ + url: tokenEndpoint, + method: 'POST', + form: true, + failOnStatusCode: false, + body: { + grant_type: 'client_credentials', + client_id: clientId, + scopes: 'openid', + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: jwt, + }, + }) + .then((res) => { + expect( + res.status, + `jwt client assertion token: ${JSON.stringify(res.body)}` + ).to.eq(200) + return res.body.access_token as string + }) +} + +export function configureGwaHost(): void { + const cleanedUrl = String(Cypress.env('BASE_URL')).replace(/^https?:\/\//i, '') + const scheme = String(Cypress.env('BASE_URL')).startsWith('https') + ? 'https' + : 'http' + cy.executeCliCommand( + `gwa config set --host ${cleanedUrl} --scheme ${scheme}` + ).then((response: any) => { + expect(response.stdout).to.contain('Config settings saved') + }) +} + +export function publishConfigWithToken( + gatewayId: string, + token: string, + relativeFixturePath: string +): void { + cy.executeCliCommand(`gwa config set --gateway ${gatewayId}`).then(() => { + cy.executeCliCommand(`gwa config set --token ${token}`).then((setToken) => { + expect(setToken.stdout || setToken.stderr || '').to.contain( + 'Config settings saved' + ) + cy.exec(`gwa pg ./cypress/fixtures/${relativeFixturePath}`, { + timeout: 60000, + failOnNonZeroExit: false, + }).then((pub) => { + const output = `${pub.stdout || ''}\n${pub.stderr || ''}` + cy.log(output) + expect( + output, + `gwa pg output (code=${pub.code})` + ).to.match(/Gateway config published|Sync successful/i) + }) + }) + }) +} + +export function extractServiceAccountCredsFromUi(): Cypress.Chainable { + return cy + .get('[data-testid=sa-new-creds-client-id]') + .invoke('val') + .then((clientId: string) => { + return cy + .get('[data-testid=sa-new-creds-client-secret]') + .invoke('val') + .then((clientSecret: string) => { + return { clientId, clientSecret } as SuiteCredentials + }) + }) +} + +export function getGatewayServices(gatewayId: string): Cypress.Chainable { + return cy.callAPI(`ds/api/v3/gateways/${gatewayId}/services`, 'GET') +} + +/** + * Poll until Keystone has ingested the published Kong services for this gateway. + * Optionally nudges the feeder to sync the namespace first. + */ +export function waitForGatewayServices( + gatewayId: string, + expectedServiceNames: string[], + attempts = 20 +): void { + // Best-effort: ask feeder to pull Kong entities for this namespace + cy.request({ + method: 'PUT', + url: `http://feeder.localtest.me:6000/forceSync/kong/namespace/${gatewayId}`, + failOnStatusCode: false, + }) + + const tryOnce = (remaining: number) => { + getGatewayServices(gatewayId).then(({ apiRes }: any) => { + const names = new Set() + ;(apiRes.body || []).forEach((r: any) => { + if (r?.name) names.add(r.name) + if (r?.service?.name) names.add(r.service.name) + if (typeof r?.service === 'string') names.add(r.service) + }) + const present = Array.from(names) + const missing = expectedServiceNames.filter((n) => !names.has(n)) + if (missing.length === 0) { + cy.log(`All ${expectedServiceNames.length} services synced`) + return + } + if (remaining <= 1) { + throw new Error( + `Timed out waiting for gateway services. Missing: ${missing.join( + ', ' + )}. Present: ${present.join(', ')}` + ) + } + cy.wait(3000) + tryOnce(remaining - 1) + }) + } + tryOnce(attempts) +} + +export function putProduct( + gatewayId: string, + product: Record +): Cypress.Chainable { + cy.setRequestBody(product) + return cy.callAPI(`ds/api/v3/gateways/${gatewayId}/products`, 'PUT') +} + +export function getProducts(gatewayId: string): Cypress.Chainable { + return cy.callAPI(`ds/api/v3/gateways/${gatewayId}/products`, 'GET') +} + +export function putIssuer( + gatewayId: string, + issuer: Record +): Cypress.Chainable { + cy.setRequestBody(issuer) + return cy.callAPI(`ds/api/v3/gateways/${gatewayId}/issuers`, 'PUT') +} diff --git a/src/services/workflow/regenerate-gateway-credential.ts b/src/services/workflow/regenerate-gateway-credential.ts index 15ac20233..f05170f9e 100644 --- a/src/services/workflow/regenerate-gateway-credential.ts +++ b/src/services/workflow/regenerate-gateway-credential.ts @@ -127,7 +127,9 @@ export async function regenerateGatewayCredential( }, }); - await kcClientService.uploadCertificate(client.id, publicKey); + // Match update-credential / issue paths: set jwt.credential.public.key + // via attributes rather than the multipart upload-certificate endpoint. + await kcClientService.updateCertificate(client, publicKey); newCredential.clientPrivateKey = privateKey; newCredential.clientPublicKey = publicKey; } else { From b3a60f5e65883d8783497ff79a1ad8a11b0ef2b9 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Thu, 6 Aug 2026 10:27:24 -0700 Subject: [PATCH 05/11] Add Cypress coverage confirming My Access excludes ownerless issuer-created applications --- .../04-my-access-exclusion.cy.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/04-my-access-exclusion.cy.ts diff --git a/e2e/cypress/tests/24-self-issuing-credentials/04-my-access-exclusion.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/04-my-access-exclusion.cy.ts new file mode 100644 index 000000000..fb0b3130f --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/04-my-access-exclusion.cy.ts @@ -0,0 +1,107 @@ +import LoginPage from '../../pageObjects/login' +import ApplicationPage from '../../pageObjects/applications' +import MyAccessPage from '../../pageObjects/myAccess' +import ConsumersPage from '../../pageObjects/consumers' +import { + FLOW_KEYS, + SuiteState, + issueConsumer, + loadSuiteState, + withIssuerToken, +} from './helpers' + +/** + * Ownerless issuer-created Applications must not appear in the developer + * portal (myApplications uses filterByOwner). They should still show on + * the gateway Consumers page for the API provider. + * + * Depends on 00-setup.cy.ts suite state. + */ +describe('24 Self-issuing credentials — My Access exclusion', () => { + const login = new LoginPage() + const applications = new ApplicationPage() + const myAccess = new MyAccessPage() + const consumers = new ConsumersPage() + + let state: SuiteState + const appName = `my-access-exclusion-${Date.now()}` + let clientId = '' + + before(() => { + loadSuiteState().then((s) => { + state = s + expect(state.gatewayId, 'suite state from 00-setup').to.be.a('string') + }) + }) + + beforeEach(() => { + cy.preserveCookies() + cy.fixture('developer').as('developer') + cy.fixture('apiowner').as('apiowner') + }) + + it('issues an ownerless credential via the issuer API', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: appName, + description: 'Must not appear in developer My Access', + }, + labels: { 'issued-by': 'my-access-check' }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + clientId = apiRes.body.clientId + expect(clientId).to.be.a('string') + }) + }) + }) + + it('does not list the application for a developer on Applications', () => { + cy.visit('/') + cy.deleteAllCookies() + cy.reload(true) + cy.get('@developer').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + cy.visit(applications.path) + cy.wait(2000) + cy.contains(appName).should('not.exist') + }) + + it('does not list the application on developer My Access', () => { + cy.visit(myAccess.path) + cy.wait(2000) + cy.contains(appName).should('not.exist') + cy.contains(clientId).should('not.exist') + }) + + it('shows the consumer on the API provider Consumers page', () => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + cy.visit(login.path) + cy.get('@apiowner').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + cy.activateGateway(state.gatewayId) + cy.visit(consumers.path) + cy.wait(2000) + consumers.filterConsumerByTypeAndValue( + 'Labels', + 'issued-by', + 'my-access-check' + ) + cy.wait(1000) + cy.get(consumers.allConsumerTable).contains(clientId).should('exist') + }) + + after(() => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + }) +}) From 36c5d081a56e14f68a563f8f90ba866d938340ce Mon Sep 17 00:00:00 2001 From: Elson9 Date: Thu, 6 Aug 2026 10:50:46 -0700 Subject: [PATCH 06/11] Fix missing gatewayId path parameter on v3 GET products so it appears in OpenAPI --- src/controllers/v3/ProductController.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/controllers/v3/ProductController.ts b/src/controllers/v3/ProductController.ts index 81a10de66..950919695 100644 --- a/src/controllers/v3/ProductController.ts +++ b/src/controllers/v3/ProductController.ts @@ -79,14 +79,17 @@ export class ProductController extends Controller { * > `Required Scope:` Namespace.Manage * * @summary Get Products - * @param ns + * @param gatewayId * @param request * @returns */ @Get('/products') @OperationId('get-products') @Security('jwt', ['Namespace.Manage']) - public async get(@Request() request: any): Promise { + public async get( + @Path() gatewayId: string, + @Request() request: any + ): Promise { const ctx = this.keystone.createContext(request); const records: KSProduct[] = await getRecords( ctx, From be2040162b7be4fc1376cafc6ee38d97dfacdc2e Mon Sep 17 00:00:00 2001 From: Elson9 Date: Fri, 7 Aug 2026 12:57:12 -0700 Subject: [PATCH 07/11] Show application description and gateway-administered owner placeholder on Consumer Details --- ...tplocalhost4180managerconsumers-b2cc08.gql | 51 +++++++++++++++++++ src/nextapp/pages/manager/consumers/[id].tsx | 26 +++++++++- src/services/keystone/service-access.ts | 1 + 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/authz/graphql-whitelist/httplocalhost4180managerconsumers-b2cc08.gql diff --git a/src/authz/graphql-whitelist/httplocalhost4180managerconsumers-b2cc08.gql b/src/authz/graphql-whitelist/httplocalhost4180managerconsumers-b2cc08.gql new file mode 100644 index 000000000..14d1a019c --- /dev/null +++ b/src/authz/graphql-whitelist/httplocalhost4180managerconsumers-b2cc08.gql @@ -0,0 +1,51 @@ + + query GetConsumer($consumerId: ID!) { + allConsumerGroupLabels + + getNamespaceConsumerAccess(consumerId: $consumerId) { + consumer { + id + username + } + application { + name + description + } + owner { + name + providerUsername + email + } + labels { + labelGroup + values + } + prodEnvAccess { + productName + environment { + flow + name + id + additionalDetailsToRequest + } + plugins { + name + } + revocable + serviceAccessId + authorization { + defaultClientScopes + } + request { + name + isIssued + isApproved + isComplete + additionalDetails + } + requestApprover { + name + } + } + } + } diff --git a/src/nextapp/pages/manager/consumers/[id].tsx b/src/nextapp/pages/manager/consumers/[id].tsx index adb2a1e93..1fde0c2ba 100644 --- a/src/nextapp/pages/manager/consumers/[id].tsx +++ b/src/nextapp/pages/manager/consumers/[id].tsx @@ -173,12 +173,33 @@ const ConsumerPage: React.FC< icon={} bg="bc-gray" /> - {application?.name} + + {application?.name} + {application?.description && ( + + {application.description} + + )} + - {consumer.owner && ( + {consumer.owner ? ( + ) : ( + + Application administered by this gateway + )} @@ -306,6 +327,7 @@ const query = gql` } application { name + description } owner { name diff --git a/src/services/keystone/service-access.ts b/src/services/keystone/service-access.ts index 77aad50d0..01800654f 100644 --- a/src/services/keystone/service-access.ts +++ b/src/services/keystone/service-access.ts @@ -359,6 +359,7 @@ export async function lookupLabeledServiceAccessesForNamespace( } application { name + description owner { name provider From d201f65aa39093806442acf90849b6dfc4cf0ac5 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Fri, 7 Aug 2026 13:10:59 -0700 Subject: [PATCH 08/11] Omit null credential fields from gateway consumer issue and regenerate responses --- src/controllers/v3/GatewayConsumersController.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/v3/GatewayConsumersController.ts b/src/controllers/v3/GatewayConsumersController.ts index e6c85af1c..43345223d 100644 --- a/src/controllers/v3/GatewayConsumersController.ts +++ b/src/controllers/v3/GatewayConsumersController.ts @@ -19,6 +19,7 @@ import { issueGatewayCredential, regenerateGatewayCredential, } from '../../services/workflow'; +import { removeEmpty } from '../../batch/feed-worker'; import { GatewayConsumerCredential, IssueGatewayConsumerRequest, @@ -67,7 +68,7 @@ export class GatewayConsumersController extends Controller { }); this.setStatus(201); - return credential; + return removeEmpty(credential) as GatewayConsumerCredential; } /** @@ -104,6 +105,11 @@ export class GatewayConsumersController extends Controller { ); const ctx = this.keystone.createContext(request, true); - return regenerateGatewayCredential(ctx, gatewayId, clientId); + const credential = await regenerateGatewayCredential( + ctx, + gatewayId, + clientId + ); + return removeEmpty(credential) as GatewayConsumerCredential; } } From 0f339cfcca6bde1e717bbd51daea16eba8b4eca3 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Fri, 7 Aug 2026 13:26:06 -0700 Subject: [PATCH 09/11] Fix consumer Edit dialog crash when ServiceAccess has no AccessRequest --- src/services/workflow/consumer-management.ts | 33 +++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/services/workflow/consumer-management.ts b/src/services/workflow/consumer-management.ts index 88b322a59..38e2f6846 100644 --- a/src/services/workflow/consumer-management.ts +++ b/src/services/workflow/consumer-management.ts @@ -413,7 +413,8 @@ export async function getConsumerProdEnvAccess( } // request?: AccessRequest; - // lookup request based on ServiceAccess record + // lookup request based on ServiceAccess record. + // Self-issued credentials create ServiceAccess without an AccessRequest. if (access.serviceAccessId) { access.request = await getAccessRequestByNamespaceServiceAccess( context, @@ -421,21 +422,23 @@ export async function getConsumerProdEnvAccess( access.serviceAccessId ); - const activity = await getActivityByRefId( - context, - `accessRequest:${access.request.id}` - ); - logger.debug('Activity %j', activity); + if (access.request) { + const activity = await getActivityByRefId( + context, + `accessRequest:${access.request.id}` + ); + logger.debug('Activity %j', activity); - const match: Activity[] = activity.filter( - (a: Activity) => a.action === 'rejected' || a.action === 'approved' - ); - if (match.length > 0) { - const context = JSON.parse(match[0].context); - access.requestApprover = { - id: '', - name: context.params.actor, - }; + const match: Activity[] = activity.filter( + (a: Activity) => a.action === 'rejected' || a.action === 'approved' + ); + if (match.length > 0) { + const context = JSON.parse(match[0].context); + access.requestApprover = { + id: '', + name: context.params.actor, + }; + } } } From 4264ca8334282d8d2aafa98bf6573dc9aedf0f28 Mon Sep 17 00:00:00 2001 From: Elson9 Date: Fri, 7 Aug 2026 13:41:10 -0700 Subject: [PATCH 10/11] Delete ownerless Applications when the last consumer ServiceAccess is revoked --- .../05-orphan-application-cleanup.cy.ts | 210 ++++++++++++++++++ src/services/keystone/index.ts | 1 + src/services/keystone/service-access.ts | 22 ++ src/services/workflow/consumer-management.ts | 28 +++ 4 files changed, 261 insertions(+) create mode 100644 e2e/cypress/tests/24-self-issuing-credentials/05-orphan-application-cleanup.cy.ts diff --git a/e2e/cypress/tests/24-self-issuing-credentials/05-orphan-application-cleanup.cy.ts b/e2e/cypress/tests/24-self-issuing-credentials/05-orphan-application-cleanup.cy.ts new file mode 100644 index 000000000..42be8fe14 --- /dev/null +++ b/e2e/cypress/tests/24-self-issuing-credentials/05-orphan-application-cleanup.cy.ts @@ -0,0 +1,210 @@ +import LoginPage from '../../pageObjects/login' +import ConsumersPage from '../../pageObjects/consumers' +import { + FLOW_KEYS, + SuiteState, + applicationAppIdFromClientId, + issueConsumer, + loadSuiteState, + withIssuerToken, +} from './helpers' + +/** + * Delete Consumer must remove ownerless Applications when they have no + * remaining ServiceAccess, but keep them when reused across environments. + * Depends on 00-setup.cy.ts suite state. + */ +describe('24 Self-issuing credentials — orphan Application cleanup', () => { + const login = new LoginPage() + const consumers = new ConsumersPage() + + let state: SuiteState + + before(() => { + loadSuiteState().then((s) => { + state = s + expect(state.gatewayId, 'suite state from 00-setup').to.be.a('string') + }) + }) + + beforeEach(() => { + cy.preserveCookies() + cy.fixture('apiowner').as('apiowner') + }) + + describe('single-env ownerless app is deleted with consumer', () => { + let clientId = '' + let appId = '' + const label = 'orphan-app-cleanup' + + it('issues an ownerless consumer', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { + name: `orphan-cleanup-${Date.now()}`, + description: 'Should be deleted with consumer', + }, + labels: { 'issued-by': label }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + clientId = apiRes.body.clientId + appId = applicationAppIdFromClientId(clientId, env.environmentAppId) + }) + }) + }) + + it('deletes the consumer via UI', () => { + cy.visit('/') + cy.deleteAllCookies() + cy.reload(true) + cy.get('@apiowner').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + cy.activateGateway(state.gatewayId) + cy.visit(consumers.path) + cy.wait(1000) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', label) + cy.wait(1000) + consumers.deleteConsumer(clientId) + cy.contains('This action cannot be undone').should('be.visible') + cy.contains('Yes, Delete').click() + cy.verifyToastMessage('Consumer deleted') + }) + + it('cannot reuse the deleted Application appId', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.not.eq(201) + const body = JSON.stringify(apiRes.body) + expect(body).to.match(/not found|Application/i) + }) + }) + }) + }) + + describe('multi-env reuse keeps Application until last consumer', () => { + let appId = '' + let devClientId = '' + let testClientId = '' + const labelDev = 'orphan-app-multienv-dev' + const labelTest = 'orphan-app-multienv-test' + + it('issues the same Application on dev and test', () => { + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const dev = flow.envs.find((e) => e.name === 'dev')! + const test = flow.envs.find((e) => e.name === 'test')! + + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: dev.environmentAppId, + application: { + name: `orphan-multienv-${Date.now()}`, + description: 'Reused across envs', + }, + labels: { 'issued-by': labelDev }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + devClientId = apiRes.body.clientId + appId = applicationAppIdFromClientId( + devClientId, + dev.environmentAppId + ) + + issueConsumer(state.gatewayId, { + environmentAppId: test.environmentAppId, + application: { appId }, + labels: { 'issued-by': labelTest }, + }).then(({ apiRes: testRes }: any) => { + expect(testRes.status).to.eq(201) + testClientId = testRes.body.clientId + }) + }) + }) + }) + + it('keeps Application after deleting only the dev consumer', () => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + cy.visit(login.path) + cy.get('@apiowner').then(({ user }: any) => { + cy.login(user.credentials.username, user.credentials.password) + }) + cy.activateGateway(state.gatewayId) + cy.visit(consumers.path) + cy.wait(1000) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', labelDev) + cy.wait(1000) + consumers.deleteConsumer(devClientId) + cy.contains('This action cannot be undone').should('be.visible') + cy.contains('Yes, Delete').click() + cy.verifyToastMessage('Consumer deleted') + + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const dev = flow.envs.find((e) => e.name === 'dev')! + + withIssuerToken(state.issuerSa, () => { + // App still exists — can re-issue on dev with same appId + issueConsumer(state.gatewayId, { + environmentAppId: dev.environmentAppId, + application: { appId }, + labels: { 'issued-by': labelDev }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.eq(201) + // leave this re-issued consumer; last-consumer test will delete both + devClientId = apiRes.body.clientId + }) + }) + }) + + it('deletes Application after the last consumer is removed', () => { + // After previous test: re-issued dev consumer + original test consumer share appId + cy.visit(consumers.path) + cy.wait(1000) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', labelDev) + cy.wait(1000) + consumers.deleteConsumer(devClientId) + cy.contains('This action cannot be undone').should('be.visible') + cy.contains('Yes, Delete').click() + cy.verifyToastMessage('Consumer deleted') + + cy.visit(consumers.path) + cy.wait(1000) + consumers.filterConsumerByTypeAndValue('Labels', 'issued-by', labelTest) + cy.wait(1000) + consumers.deleteConsumer(testClientId) + cy.contains('This action cannot be undone').should('be.visible') + cy.contains('Yes, Delete').click() + cy.verifyToastMessage('Consumer deleted') + + const flow = state.flows[FLOW_KEYS.apiKeyOnly] + const env = flow.envs.find((e) => e.name === 'dev')! + withIssuerToken(state.issuerSa, () => { + issueConsumer(state.gatewayId, { + environmentAppId: env.environmentAppId, + application: { appId }, + }).then(({ apiRes }: any) => { + expect(apiRes.status).to.not.eq(201) + const body = JSON.stringify(apiRes.body) + expect(body).to.match(/not found|Application/i) + }) + }) + }) + }) + + after(() => { + cy.logout() + cy.clearLocalStorage({ log: true }) + cy.deleteAllCookies() + }) +}) diff --git a/src/services/keystone/index.ts b/src/services/keystone/index.ts index 8451f432b..9869ff436 100644 --- a/src/services/keystone/index.ts +++ b/src/services/keystone/index.ts @@ -52,6 +52,7 @@ export { export { addServiceAccess, + countServiceAccessesByApplication, deleteServiceAccess, linkCredRefsToServiceAccess, lookupCredentialReferenceByServiceAccess, diff --git a/src/services/keystone/service-access.ts b/src/services/keystone/service-access.ts index 01800654f..8f2256033 100644 --- a/src/services/keystone/service-access.ts +++ b/src/services/keystone/service-access.ts @@ -30,8 +30,10 @@ export async function lookupCredentialReferenceByServiceAccess( } } application { + id name owner { + id name username email @@ -535,3 +537,23 @@ export async function deleteServiceAccess( }); logger.debug('[deleteServiceAccess] RESULT %j', result); } + +export async function countServiceAccessesByApplication( + context: any, + applicationId: string +): Promise { + const result = await context.executeGraphQL({ + query: `query CountServiceAccessesByApplication($applicationId: ID!) { + allServiceAccesses(where: { application: { id: $applicationId } }) { + id + } + }`, + variables: { applicationId }, + }); + assert.strictEqual( + 'errors' in result, + false, + `Unexpected errors ${JSON.stringify(result.errors)}` + ); + return result.data.allServiceAccesses.length; +} diff --git a/src/services/workflow/consumer-management.ts b/src/services/workflow/consumer-management.ts index 38e2f6846..b1e467bd6 100644 --- a/src/services/workflow/consumer-management.ts +++ b/src/services/workflow/consumer-management.ts @@ -57,7 +57,10 @@ import { lookupServiceAccessesByConsumer, lookupEnvironmentAndIssuerById, getConsumerLabels, + countServiceAccessesByApplication, + deleteRecord, deleteServiceAccess, + lookupCredentialReferenceByServiceAccess, } from '../keystone'; import { lookupEnvironmentsByNS } from '../keystone/product-environment'; import { KongConsumerService } from '../kong'; @@ -765,8 +768,33 @@ export async function revokeAllConsumerAccess( ); const serviceAccessId = prodEnvAccess[0].serviceAccessId; + const serviceAccess = await lookupCredentialReferenceByServiceAccess( + context, + serviceAccessId + ); + const application = serviceAccess.application; + await deleteServiceAccess(context, serviceAccessId); + // Self-issued Applications are ownerless. Delete them when this was the + // last ServiceAccess so they are not left orphaned. Keep apps that are + // developer-owned or still reused across other environments. + if (application?.id && !application.owner) { + const remaining = await countServiceAccessesByApplication( + context, + application.id + ); + if (remaining === 0) { + logger.info( + '[revokeAllConsumerAccess] Deleting ownerless Application %s', + application.id + ); + await deleteRecord(context, 'Application', { id: application.id }, [ + 'id', + ]); + } + } + await new StructuredActivityService(context, ns).logRevokeAllConsumerAccess( true, { From 3a52adfe06a3e697341d3540ddcd7ee1b561c12c Mon Sep 17 00:00:00 2001 From: Elson9 Date: Fri, 7 Aug 2026 14:58:47 -0700 Subject: [PATCH 11/11] Stabilize Keycloak org-assignment Cypress tests by fixing Users navigation and Join Group flakiness --- e2e/cypress/pageObjects/keycloakGroup.ts | 116 +++++++++++++++--- e2e/cypress/pageObjects/keycloakUsers.ts | 32 ++++- e2e/cypress/support/auth-commands.ts | 5 +- .../04-multiple-org-admin-org-unit.ts | 116 +++++++++--------- 4 files changed, 181 insertions(+), 88 deletions(-) diff --git a/e2e/cypress/pageObjects/keycloakGroup.ts b/e2e/cypress/pageObjects/keycloakGroup.ts index 592603f66..f63ea1d6a 100644 --- a/e2e/cypress/pageObjects/keycloakGroup.ts +++ b/e2e/cypress/pageObjects/keycloakGroup.ts @@ -12,15 +12,34 @@ class keycloakGroupPage { attributeValue: string = '[data-testid="attributes-value"]' saveBtn: string = '[data-testid="attributes-save"]' - private consoleUrl(): string { + private consoleRoot(): string { const base = Cypress.env('KEYCLOAK_URL') + return `${base}/auth/admin/master/console/` + } + + private groupsHash(): string { const realm = Cypress.env('KEYCLOAK_REALM') || 'master' - return `${base}/auth/admin/master/console/#/${realm}/groups` + return `#/${realm}/groups` + } + + private consoleUrl(): string { + return `${this.consoleRoot()}${this.groupsHash()}` } visitGroups() { - cy.visit(this.consoleUrl()) - cy.get(this.groupSearchInput, { timeout: 20000 }).should('be.visible') + // Cypress skips reload when only the hash changes. Load console root first, + // then set the groups hash so the SPA always remounts the Groups view. + cy.visit(this.consoleRoot()) + cy.get('body', { timeout: 20000 }).should('be.visible') + cy.window().then((win) => { + if (win.location.hash !== this.groupsHash()) { + win.location.hash = this.groupsHash() + } + }) + cy.location('hash', { timeout: 20000 }).should('include', '/groups') + cy.get(this.groupSearchInput, { timeout: 20000 }) + .first() + .should('be.visible') } selectTab(tabName: string) { @@ -38,25 +57,66 @@ class keycloakGroupPage { cy.get(this.userGroupsTab, { timeout: 15000 }) .should('be.visible') .click() - cy.contains('Join Group', { timeout: 15000 }).should('be.visible') + cy.contains('button', 'Join Group', { timeout: 20000 }).should( + 'be.visible' + ) + } + + private dismissOpenDialog() { + cy.get('body').then(($body) => { + if ($body.find('[role="dialog"]').length === 0) { + return + } + cy.get('body').type('{esc}') + cy.get('[role="dialog"]', { timeout: 10000 }).should('not.exist') + }) } setUserToOrganization(orgName: string) { - cy.contains('Join Group', { timeout: 15000 }) - .should('be.visible') - .click() - cy.get(this.joinGroupSearchInput, { timeout: 15000 }) - .should('be.visible') - .clear() - .type(orgName) - .type('{enter}') - cy.get(`input[data-testid="${orgName}-check"]`, { timeout: 15000 }) - .first() - .should('exist') - .click({ force: true }) - cy.get(this.joinButton, { timeout: 10000 }) - .should('be.visible') - .click() + const leaveSel = `[data-testid="leave-${orgName}"]` + + // Retries can leave the Join Groups modal open, which hides "Join Group". + this.dismissOpenDialog() + + cy.get('body').then(($body) => { + if ($body.find(leaveSel).length > 0) { + cy.log(`User already belongs to ${orgName}; skipping join`) + return + } + + cy.contains('button', 'Join Group', { timeout: 15000 }) + .should('be.visible') + .click() + + // Scope to the modal — Keycloak also keeps a hidden/duplicate search input in the page. + cy.get('[role="dialog"]', { timeout: 15000 }).should('be.visible') + cy.get('[role="dialog"] input[placeholder="Search group"]', { + timeout: 15000, + }) + .filter(':visible') + .first() + .should('be.visible') + .click() + .type('{selectall}{backspace}') + .type(orgName) + .type('{enter}') + + cy.get( + `[role="dialog"] input[data-testid="${orgName}-check"]`, + { timeout: 15000 } + ) + .first() + .should('exist') + .click({ force: true }) + + cy.get(`[role="dialog"] ${this.joinButton}`, { timeout: 10000 }) + .should('be.visible') + .and('not.be.disabled') + .click() + + cy.get('[role="dialog"]', { timeout: 10000 }).should('not.exist') + cy.get(leaveSel, { timeout: 15000 }).should('be.visible') + }) } leaveGroup(orgName: string) { @@ -67,6 +127,22 @@ class keycloakGroupPage { .should('be.visible') .click() } + + leaveGroupIfPresent(orgName: string) { + this.dismissOpenDialog() + cy.get('body').then(($body) => { + const sel = `[data-testid="leave-${orgName}"]` + if ($body.find(sel).length === 0) { + cy.log(`User is not in ${orgName}; skipping leave`) + return + } + cy.get(sel).should('be.visible').click() + cy.get(this.confirmButton, { timeout: 10000 }) + .should('be.visible') + .click() + cy.get(sel).should('not.exist') + }) + } } export default keycloakGroupPage diff --git a/e2e/cypress/pageObjects/keycloakUsers.ts b/e2e/cypress/pageObjects/keycloakUsers.ts index a33cd9c3e..89f08e048 100644 --- a/e2e/cypress/pageObjects/keycloakUsers.ts +++ b/e2e/cypress/pageObjects/keycloakUsers.ts @@ -1,18 +1,39 @@ class keycloakUsersPage { path: string = '/' - userSearchInput: string = '[data-testid="table-search-input"] input' + // Match Keycloak admin search inputs across Users/Groups table variants + userSearchInput: string = + '[data-testid="table-search-input"] input[type="search"], [data-testid="table-search-input"] input[type="text"], [data-testid="table-search-input"] input' userTab: string = '[data-ng-controller="UserTabCtrl"]' - private consoleUrl(): string { + private consoleRoot(): string { const base = Cypress.env('KEYCLOAK_URL') + return `${base}/auth/admin/master/console/` + } + + private usersHash(): string { const realm = Cypress.env('KEYCLOAK_REALM') || 'master' - return `${base}/auth/admin/master/console/#/${realm}/users` + return `#/${realm}/users` + } + + private consoleUrl(): string { + return `${this.consoleRoot()}${this.usersHash()}` } visitUsers() { - cy.visit(this.consoleUrl()) - cy.get(this.userSearchInput, { timeout: 20000 }).should('be.visible') + // Cypress skips reload when only the hash changes (e.g. Groups -> Users). + // Load the console root first, then set the users hash so the SPA navigates. + cy.visit(this.consoleRoot()) + cy.get('body', { timeout: 20000 }).should('be.visible') + cy.window().then((win) => { + if (win.location.hash !== this.usersHash()) { + win.location.hash = this.usersHash() + } + }) + cy.location('hash', { timeout: 20000 }).should('include', '/users') + cy.get(this.userSearchInput, { timeout: 20000 }) + .first() + .should('be.visible') } selectTab(tabName: string) { @@ -21,6 +42,7 @@ class keycloakUsersPage { editUser(userName: string) { cy.get(this.userSearchInput, { timeout: 20000 }) + .first() .should('be.visible') .clear() .type(userName) diff --git a/e2e/cypress/support/auth-commands.ts b/e2e/cypress/support/auth-commands.ts index c1334a891..b2eb1e44b 100644 --- a/e2e/cypress/support/auth-commands.ts +++ b/e2e/cypress/support/auth-commands.ts @@ -345,8 +345,9 @@ Cypress.Commands.add('logout', () => { Cypress.Commands.add('keycloakLogout', () => { cy.log('< Logging out') - cy.get('[data-testid=options-toggle]').click() - cy.contains('Sign out').click() + // Success/error toasts often cover the kebab menu after group membership changes + cy.get('[data-testid=options-toggle]').click({ force: true }) + cy.contains('Sign out').click({ force: true }) cy.log('> Logging out') }) diff --git a/e2e/cypress/tests/14-org-assignment/04-multiple-org-admin-org-unit.ts b/e2e/cypress/tests/14-org-assignment/04-multiple-org-admin-org-unit.ts index 746abbf35..ae4b424fb 100644 --- a/e2e/cypress/tests/14-org-assignment/04-multiple-org-admin-org-unit.ts +++ b/e2e/cypress/tests/14-org-assignment/04-multiple-org-admin-org-unit.ts @@ -31,83 +31,75 @@ describe('Give a user org admin access at organization unit level', () => { }) it('Add another org unit', () => { - const parentGroupName = 'ministry-of-health' + const parentPath = 'organization-admin/ca.bc.gov/ministry-of-health' const newGroupName = 'health-protection' - let authToken: string = '' - let parentGroupId: string = '' - let baseUrl: string = '' - - // Intercept API calls to capture Bearer token from request headers - cy.intercept('GET', '**/groups/**', (req) => { - if (req.headers['authorization']) { - const authHeader = req.headers['authorization'] as string - if (authHeader.startsWith('Bearer ')) { - authToken = authHeader.replace('Bearer ', '') - } + let authToken = '' + let baseUrl = '' + + // Capture admin bearer token from any admin API call + cy.intercept('GET', '**/admin/realms/**', (req) => { + const authHeader = req.headers['authorization'] + if ( + typeof authHeader === 'string' && + authHeader.startsWith('Bearer ') + ) { + authToken = authHeader.replace('Bearer ', '') + } + const baseUrlMatch = req.url.match(/^(https?:\/\/[^/]+)/) + if (baseUrlMatch) { + baseUrl = baseUrlMatch[1] } req.continue() - }).as('groupsApi') + }).as('adminApi') - // Navigate to groups and click on parent group to trigger API call + // Trigger an authenticated admin call from the Groups UI cy.get(groups.groupSearchInput, { timeout: 20000 }) + .first() .should('be.visible') .clear() - .type(parentGroupName) + .type('ministry-of-health') .type('{enter}') - cy.get('button', { timeout: 15000 }) - .contains(parentGroupName) - .should('be.visible') - .click() - - // Wait for API call and extract group ID and base URL from intercepted request - cy.wait('@groupsApi', { timeout: 10000 }).then((interception: any) => { - const url = interception.request.url - // Extract group ID from URL: /groups/{id}/children - const groupIdMatch = url.match(/\/groups\/([a-f0-9-]+)/) - if (groupIdMatch && groupIdMatch[1]) { - parentGroupId = groupIdMatch[1] - } - - // Extract base URL from intercepted request (e.g., http://keycloak.localtest.me:9081) - const baseUrlMatch = url.match(/^(https?:\/\/[^\/]+)/) - if (baseUrlMatch && baseUrlMatch[1]) { - baseUrl = baseUrlMatch[1] - } - }) + cy.wait('@adminApi', { timeout: 15000 }) - // Create the child group via API cy.then(() => { - if (!authToken) { - throw new Error('Could not retrieve Bearer token') - } - - if (!parentGroupId) { - throw new Error(`Could not find parent group ID for ${parentGroupName}`) - } - - if (!baseUrl) { - throw new Error('Could not extract base URL from intercepted request') - } - - // Construct API URL using base URL from intercepted request - const apiUrl = `${baseUrl}/auth/admin/realms/master/groups/${parentGroupId}/children` + expect(authToken, 'Keycloak admin bearer token').to.be.a('string').and + .not.be.empty + expect(baseUrl, 'Keycloak base URL').to.be.a('string').and.not.be.empty + // Resolve parent by path so we never create health-protection under the wrong group cy.request({ - method: 'POST', - url: apiUrl, + method: 'GET', + url: `${baseUrl}/auth/admin/realms/master/group-by-path/${parentPath}`, headers: { Accept: 'application/json', - 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, - body: { - name: newGroupName, - description: '', - }, - }).then((createResponse) => { - expect(createResponse.status).to.be.oneOf([200, 201]) - cy.log(`Successfully created group ${newGroupName} via API`) + }).then((parentRes) => { + expect(parentRes.status).to.eq(200) + const parentGroupId = parentRes.body.id + expect(parentGroupId, 'ministry-of-health group id').to.be.a('string') + + cy.request({ + method: 'POST', + url: `${baseUrl}/auth/admin/realms/master/groups/${parentGroupId}/children`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: { + name: newGroupName, + description: '', + }, + failOnStatusCode: false, + }).then((createResponse) => { + // 409 when the org unit already exists from a previous run + expect(createResponse.status).to.be.oneOf([200, 201, 409]) + cy.log( + `Create group ${newGroupName} under ${parentPath} -> ${createResponse.status}` + ) + }) }) }) }) @@ -127,7 +119,9 @@ describe('Give a user org admin access at organization unit level', () => { }) it('Leave existing org unit', () => { - groups.leaveGroup('ministry-of-health') + // From 02 Wendy is in ministry-of-health; on re-runs she may already be in health-protection + groups.leaveGroupIfPresent('ministry-of-health') + groups.leaveGroupIfPresent('health-protection') }) it('Set the user(Wendy) to the Organization Unit', () => {