diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js new file mode 100644 index 00000000..0cddadf6 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js @@ -0,0 +1,43 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query().insert( + samlAuthProviderParams(request) + ); + + renderObject(response, samlAuthProvider, { + serializer: 'AdminSamlAuthProvider', + status: 201, + }); +}; + +const samlAuthProviderParams = (request) => { + const { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + } = request.body; + + return { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js new file mode 100644 index 00000000..517b59d6 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js @@ -0,0 +1,78 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import createSamlAuthProviderMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/saml-auth-provider', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ key: 'admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the created saml auth provider', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const response = await request(app) + .post('/api/v1/admin/saml-auth-providers') + .set('Authorization', token) + .send(samlAuthProviderPayload) + .expect(201); + + const expectedPayload = await createSamlAuthProviderMock({ + id: response.body.data.id, + ...samlAuthProviderPayload, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const response = await request(app) + .post('/api/v1/admin/saml-auth-providers') + .set('Authorization', token) + .send({ + active: true, + name: 'Name', + issuer: 'theclientid', + signatureAlgorithm: 'invalid', + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + certificate: ["must have required property 'certificate'"], + entryPoint: ["must have required property 'entryPoint'"], + defaultRoleId: ["must have required property 'defaultRoleId'"], + signatureAlgorithm: ['must be equal to one of the allowed values'], + roleAttributeName: ['must be string'], + }, + meta: { type: 'ModelValidation' }, + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js new file mode 100644 index 00000000..bf678e95 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js @@ -0,0 +1,45 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query() + .patchAndFetchById( + request.params.samlAuthProviderId, + samlAuthProviderParams(request) + ) + .throwIfNotFound(); + + renderObject(response, samlAuthProvider, { + serializer: 'AdminSamlAuthProvider', + }); +}; + +const samlAuthProviderParams = (request) => { + const { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + } = request.body; + + return { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js new file mode 100644 index 00000000..f8c858f1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js @@ -0,0 +1,119 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import createSamlAuthProviderMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/saml-auth-provider/:samlAuthProviderId', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ key: 'admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the updated saml auth provider', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const samlAuthProvider = await createSamlAuthProvider( + samlAuthProviderPayload + ); + + const response = await request(app) + .patch(`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}`) + .set('Authorization', token) + .send({ + active: false, + name: 'Archived', + }) + .expect(200); + + const refetchedSamlAuthProvider = await samlAuthProvider.$query(); + + const expectedPayload = await createSamlAuthProviderMock({ + ...refetchedSamlAuthProvider, + name: 'Archived', + active: false, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const samlAuthProvider = await createSamlAuthProvider( + samlAuthProviderPayload + ); + + const response = await request(app) + .patch(`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}`) + .set('Authorization', token) + .send({ + active: 'true', + name: 123, + roleAttributeName: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ['must be string'], + active: ['must be boolean'], + roleAttributeName: ['must be string'], + }, + meta: { type: 'ModelValidation' }, + }); + }); + + it('should return not found response for not existing SAML auth provider UUID', async () => { + const notExistingSamlAuthProviderUUID = Crypto.randomUUID(); + + await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${notExistingSamlAuthProviderUUID}` + ) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .patch('/api/v1/admin/saml-auth-providers/invalidSamlAuthProviderUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/graphql/mutation-resolvers.js b/packages/backend/src/graphql/mutation-resolvers.js index 8752521d..38bb9f07 100644 --- a/packages/backend/src/graphql/mutation-resolvers.js +++ b/packages/backend/src/graphql/mutation-resolvers.js @@ -14,7 +14,6 @@ import updateCurrentUser from './mutations/update-current-user.js'; import updateFlowStatus from './mutations/update-flow-status.js'; import updateRole from './mutations/update-role.ee.js'; import updateStep from './mutations/update-step.js'; -import upsertSamlAuthProvider from './mutations/upsert-saml-auth-provider.ee.js'; import upsertSamlAuthProvidersRoleMappings from './mutations/upsert-saml-auth-providers-role-mappings.ee.js'; // Converted mutations @@ -45,7 +44,6 @@ const mutationResolvers = { updateRole, updateStep, updateUser, - upsertSamlAuthProvider, upsertSamlAuthProvidersRoleMappings, verifyConnection, }; diff --git a/packages/backend/src/graphql/mutations/upsert-saml-auth-provider.ee.js b/packages/backend/src/graphql/mutations/upsert-saml-auth-provider.ee.js deleted file mode 100644 index 382945b4..00000000 --- a/packages/backend/src/graphql/mutations/upsert-saml-auth-provider.ee.js +++ /dev/null @@ -1,30 +0,0 @@ -import SamlAuthProvider from '../../models/saml-auth-provider.ee.js'; - -const upsertSamlAuthProvider = async (_parent, params, context) => { - context.currentUser.can('create', 'SamlAuthProvider'); - - const samlAuthProviderPayload = { - ...params.input, - }; - - const existingSamlAuthProvider = await SamlAuthProvider.query() - .limit(1) - .first(); - - if (!existingSamlAuthProvider) { - const samlAuthProvider = await SamlAuthProvider.query().insert( - samlAuthProviderPayload - ); - - return samlAuthProvider; - } - - const samlAuthProvider = await SamlAuthProvider.query().patchAndFetchById( - existingSamlAuthProvider.id, - samlAuthProviderPayload - ); - - return samlAuthProvider; -}; - -export default upsertSamlAuthProvider; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 3990c1c0..0765d3ce 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -22,7 +22,6 @@ type Mutation { updateRole(input: UpdateRoleInput): Role updateStep(input: UpdateStepInput): Step updateUser(input: UpdateUserInput): User - upsertSamlAuthProvider(input: UpsertSamlAuthProviderInput): SamlAuthProvider upsertSamlAuthProvidersRoleMappings( input: UpsertSamlAuthProvidersRoleMappingsInput ): [SamlAuthProvidersRoleMapping] @@ -219,21 +218,6 @@ type Flow { status: FlowStatus } -type SamlAuthProvider { - id: String - name: String - certificate: String - signatureAlgorithm: String - issuer: String - entryPoint: String - firstnameAttributeName: String - surnameAttributeName: String - emailAttributeName: String - roleAttributeName: String - active: Boolean - defaultRoleId: String -} - type SamlAuthProvidersRoleMapping { id: String samlAuthProviderId: String @@ -265,20 +249,6 @@ input VerifyConnectionInput { id: String! } -input UpsertSamlAuthProviderInput { - name: String! - certificate: String! - signatureAlgorithm: String! - issuer: String! - entryPoint: String! - firstnameAttributeName: String! - surnameAttributeName: String! - emailAttributeName: String! - roleAttributeName: String! - defaultRoleId: String! - active: Boolean! -} - input UpsertSamlAuthProvidersRoleMappingsInput { samlAuthProviderId: String! samlAuthProvidersRoleMappings: [SamlAuthProviderRoleMappingInput] diff --git a/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js b/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js index 62f05859..57e333f8 100644 --- a/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js +++ b/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js @@ -2,6 +2,8 @@ import { Router } from 'express'; import { authenticateUser } from '../../../../helpers/authentication.js'; import { authorizeAdmin } from '../../../../helpers/authorization.js'; import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import createSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import updateSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js'; import getSamlAuthProvidersAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js'; import getSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js'; import getRoleMappingsAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js'; @@ -16,6 +18,14 @@ router.get( getSamlAuthProvidersAction ); +router.post( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createSamlAuthProviderAction +); + router.get( '/:samlAuthProviderId', authenticateUser, @@ -32,4 +42,12 @@ router.get( getRoleMappingsAction ); +router.patch( + '/:samlAuthProviderId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateSamlAuthProviderAction +); + export default router; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js new file mode 100644 index 00000000..2a2a7333 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js @@ -0,0 +1,29 @@ +const createSamlAuthProviderMock = async (samlAuthProvider) => { + const data = { + active: samlAuthProvider.active, + certificate: samlAuthProvider.certificate, + defaultRoleId: samlAuthProvider.defaultRoleId, + emailAttributeName: samlAuthProvider.emailAttributeName, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + id: samlAuthProvider.id, + issuer: samlAuthProvider.issuer, + name: samlAuthProvider.name, + roleAttributeName: samlAuthProvider.roleAttributeName, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'SamlAuthProvider', + }, + }; +}; + +export default createSamlAuthProviderMock; diff --git a/packages/web/src/graphql/mutations/upsert-saml-auth-provider.js b/packages/web/src/graphql/mutations/upsert-saml-auth-provider.js deleted file mode 100644 index 575c846d..00000000 --- a/packages/web/src/graphql/mutations/upsert-saml-auth-provider.js +++ /dev/null @@ -1,8 +0,0 @@ -import { gql } from '@apollo/client'; -export const UPSERT_SAML_AUTH_PROVIDER = gql` - mutation UpsertSamlAuthProvider($input: UpsertSamlAuthProviderInput) { - upsertSamlAuthProvider(input: $input) { - id - } - } -`; diff --git a/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js b/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js new file mode 100644 index 00000000..33fa7e79 --- /dev/null +++ b/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from 'helpers/api'; + +export default function useAdminCreateSamlAuthProvider() { + const queryClient = useQueryClient(); + + const query = useMutation({ + mutationFn: async (payload) => { + const { data } = await api.post(`/v1/admin/saml-auth-providers`, payload); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['admin', 'samlAuthProviders'], + }); + }, + }); + + return query; +} diff --git a/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js b/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js new file mode 100644 index 00000000..9e6600f4 --- /dev/null +++ b/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from 'helpers/api'; + +export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) { + const queryClient = useQueryClient(); + + const query = useMutation({ + mutationFn: async (payload) => { + const { data } = await api.patch( + `/v1/admin/saml-auth-providers/${samlAuthProviderId}`, + payload, + ); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['admin', 'samlAuthProviders'], + }); + }, + }); + + return query; +} diff --git a/packages/web/src/pages/Authentication/SamlConfiguration.jsx b/packages/web/src/pages/Authentication/SamlConfiguration.jsx index 6a99b6bb..719ee1e8 100644 --- a/packages/web/src/pages/Authentication/SamlConfiguration.jsx +++ b/packages/web/src/pages/Authentication/SamlConfiguration.jsx @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import { useMutation } from '@apollo/client'; import LoadingButton from '@mui/lab/LoadingButton'; import Stack from '@mui/material/Stack'; import MuiTextField from '@mui/material/TextField'; @@ -10,8 +9,9 @@ import ControlledAutocomplete from 'components/ControlledAutocomplete'; import Form from 'components/Form'; import Switch from 'components/Switch'; import TextField from 'components/TextField'; -import { UPSERT_SAML_AUTH_PROVIDER } from 'graphql/mutations/upsert-saml-auth-provider'; import useFormatMessage from 'hooks/useFormatMessage'; +import useAdminCreateSamlAuthProvider from 'hooks/useAdminCreateSamlAuthProvider'; +import useAdminUpdateSamlAuthProvider from 'hooks/useAdminUpdateSamlAuthProvider'; import useRoles from 'hooks/useRoles.ee'; const defaultValues = { @@ -38,42 +38,26 @@ function SamlConfiguration({ provider, providerLoading }) { const roles = data?.data; const enqueueSnackbar = useEnqueueSnackbar(); - const [upsertSamlAuthProvider, { loading }] = useMutation( - UPSERT_SAML_AUTH_PROVIDER, - ); + const { + mutateAsync: createSamlAuthProvider, + isPending: isCreateSamlAuthProviderPending, + } = useAdminCreateSamlAuthProvider(); - const handleProviderUpdate = async (providerDataToUpdate) => { + const { + mutateAsync: updateSamlAuthProvider, + isPending: isUpdateSamlAuthProviderPending, + } = useAdminUpdateSamlAuthProvider(provider?.id); + + const isPending = + isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending; + + const handleSubmit = async (providerData) => { try { - const { - name, - certificate, - signatureAlgorithm, - issuer, - entryPoint, - firstnameAttributeName, - surnameAttributeName, - emailAttributeName, - roleAttributeName, - active, - defaultRoleId, - } = providerDataToUpdate; - await upsertSamlAuthProvider({ - variables: { - input: { - name, - certificate, - signatureAlgorithm, - issuer, - entryPoint, - firstnameAttributeName, - surnameAttributeName, - emailAttributeName, - roleAttributeName, - active, - defaultRoleId, - }, - }, - }); + if (provider?.id) { + await updateSamlAuthProvider(providerData); + } else { + await createSamlAuthProvider(providerData); + } enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), { variant: 'success', @@ -91,10 +75,7 @@ function SamlConfiguration({ provider, providerLoading }) { } return ( -
+ {formatMessage('authenticationForm.save')} @@ -196,6 +177,7 @@ function SamlConfiguration({ provider, providerLoading }) { SamlConfiguration.propTypes = { provider: PropTypes.shape({ + id: PropTypes.string, active: PropTypes.bool, name: PropTypes.string, certificate: PropTypes.string,