From bbc11e68f7c7748abbe123feef0bedc03d2760da Mon Sep 17 00:00:00 2001 From: ike thecoder Date: Wed, 17 May 2023 14:04:02 -0700 Subject: [PATCH] Client Assertion (public key) maintenance (#808) Co-authored-by: Joshua Jones --- ...ttplocalhost4180devportalaccess-0c2f56.gql | 43 ++++ ...ttplocalhost4180devportalaccess-b680e8.gql | 9 + ...vportalapidirectorypreviewfalse-f0c654.gql | 63 ++++++ src/authz/matrix.csv | 1 + ...edentialRegenerate.ts => ServiceAccess.ts} | 30 ++- src/mocks/handlers.js | 10 + src/mocks/resolvers/consumers.js | 67 +++++++ src/mocks/resolvers/service-accounts.js | 109 ++++++++++- .../access-list/access-list-item.tsx | 1 + .../access-list/access-list-row.tsx | 68 ++++++- .../access-request-dialog.tsx | 4 + .../access-request-form.tsx | 64 ++++-- .../access-request-form/jwks-dialog.tsx | 183 ++++++++++++++++++ .../access-request-form/public-key-dialog.tsx | 183 ++++++++++++++++++ .../components/access-request-form/shared.ts | 16 ++ .../authentication-form.tsx | 3 +- .../components/copy-button/copy-button.tsx | 47 +++++ src/nextapp/components/copy-button/index.ts | 1 + src/nextapp/components/secret-text/index.ts | 1 + .../components/secret-text/secret-text.tsx | 22 +++ src/nextapp/pages/devportal/access/index.tsx | 4 + src/nextapp/shared/theme.ts | 14 ++ src/nextapp/shared/types/query.types.ts | 24 ++- src/server.ts | 6 +- .../keycloak/client-registration-service.ts | 6 +- src/services/keycloak/client-service.ts | 22 +++ src/services/keystone/types.ts | 24 ++- src/services/utils.ts | 16 ++ src/services/workflow/generate-credential.ts | 19 +- src/services/workflow/index.ts | 2 + src/services/workflow/types.ts | 5 + src/services/workflow/update-credential.ts | 132 +++++++++++++ .../workflow/validate-access-request.ts | 23 ++- src/test/integrated/keystonejs/init.ts | 2 +- .../integrated/workflow/update-credential.ts | 63 ++++++ 35 files changed, 1241 insertions(+), 46 deletions(-) create mode 100644 src/authz/graphql-whitelist/httplocalhost4180devportalaccess-0c2f56.gql create mode 100644 src/authz/graphql-whitelist/httplocalhost4180devportalaccess-b680e8.gql create mode 100644 src/authz/graphql-whitelist/httplocalhost4180devportalapidirectorypreviewfalse-f0c654.gql rename src/lists/extensions/{CredentialRegenerate.ts => ServiceAccess.ts} (84%) create mode 100644 src/nextapp/components/access-request-form/jwks-dialog.tsx create mode 100644 src/nextapp/components/access-request-form/public-key-dialog.tsx create mode 100644 src/nextapp/components/access-request-form/shared.ts create mode 100644 src/nextapp/components/copy-button/copy-button.tsx create mode 100644 src/nextapp/components/copy-button/index.ts create mode 100644 src/nextapp/components/secret-text/index.ts create mode 100644 src/nextapp/components/secret-text/secret-text.tsx create mode 100644 src/services/workflow/update-credential.ts create mode 100644 src/test/integrated/workflow/update-credential.ts diff --git a/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-0c2f56.gql b/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-0c2f56.gql new file mode 100644 index 000000000..8e4dda89f --- /dev/null +++ b/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-0c2f56.gql @@ -0,0 +1,43 @@ + + query GetMyServiceAccesses { + myServiceAccesses(where: { productEnvironment_is_null: false }) { + id + name + active + credentialReference + application { + name + } + productEnvironment { + id + name + flow + product { + id + name + } + credentialIssuer { + clientAuthenticator + } + } + } + myAccessRequests( + where: { productEnvironment_is_null: false, serviceAccess_is_null: true } + ) { + id + application { + name + } + productEnvironment { + id + name + product { + id + name + } + } + isComplete + isApproved + isIssued + } + } diff --git a/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-b680e8.gql b/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-b680e8.gql new file mode 100644 index 000000000..02535261c --- /dev/null +++ b/src/authz/graphql-whitelist/httplocalhost4180devportalaccess-b680e8.gql @@ -0,0 +1,9 @@ + + mutation UpdateServiceAccessCredential( + $id: ID! + $controls: CredentialReferenceUpdateInput + ) { + updateServiceAccessCredential(id: $id, controls: $controls) { + credential + } + } diff --git a/src/authz/graphql-whitelist/httplocalhost4180devportalapidirectorypreviewfalse-f0c654.gql b/src/authz/graphql-whitelist/httplocalhost4180devportalapidirectorypreviewfalse-f0c654.gql new file mode 100644 index 000000000..cb770256f --- /dev/null +++ b/src/authz/graphql-whitelist/httplocalhost4180devportalapidirectorypreviewfalse-f0c654.gql @@ -0,0 +1,63 @@ + + query GetAccessRequestForm($id: ID!) { + allProductsByNamespace(where: { id: $id }) { + id + name + environments { + id + approval + name + active + flow + additionalDetailsToRequest + legal { + title + description + link + reference + } + credentialIssuer { + clientAuthenticator + } + } + } + allDiscoverableProducts(where: { id: $id }) { + id + name + environments { + id + approval + name + active + flow + additionalDetailsToRequest + legal { + title + description + link + reference + } + credentialIssuer { + clientAuthenticator + } + } + } + myApplications { + id + appId + name + owner { + name + } + } + mySelf { + legalsAgreed + } + allTemporaryIdentities { + id + userId + name + providerUsername + email + } + } diff --git a/src/authz/matrix.csv b/src/authz/matrix.csv index 5a0c1ac28..19bfab619 100644 --- a/src/authz/matrix.csv +++ b/src/authz/matrix.csv @@ -200,6 +200,7 @@ API Owner Role - All Fields,,,,,read,,*,,,,"api-owner,provider-user",allow, API Owner Role - All Fields,,,,,"update,create",,*,,api-owner,,,allow, Portal User,,DiscoverableProduct,,,,,,,portal-user,,,allow, Portal User,,myServiceAccesses,,,,,,,portal-user,,,allow,filterByAppOwner +Portal User,,updateServiceAccessCredential,,,,,,,portal-user,,,allow,filterByAppOwner Portal User,,regenerateCredentials,,,,,,,portal-user,,,allow,filterByAppOwner Portal User,,myApplications,,,,,,,portal-user,,,allow,filterByOwner API Owner Role Rules,,allGatewayServicesByNamespace,,,,,,,,,"api-owner,provider-user",allow,filterByUserNS diff --git a/src/lists/extensions/CredentialRegenerate.ts b/src/lists/extensions/ServiceAccess.ts similarity index 84% rename from src/lists/extensions/CredentialRegenerate.ts rename to src/lists/extensions/ServiceAccess.ts index 4d12b09e9..df80e60da 100644 --- a/src/lists/extensions/CredentialRegenerate.ts +++ b/src/lists/extensions/ServiceAccess.ts @@ -5,21 +5,47 @@ import { lookupCredentialReferenceByServiceAccess, } from '../../services/keystone'; import { EnforcementPoint } from '../../authz/enforcement'; -import { KeycloakClientService } from '../../services/keycloak'; +import { + ClientAuthenticator, + KeycloakClientService, +} from '../../services/keycloak'; import { CredentialReference, NewCredential, } from '../../services/workflow/types'; import { getEnvironmentContext } from '../../services/workflow/get-namespaces'; import { replaceApiKey } from '../../services/workflow/kong-api-key-replace'; +import { strict as assert } from 'assert'; +import { UpdateCredentials } from '../../services/workflow'; + +const typeCredentialReferenceUpdateInput = ` +input CredentialReferenceUpdateInput { + clientCertificate: String, + jwksUrl: String +} +`; module.exports = { extensions: [ (keystone: any) => { keystone.extendGraphQLSchema({ - types: [], + types: [{ type: typeCredentialReferenceUpdateInput }], queries: [], mutations: [ + { + schema: + 'updateServiceAccessCredential(id: ID!, controls: CredentialReferenceUpdateInput): AccessRequest', + resolver: async ( + item: any, + args: any, + context: any, + info: any, + { query, access }: any + ) => { + return await UpdateCredentials(context, args.id, args.controls); + }, + access: EnforcementPoint, + }, { schema: 'regenerateCredentials(id: ID!): AccessRequest', resolver: async ( diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 09fa395e4..40ec6ddc8 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -20,6 +20,7 @@ import { fullfillRequestHandler, gatewayServicesHandler, getAccessRequestsHandler, + getAccessRequestForm, getAllConsumerGroupLabelsHandler, getConsumersHandler, getConsumerHandler, @@ -73,6 +74,8 @@ import { handleAllDatasets } from './resolvers/datasets'; import { createServiceAccountHandler, getAllServiceAccountsHandler, + updateMyServiceAccessHandlers, + getMyServiceAccessHandlers, } from './resolvers/service-accounts'; import { allServicesHandler, @@ -229,6 +232,7 @@ export const handlers = [ keystone.query('GetAccessRequests', getAccessRequestsHandler), keystone.query('GetConsumerEditDetails', getConsumerProdEnvAccessHandler), keystone.query('GetAccessRequestAuth', accessRequestAuthHandler), + keystone.query('GetAccessRequestForm', getAccessRequestForm), keystone.query('GetFilterConsumers', getConsumersFilterHandler), keystone.query('GetAllConsumerGroupLabels', getAllConsumerGroupLabelsHandler), keystone.query('GetControlContent', gatewayServicesHandler), @@ -259,6 +263,7 @@ export const handlers = [ // Applications keystone.query('MyApplications', allApplicationsHandler), keystone.query('GetApplicationServices', getApplicationServicesHandler), + keystone.query('ApplicationSelectApplications', allApplicationsHandler), keystone.mutation('AddApplication', createApplicationHandler), keystone.mutation('UpdateApplication', updateApplicationHandler), keystone.mutation('RemoveApplication', removeApplicationHandler), @@ -268,6 +273,11 @@ export const handlers = [ keystone.query('GetMetrics', getMetricsHandler), // Service accounts keystone.query('GetAllServiceAccounts', getAllServiceAccountsHandler), + keystone.query('GetMyServiceAccesses', getMyServiceAccessHandlers), + keystone.mutation( + 'UpdateServiceAccessCredential', + updateMyServiceAccessHandlers + ), keystone.query('GetGatewayService', getGatewayServiceHandler), keystone.query('GetGatewayServiceFilters', getGatewayServiceFilterHandler), keystone.mutation('CreateServiceAccount', createServiceAccountHandler), diff --git a/src/mocks/resolvers/consumers.js b/src/mocks/resolvers/consumers.js index 629faf08a..f1272518d 100644 --- a/src/mocks/resolvers/consumers.js +++ b/src/mocks/resolvers/consumers.js @@ -607,3 +607,70 @@ export const getAllConsumerGroupLabelsHandler = (_, res, ctx) => { }) ); }; + +export const getAccessRequestForm = (_, res, ctx) => { + return res( + ctx.data({ + allProductsByNamespace: [], + allDiscoverableProducts: [ + { + id: 'p1', + name: 'eRX Demo API', + environments: [ + { + id: 'e1', + approval: true, + name: 'dev', + active: true, + flow: 'kong-api-key-acl', + additionalDetailsToRequest: '', + legal: { + title: 'Terms of Use for API Gateway', + description: null, + link: + 'https://www2.gov.bc.ca/gov/content/data/open-data/api-terms-of-use-for-ogl-information', + reference: 'terms-of-use-for-api-gateway-1', + }, + credentialIssuer: null, + }, + { + id: 'e2', + approval: true, + name: 'prod', + active: true, + flow: 'client-credentials', + additionalDetailsToRequest: '', + legal: null, + credentialIssuer: { + clientAuthenticator: 'client-jwt-jwks-url', + }, + }, + ], + }, + ], + myApplications: [ + { + id: '111', + appId: 'appID1111', + name: 'Demo App', + owner: { + name: 'XT:Jones, Joshua CITZ:IN', + }, + }, + ], + mySelf: { + legalsAgreed: + '[{"reference":"terms-of-use-for-api-gateway-1","agreedTimestamp":"2023-05-01T18:02:22.973Z"}]', + }, + allTemporaryIdentities: [ + { + id: 'temp1', + userId: '2', + name: 'XT:Jones, Joshua CITZ:IN', + providerUsername: 'JOSHJONE', + email: 'joshua@general-metrics.com', + }, + ], + }) + ); +}; diff --git a/src/mocks/resolvers/service-accounts.js b/src/mocks/resolvers/service-accounts.js index 0720c6dd6..8e26afaea 100644 --- a/src/mocks/resolvers/service-accounts.js +++ b/src/mocks/resolvers/service-accounts.js @@ -22,7 +22,7 @@ const allNamespaceServiceAccounts = [ createdAt: '2021-07-10T18:09:11.555Z', }, ]; -export const getAllServiceAccountsHandler = (req, res, ctx) => { +export const getAllServiceAccountsHandler = (_, res, ctx) => { return res( ctx.data({ allNamespaceServiceAccounts, @@ -36,7 +36,7 @@ export const getAllServiceAccountsHandler = (req, res, ctx) => { ); }; -export const createServiceAccountHandler = (req, res, ctx) => { +export const createServiceAccountHandler = (_, res, ctx) => { const record = { id: (allNamespaceServiceAccounts.length + 1).toString(), name: `sa-sampler-${uuidv4()}`, @@ -60,3 +60,108 @@ export const createServiceAccountHandler = (req, res, ctx) => { }) ); }; + +export const getMyServiceAccessHandlers = (_, res, ctx) => { + return res( + ctx.data({ + myAccessRequests: [ + { + id: '1581', + name: 'BE2BD769-5BDA36EB527', + active: false, + application: { + name: 'Demo App', + }, + productEnvironment: { + id: '175', + name: 'dev', + flow: 'kong-api-key-acl', + product: { + id: '166', + name: 'eRX Demo API', + }, + credentialIssuer: { + clientAuthenticator: 'client-jwt-jwks-url', + }, + }, + }, + ], + myServiceAccesses: [ + { + id: '111', + name: 'aiofja90sdfja0s9fj', + active: true, + application: { + name: 'Shoppers Drug Mart 123', + }, + productEnvironment: { + id: '175', + name: 'dev', + flow: 'client-jwt-jwks-url', + product: { + id: '166', + name: 'Demo App', + }, + credentialIssuer: { + clientAuthenticator: 'client-jwt-jwks-url', + }, + }, + credentialReference: JSON.stringify({ + id: 'asldf-asdf-asdf', + clientId: 'CLIENT_ID_123', + clientCertificate: null, + jwksUrl: 'https://example.com/.well-known/jwks.json', + }), + controls: JSON.stringify({ + clientGenCertificate: true, + jwksUrl: 'https://example.com/.well-known/jwks.json', + }), + isComplete: true, + isApproved: true, + isIssued: true, + }, + { + id: '211', + name: 'asf9oas0f9jwf', + active: true, + application: { + name: 'Shoppers Drug Mart 456', + }, + productEnvironment: { + id: '175', + name: 'dev', + flow: 'client-jwt-jwks-url', + product: { + id: '166', + name: 'Demo App', + }, + credentialIssuer: { + clientAuthenticator: 'client-jwt-jwks-url', + }, + }, + credentialReference: JSON.stringify({ + id: 'asldf-asdf-asdf', + clientId: 'CLIENT_ID_123', + clientCertificate: `-----BEGIN PUBLIC KEY----- +asdf0jas0dfja0sdfj0as9dfja0sd9fj09sdfj0asjdf0as9jf09asjf0a9sjfa0s9djf0asdjf0asdjf0asdjy/aosdf0oaisdfj0a9sdfj+0a9sdf0a9jfoasdjf0asdjf+a0osdifja-sdfj+)9uasdfjlaojksdjfoasdfj+oajsd0fiajsd0f/laksdjflasdkfj
2Q1AGNYP8cZOQ9NzNnIYsGTsHw8GvDn6l/b1N+aklsjdfoasdjf0oaisdjf0asidfj/asodfj0asidfj.asdf0jasdf/asodifjas0d-pfj/asddofijas0dfj/asdopija0opsdjB +-----END PUBLIC KEY-----`, + jwksUrl: null, + }), + controls: JSON.stringify({ + clientGenCertificate: true, + publicKey: `-----BEGIN PUBLIC KEY----- +asdf0jas0dfja0sdfj0as9dfja0sd9fj09sdfj0asjdf0as9jf09asjf0a9sjfa0s9djf0asdjf0asdjf0asdjy/aosdf0oaisdfj0a9sdfj+0a9sdf0a9jfoasdjf0asdjf+a0osdifja-sdfj+)9uasdfjlaojksdjfoasdfj+oajsd0fiajsd0f/laksdjflasdkfj
2Q1AGNYP8cZOQ9NzNnIYsGTsHw8GvDn6l/b1N+aklsjdfoasdjf0oaisdjf0asidfj/asodfj0asidfj.asdf0jasdf/asodifjas0d-pfj/asddofijas0dfj/asdopija0opsdjB +-----END PUBLIC KEY-----`, + }), + isComplete: true, + isApproved: true, + isIssued: true, + }, + ], + }) + ); +}; + +export const updateMyServiceAccessHandlers = (req, res, ctx) => { + return res(ctx.data({})); +}; diff --git a/src/nextapp/components/access-list/access-list-item.tsx b/src/nextapp/components/access-list/access-list-item.tsx index e69b5d965..e1e2fdf75 100644 --- a/src/nextapp/components/access-list/access-list-item.tsx +++ b/src/nextapp/components/access-list/access-list-item.tsx @@ -88,6 +88,7 @@ const AccessListItem: React.FC = ({ Status Environments Application + Client ID diff --git a/src/nextapp/components/access-list/access-list-row.tsx b/src/nextapp/components/access-list/access-list-row.tsx index 3a60ce315..03c00ef75 100644 --- a/src/nextapp/components/access-list/access-list-row.tsx +++ b/src/nextapp/components/access-list/access-list-row.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { + Flex, Icon, IconButton, Menu, @@ -12,12 +13,16 @@ import { useDisclosure, } from '@chakra-ui/react'; import has from 'lodash/has'; +import { IoCopy } from 'react-icons/io5'; import type { ServiceAccess, AccessRequest } from '@/shared/types/query.types'; import AccessStatus from './access-status'; import GenerateCredentialsDialog from '../access-request-form/generate-credentials-dialog'; import RegenerateCredentialsDialog from '../access-request-form/regenerate-credentials-dialog'; import { IoEllipsisHorizontal } from 'react-icons/io5'; +import JwksDialog from '../access-request-form/jwks-dialog'; +import PublicKeyDialog from '../access-request-form/public-key-dialog'; +import SecretText from '../secret-text'; interface AccessListRowProps { data: AccessRequest & ServiceAccess; @@ -31,6 +36,8 @@ const AccessListRow: React.FC = ({ onRevoke, }) => { const { isOpen, onClose, onOpen } = useDisclosure(); + const jwksDialog = useDisclosure(); + const publicKeyDialog = useDisclosure(); const handleRevoke = React.useCallback( (id, isRequest) => async (event: React.MouseEvent) => { event.preventDefault(); @@ -40,17 +47,20 @@ const AccessListRow: React.FC = ({ }, [onRevoke] ); + const controls = data?.credentialReference + ? JSON.parse(data.credentialReference) + : {}; return ( - + - + = ({ {data.productEnvironment?.name} - {data.application?.name} + {data.application?.name} + + {data.name} + {(has(data, 'isApproved') || has(data, 'isIssued') || @@ -72,6 +85,22 @@ const AccessListRow: React.FC = ({ isOpen={isOpen} onClose={onClose} /> + + = ({ 'kong-api-key-only', 'kong-api-key-acl', 'client-credentials', - ].includes(data.productEnvironment.flow) && ( - Regenerate Credentials - )} + ].includes(data.productEnvironment.flow) || + (data.productEnvironment?.credentialIssuer + ?.clientAuthenticator === 'client-secret' && ( + + Regenerate Credentials + + ))} + {data.productEnvironment?.credentialIssuer?.clientAuthenticator === + 'client-jwt-jwks-url' && + controls.jwksUrl && ( + + Update JWKS URL + + )} + {data.productEnvironment?.credentialIssuer?.clientAuthenticator === + 'client-jwt-jwks-url' && + controls.clientCertificate && ( + + Update Public Key + + )} = ({ formData.get('clientAuthenticator') === 'client-jwt-jwks-url' ? formData.get('jwksUrl') : '', + clientCertificate: + formData.get('clientAuthenticator') === 'client-jwt-jwks-url' + ? formData.get('clientCertificate') + : '', }), requestor: formData.get('requestor'), applicationId: formData.get('applicationId'), diff --git a/src/nextapp/components/access-request-form/access-request-form.tsx b/src/nextapp/components/access-request-form/access-request-form.tsx index 76beefc28..ff6083e46 100644 --- a/src/nextapp/components/access-request-form/access-request-form.tsx +++ b/src/nextapp/components/access-request-form/access-request-form.tsx @@ -22,6 +22,7 @@ import { Environment } from '@/shared/types/query.types'; import Fieldset from './access-request-fieldset'; import ApplicationSelect from './application-select'; +import { publicKeyPlaceholder } from './shared'; interface AccessRequestFormProps { id: string; @@ -39,6 +40,7 @@ const AccessRequestForm: React.FC = ({ variables: { id }, }); const [environment, setEnvironment] = React.useState(''); + const [authMethod, setAuthMethod] = React.useState('publicKey'); const itemList = preview ? data.allProductsByNamespace : data.allDiscoverableProducts; @@ -112,17 +114,55 @@ const AccessRequestForm: React.FC = ({ {clientAuthenticator === 'client-jwt-jwks-url' && ( - Public Key URL - - A URL to a JWKS formatted document for signed JWT authentication - - + + + + Public Key + + {authMethod === 'publicKey' && ( + + + Enter the public key for authentication. + +