From 4e071e861c5af399dc24806487cfa99b416a22e1 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Fri, 9 Feb 2024 14:49:49 -0500 Subject: [PATCH] Webapp - Outgoing OAuth Connections (#25507) * added store * make generated * add missing license headers * fix receiver name * i18n * i18n sorting * update migrations from master * make migrations-extract * update retrylayer tests * replaced sql query with id pagination * fixed flaky tests * missing columns * missing columns on save/update * typo * improved tests * remove enum from mysql colum * add password credentials to store * license changes * OAuthOutgoingConnectionInterface * Oauth -> OAuth * make generated * copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections * merge migrations * renamed migrations * model change suggestions * refactor test functionsn * migration typo * refactor store table names * updated sanitize test * cleanup merge * refactor symbol * "installed outgoing oauth connections" page works * move things into a nested folder * add and edit page stubs work * list endpoint * oauthoutgoingconnection -> outgoingoauthconnection * signature change * i18n update * granttype typo * naming * api list * uppercase typo * i18n * missing license header * fixed path in comments * updated openapi definitions * changes to support selecting command request url * sanitize connections * make generated * test license and no feature flag * removed t.fatal * updated testhelper calls * yaml schema fixes * switched interface name * suggested translation * missing i18n translation * management permission * moved permission initalization to proper place * endpoints * put tests * error check typo * fixed specific enttity urls * tests * read permission check * updated openapi definitions * i18n * GetConnectionByAudience method * notes * replaced GetConnectionsByAudience with a filter * added custom oauth token object * updated interface and usage * properly set enterprise interface * move retrieval logic to impl * webhook tests * translations * i18n: updates * address comments * endpoint and tests * i18n * api docs * fixed endpoint path * sq.like * use filter object instead of parameters * set url values if not empty * typos * converted some components to function components, and move around files * correctly check token url * restore flag to previous value * added command oauth handler * update enterprise imports * migrate last component to function component * Added enterprise import * refactor permissions and add necessary webapp code * Check correct flag in permission tree * allow partial updates * sort i18n webapp * missing test modification * fixed webapp i18n sorting * allow validating stored connections * added missing translation * fix finished adding connection link and text on result page * added missing permission to smoke tests * missing role in smoke test * updated translations * updated translations * support editing client secret on existing connection * fix some i18n strings * updated translations * better error messages * progress on using react select for command request url while maintaining typed in value * remove writeheader, test * HasValidGrantType * end early to avoid nil pointer errors * move slash command request url input box into its own component * wrap components related to oauth connections in config check * fix tests * i18n-extract * change some i18n strings to say "Outgoing OAuth 2.0 Connections" * remove debug code * fixed i18n * updated i18n file * feature configuration backend * typo * add system console setting * Revert "typo" This reverts commit 669da23e8ee47525ccaa6f59cbbd20bf8a121191. * Revert "updated i18n file" This reverts commit d0882c0dd7587533f0d0f7a7b7b190684186158a. * Revert "fixed i18n" This reverts commit 3108866bc19139182dfd094921c56cdefc4695ea. * fixed i18n * updated i18n file * typo * updated i18n * updated i18n * updated i18n * updated version to 9.6 * replace feature flag with system console configuration * i18n * updated tests * pr feedback * fix styling of disabled text box * fix styling of action links in integration console * server changes for validation feature * webapp changes for validation feature * pencil icon styling * styling fixes for oauth audience correct configuration message * fix sanitize test * remove max lengths from outgoing oauth connection form * use config var in webapp instead of feature flag * change asterisks to bullets * update api docs for validate endpoint * feedback from ux review * fix lint, types, tests * fix stylelint * implement validation button under the token url input * support wildcard for matching audience urls * updates for styling * update snapshots * add doc links for the outgoing oauth connections feature * change doc links to use permalink * add docs link to system console * fix: use limitedreader in json decoding * fix: form error in validation * management permission can read now * updated api documentation * doc typo * require one permission to read only * fix api connection list audience filter * fix audience matching and add loading indicator * fix team permissions on outgoing oauth connection api calls * fix api doc and test, for adding team id to query params * handle read permissions by adding a team in the payload * missing teamid query parameter in test * change validate button logic to not require audience urls to be filled out * fix redux type --------- Co-authored-by: Felipe Martin --- api/v4/source/definitions.yaml | 33 + api/v4/source/outgoing_oauth_connections.yaml | 169 +- e2e-tests/cypress/tests/support/api/role.js | 4 +- server/channels/api4/api.go | 2 +- .../api4/outgoing_oauth_connection.go | 297 ++- .../api4/outgoing_oauth_connection_test.go | 1386 ++++++++++- server/channels/app/channels.go | 18 +- server/channels/app/command.go | 26 +- server/channels/app/permissions_migrations.go | 16 + server/channels/app/server.go | 4 + .../app/slashcommands/command_test.go | 47 + server/channels/app/webhook.go | 28 +- server/channels/app/webhook_test.go | 29 +- .../outgoing_oauth_connection_store.go | 37 +- .../storetest/outgoing_oauth_connection.go | 172 ++ server/channels/testlib/store.go | 1 + server/cmd/mattermost/commands/test.go | 1 + server/config/client.go | 1 + .../mocks/OutgoingOAuthConnectionInterface.go | 56 + .../einterfaces/outgoing_oauth_connection.go | 3 + server/enterprise/external_imports.go | 2 + server/i18n/en.json | 82 +- .../platform/services/telemetry/telemetry.go | 1 + server/public/model/client4.go | 53 +- server/public/model/config.go | 5 + server/public/model/feature_flags.go | 3 - server/public/model/migration.go | 1 + .../public/model/outgoing_oauth_connection.go | 85 +- .../model/outgoing_oauth_connection_test.go | 84 +- server/public/model/permission.go | 10 + server/public/model/role.go | 1 + .../src/actions/integration_actions.tsx | 33 +- .../admin_console/admin_definition.tsx | 13 + .../permissions_tree/permissions_tree.tsx | 4 + .../strings/permissions.tsx | 10 + .../backstage/backstage_controller.tsx | 24 +- .../components/backstage_sidebar.test.tsx | 30 +- .../components/backstage_sidebar.tsx | 26 +- .../src/components/backstage/index.ts | 4 +- .../abstract_command.test.tsx.snap | 57 +- .../installed_command.test.tsx.snap | 72 +- .../integrations/abstract_command.tsx | 23 +- .../abstract_outgoing_webhook.test.tsx | 1 - .../src/components/integrations/bots/bot.tsx | 1 + .../confirm_integration.test.tsx.snap | 111 + .../confirm_integration.test.tsx | 22 +- .../confirm_integration.tsx | 95 +- .../integrations/confirm_integration/index.ts | 3 +- .../delete_integration_link.tsx | 22 +- .../__snapshots__/edit_command.test.tsx.snap | 4 +- .../edit_command/edit_command.tsx | 1 + .../edit_incoming_webhook.tsx | 2 - .../edit_oauth_app.test.tsx.snap | 8 +- .../edit_oauth_app/edit_oauth_app.tsx | 1 + .../edit_outgoing_webhook.test.tsx.snap | 6 +- .../edit_outgoing_webhook.tsx | 1 + .../src/components/integrations/index.ts | 2 + .../integrations/installed_command.tsx | 10 +- .../components/integrations/integrations.tsx | 30 + ...ct_outgoing_oauth_connection.test.tsx.snap | 1157 +++++++++ ...dd_outgoing_oauth_connection.test.tsx.snap | 559 +++++ ...it_outgoing_oauth_connection.test.tsx.snap | 2162 +++++++++++++++++ ...ed_outgoing_oauth_connection.test.tsx.snap | 137 ++ ...d_outgoing_oauth_connections.test.tsx.snap | 260 ++ ...th_connection_audience_input.test.tsx.snap | 417 ++++ ...bstract_outgoing_oauth_connection.test.tsx | 140 ++ .../abstract_outgoing_oauth_connection.tsx | 632 +++++ .../add_outgoing_oauth_connection.test.tsx | 64 + .../add_outgoing_oauth_connection.tsx | 57 + .../edit_outgoing_oauth_connection.test.tsx | 140 ++ .../edit_outgoing_oauth_connection.tsx | 150 ++ ...stalled_outgoing_oauth_connection.test.tsx | 56 + .../installed_outgoing_oauth_connection.tsx | 197 ++ ...talled_outgoing_oauth_connections.test.tsx | 97 + .../installed_outgoing_oauth_connections.tsx | 150 ++ .../oauth_connection_audience_input.test.tsx | 161 ++ .../oauth_connection_audience_input.tsx | 151 ++ webapp/channels/src/i18n/en.json | 67 +- .../src/images/outgoing_oauth_connection.png | Bin 0 -> 18886 bytes .../src/action_types/integrations.ts | 3 + .../src/actions/integrations.ts | 90 +- .../src/constants/permissions.ts | 1 + .../src/reducers/entities/integrations.ts | 32 +- .../src/selectors/entities/integrations.ts | 4 + .../src/store/initial_state.ts | 1 + .../channels/src/sass/components/_forms.scss | 3 +- .../channels/src/sass/routes/_backstage.scss | 73 +- webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 65 +- webapp/platform/types/src/config.ts | 1 + webapp/platform/types/src/integrations.ts | 16 + 91 files changed, 10116 insertions(+), 201 deletions(-) create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/abstract_outgoing_oauth_connection.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/add_outgoing_oauth_connection.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/edit_outgoing_oauth_connection.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connection.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/oauth_connection_audience_input.test.tsx.snap create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/abstract_outgoing_oauth_connection.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/abstract_outgoing_oauth_connection.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/add_outgoing_oauth_connection.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/add_outgoing_oauth_connection.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/edit_outgoing_oauth_connection.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/edit_outgoing_oauth_connection.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connections.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connections.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/oauth_connection_audience_input.test.tsx create mode 100644 webapp/channels/src/components/integrations/outgoing_oauth_connections/oauth_connection_audience_input.tsx create mode 100644 webapp/channels/src/images/outgoing_oauth_connection.png diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 22120ab5d1..6ca07dbc43 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -53,6 +53,12 @@ components: application/json: schema: $ref: "#/components/schemas/AppError" + BadGateway: + description: Bad gateway + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" schemas: User: type: object @@ -3604,6 +3610,33 @@ components: audiences: description: The audiences of the outgoing OAuth connection. type: string + OutgoingOAuthConnectionPostItem: + type: object + properties: + name: + description: The name of the outgoing OAuth connection. + type: string + client_id: + description: The client ID of the outgoing OAuth connection. + type: string + client_secret: + description: The client secret of the outgoing OAuth connection. + type: string + credentials_username: + description: The username of the credentials of the outgoing OAuth connection. + type: string + credentials_password: + description: The password of the credentials of the outgoing OAuth connection. + type: string + oauth_token_url: + description: The OAuth token URL of the outgoing OAuth connection. + type: string + grant_type: + description: The grant type of the outgoing OAuth connection. + type: string + audiences: + description: The audiences of the outgoing OAuth connection. + type: string externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/outgoing_oauth_connections.yaml b/api/v4/source/outgoing_oauth_connections.yaml index c599dd7dbb..1006fa433e 100644 --- a/api/v4/source/outgoing_oauth_connections.yaml +++ b/api/v4/source/outgoing_oauth_connections.yaml @@ -8,8 +8,15 @@ description: > List all outgoing OAuth connections. - __Minimum server version__: 9.5 + __Minimum server version__: 9.6 operationId: ListOutgoingOAuthConnections + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string responses: "200": description: Successfully fetched outgoing OAuth connections @@ -25,6 +32,45 @@ $ref: "#/components/responses/InternalServerError" "501": $ref: "#/components/responses/NotImplemented" + post: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: Create a connection + description: > + Create an outgoing OAuth connection. + + __Minimum server version__: 9.6 + operationId: CreateOutgoingOAuthConnection + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string + requestBody: + description: Outgoing OAuth connection to create + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionPostItem" + responses: + "201": + description: Successfully created outgoing OAuth connection + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionGetItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" /api/v4/oauth/outgoing_connections/{connection_id}: get: tags: @@ -35,8 +81,15 @@ description: > Retrieve an outgoing OAuth connection. - __Minimum server version__: 9.5 + __Minimum server version__: 9.6 operationId: GetOutgoingOAuthConnection + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string responses: "200": description: Successfully fetched outgoing OAuth connection @@ -50,3 +103,115 @@ $ref: "#/components/responses/InternalServerError" "501": $ref: "#/components/responses/NotImplemented" + put: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: Update a connection + description: > + Update an outgoing OAuth connection. + + __Minimum server version__: 9.6 + operationId: UpdateOutgoingOAuthConnection + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string + requestBody: + description: Outgoing OAuth connection to update + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionPostItem" + responses: + "200": + description: Successfully updated outgoing OAuth connection + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionGetItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + delete: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: Delete a connection + description: > + Delete an outgoing OAuth connection. + + __Minimum server version__: 9.6 + operationId: DeleteOutgoingOAuthConnection + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string + responses: + "200": + description: Successfully deleted outgoing OAuth connection + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/oauth/outgoing_connections/validate: + post: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: Validate a connection configuration + description: > + Validate an outgoing OAuth connection. If an id is provided in the payload, and no client secret is provided, then the stored client secret is implicitly used for the validation. + + __Minimum server version__: 9.6 + operationId: ValidateOutgoingOAuthConnection + parameters: + - name: team_id + in: query + description: Current Team ID in integrations backstage + required: true + schema: + type: string + requestBody: + description: Outgoing OAuth connection to validate + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionPostItem" + responses: + "200": + description: The connection configuration is valid. + "400": + description: The connection configuration is invalid. + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + "502": + description: The connection configuration may be valid, but the server is unable to validate it upstream. + $ref: "#/components/responses/BadGateway" diff --git a/e2e-tests/cypress/tests/support/api/role.js b/e2e-tests/cypress/tests/support/api/role.js index 16f6f47f3b..86208dcbfc 100644 --- a/e2e-tests/cypress/tests/support/api/role.js +++ b/e2e-tests/cypress/tests/support/api/role.js @@ -17,10 +17,10 @@ export const defaultRolesPermissions = { playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create', run_admin: 'run_manage_properties run_manage_members', run_member: 'run_view', - system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel read_channel_content sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private', + system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel read_channel_content sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth manage_outgoing_oauth_connections delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private', system_custom_group_admin: 'create_custom_group edit_custom_group delete_custom_group restore_custom_group manage_custom_group_members', system_guest: 'create_group_channel create_direct_channel', - system_manager: ' sysconsole_read_site_announcement_banner manage_private_channel_properties edit_brand read_private_channel_groups manage_private_channel_members manage_team_roles sysconsole_write_environment_session_lengths sysconsole_read_site_emoji sysconsole_write_environment_developer sysconsole_read_user_management_groups sysconsole_write_user_management_groups sysconsole_write_environment_rate_limiting delete_private_channel sysconsole_read_environment_performance_monitoring sysconsole_read_environment_rate_limiting sysconsole_write_user_management_teams sysconsole_write_integrations_integration_management sysconsole_write_site_public_links sysconsole_read_authentication_ldap sysconsole_write_integrations_cors reload_config sysconsole_write_user_management_channels sysconsole_read_environment_high_availability sysconsole_read_site_users_and_teams sysconsole_read_user_management_teams sysconsole_write_site_users_and_teams sysconsole_read_site_customization sysconsole_write_environment_high_availability sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_guest_access sysconsole_read_site_public_links read_elasticsearch_post_indexing_job sysconsole_read_user_management_channels sysconsole_read_reporting_team_statistics invalidate_caches sysconsole_read_authentication_signup read_elasticsearch_post_aggregation_job sysconsole_write_environment_smtp manage_public_channel_members list_public_teams add_user_to_team sysconsole_read_environment_web_server sysconsole_read_site_localization get_logs sysconsole_write_site_posts sysconsole_write_integrations_bot_accounts sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_read_environment_smtp list_private_teams read_public_channel_groups sysconsole_write_environment_file_storage sysconsole_write_integrations_gif manage_public_channel_properties sysconsole_write_environment_performance_monitoring sysconsole_write_site_notifications sysconsole_read_site_notifications sysconsole_read_environment_image_proxy sysconsole_write_site_announcement_banner sysconsole_write_site_emoji test_site_url sysconsole_read_integrations_gif sysconsole_write_environment_logging convert_public_channel_to_private get_analytics sysconsole_read_user_management_permissions sysconsole_write_environment_image_proxy test_elasticsearch recycle_database_connections sysconsole_write_site_localization sysconsole_read_reporting_server_logs create_elasticsearch_post_indexing_job sysconsole_read_reporting_site_statistics test_ldap delete_public_channel sysconsole_write_environment_push_notification_server read_license_information sysconsole_write_products_boards sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_integrations_integration_management create_elasticsearch_post_aggregation_job purge_elasticsearch_indexes sysconsole_read_environment_database join_public_teams sysconsole_read_authentication_email sysconsole_read_environment_push_notification_server view_team read_channel sysconsole_read_authentication_password read_ldap_sync_job sysconsole_read_integrations_cors sysconsole_read_environment_logging manage_team sysconsole_read_authentication_openid read_public_channel sysconsole_write_environment_elasticsearch sysconsole_read_plugins manage_channel_roles remove_user_from_team test_email sysconsole_write_site_file_sharing_and_downloads test_s3 sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_notices sysconsole_read_environment_file_storage join_private_teams sysconsole_read_products_boards sysconsole_read_environment_session_lengths sysconsole_write_environment_database sysconsole_read_authentication_saml sysconsole_read_authentication_mfa sysconsole_write_site_notices sysconsole_write_environment_web_server sysconsole_read_site_posts sysconsole_read_environment_developer sysconsole_write_site_customization', + system_manager: ' sysconsole_read_site_announcement_banner manage_private_channel_properties edit_brand read_private_channel_groups manage_private_channel_members manage_team_roles sysconsole_write_environment_session_lengths sysconsole_read_site_emoji sysconsole_write_environment_developer sysconsole_read_user_management_groups sysconsole_write_user_management_groups sysconsole_write_environment_rate_limiting delete_private_channel sysconsole_read_environment_performance_monitoring sysconsole_read_environment_rate_limiting sysconsole_write_user_management_teams sysconsole_write_integrations_integration_management sysconsole_write_site_public_links sysconsole_read_authentication_ldap sysconsole_write_integrations_cors reload_config sysconsole_write_user_management_channels sysconsole_read_environment_high_availability sysconsole_read_site_users_and_teams sysconsole_read_user_management_teams sysconsole_write_site_users_and_teams sysconsole_read_site_customization sysconsole_write_environment_high_availability sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_guest_access sysconsole_read_site_public_links read_elasticsearch_post_indexing_job sysconsole_read_user_management_channels sysconsole_read_reporting_team_statistics invalidate_caches sysconsole_read_authentication_signup read_elasticsearch_post_aggregation_job sysconsole_write_environment_smtp manage_public_channel_members list_public_teams add_user_to_team sysconsole_read_environment_web_server sysconsole_read_site_localization get_logs sysconsole_write_site_posts sysconsole_write_integrations_bot_accounts sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_read_environment_smtp list_private_teams read_public_channel_groups sysconsole_write_environment_file_storage sysconsole_write_integrations_gif manage_public_channel_properties sysconsole_write_environment_performance_monitoring sysconsole_write_site_notifications sysconsole_read_site_notifications sysconsole_read_environment_image_proxy sysconsole_write_site_announcement_banner sysconsole_write_site_emoji test_site_url sysconsole_read_integrations_gif sysconsole_write_environment_logging convert_public_channel_to_private get_analytics sysconsole_read_user_management_permissions sysconsole_write_environment_image_proxy test_elasticsearch recycle_database_connections sysconsole_write_site_localization sysconsole_read_reporting_server_logs create_elasticsearch_post_indexing_job sysconsole_read_reporting_site_statistics test_ldap delete_public_channel sysconsole_write_environment_push_notification_server read_license_information sysconsole_write_products_boards sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_integrations_integration_management create_elasticsearch_post_aggregation_job purge_elasticsearch_indexes sysconsole_read_environment_database join_public_teams sysconsole_read_authentication_email sysconsole_read_environment_push_notification_server view_team read_channel sysconsole_read_authentication_password read_ldap_sync_job sysconsole_read_integrations_cors sysconsole_read_environment_logging manage_team sysconsole_read_authentication_openid read_public_channel sysconsole_write_environment_elasticsearch sysconsole_read_plugins manage_channel_roles remove_user_from_team test_email sysconsole_write_site_file_sharing_and_downloads test_s3 sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_notices sysconsole_read_environment_file_storage join_private_teams sysconsole_read_products_boards sysconsole_read_environment_session_lengths sysconsole_write_environment_database sysconsole_read_authentication_saml sysconsole_read_authentication_mfa sysconsole_write_site_notices sysconsole_write_environment_web_server sysconsole_read_site_posts sysconsole_read_environment_developer sysconsole_write_site_customization manage_outgoing_oauth_connections', system_post_all: 'use_group_mentions use_channel_mentions create_post', system_post_all_public: 'use_group_mentions use_channel_mentions create_post_public', system_read_only_admin: 'sysconsole_read_authentication_guest_access download_compliance_export_result sysconsole_read_compliance_data_retention_policy get_logs sysconsole_read_environment_file_storage read_channel sysconsole_read_integrations_integration_management sysconsole_read_compliance_custom_terms_of_service sysconsole_read_site_notices sysconsole_read_environment_rate_limiting sysconsole_read_about_edition_and_license read_public_channel sysconsole_read_experimental_features test_ldap sysconsole_read_user_management_permissions read_elasticsearch_post_aggregation_job sysconsole_read_environment_image_proxy sysconsole_read_compliance_compliance_export sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_openid sysconsole_read_site_posts sysconsole_read_user_management_users sysconsole_read_experimental_feature_flags sysconsole_read_reporting_team_statistics sysconsole_read_site_localization read_private_channel_groups sysconsole_read_site_file_sharing_and_downloads sysconsole_read_user_management_channels sysconsole_read_authentication_email read_data_retention_job read_audits sysconsole_read_plugins view_team get_analytics sysconsole_read_user_management_groups sysconsole_read_experimental_bleve sysconsole_read_products_boards read_compliance_export_job sysconsole_read_environment_logging sysconsole_read_authentication_signup sysconsole_read_environment_smtp sysconsole_read_environment_session_lengths sysconsole_read_environment_developer sysconsole_read_environment_high_availability read_ldap_sync_job sysconsole_read_environment_performance_monitoring sysconsole_read_authentication_saml read_public_channel_groups sysconsole_read_integrations_gif sysconsole_read_authentication_mfa list_public_teams sysconsole_read_environment_database list_private_teams sysconsole_read_authentication_ldap sysconsole_read_compliance_compliance_monitoring sysconsole_read_site_notifications sysconsole_read_site_announcement_banner read_other_users_teams sysconsole_read_authentication_password sysconsole_read_environment_push_notification_server sysconsole_read_site_users_and_teams sysconsole_read_site_public_links sysconsole_read_site_emoji sysconsole_read_environment_elasticsearch read_license_information sysconsole_read_integrations_cors sysconsole_read_user_management_teams sysconsole_read_reporting_server_logs sysconsole_read_site_customization sysconsole_read_reporting_site_statistics sysconsole_read_environment_web_server read_elasticsearch_post_indexing_job', diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 4505178e2f..b1c3394eee 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -277,7 +277,7 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter() api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter() - api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter() api.InitUser() api.InitBot() diff --git a/server/channels/api4/outgoing_oauth_connection.go b/server/channels/api4/outgoing_oauth_connection.go index b851cd21dd..4a3be05045 100644 --- a/server/channels/api4/outgoing_oauth_connection.go +++ b/server/channels/api4/outgoing_oauth_connection.go @@ -6,11 +6,14 @@ package api4 import ( "encoding/json" "fmt" + "io" "net/http" "net/url" "strconv" + "github.com/mattermost/logr/v2" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/einterfaces" ) @@ -20,12 +23,44 @@ const ( func (api *API) InitOutgoingOAuthConnection() { api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(listOutgoingOAuthConnections)).Methods("GET") + api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(createOutgoingOAuthConnection)).Methods("POST") api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(getOutgoingOAuthConnection)).Methods("GET") + api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(updateOutgoingOAuthConnection)).Methods("PUT") + api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(deleteOutgoingOAuthConnection)).Methods("DELETE") + api.BaseRoutes.OutgoingOAuthConnections.Handle("/validate", api.APISessionRequired(validateOutgoingOAuthConnectionCredentials)).Methods("POST") +} + +// checkOutgoingOAuthConnectionReadPermissions checks if the user has the permissions to read outgoing oauth connections. +// An user with the permissions to manage outgoing oauth connections can read outgoing oauth connections. +// Otherwise the user needs to have the permissions to manage outgoing webhooks or slash commands in order to read outgoing +// oauth connections so that they can use them. +// This is made in this way so only users with the management permission can setup the outgoing oauth connections and then +// other users can use them in their outgoing webhooks and slash commands if they have permissions to manage those. +func checkOutgoingOAuthConnectionReadPermissions(c *Context, teamId string) bool { + if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) || + c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOutgoingWebhooks) || + c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageSlashCommands) { + return true + } + + c.SetPermissionError(model.PermissionManageOutgoingWebhooks, model.PermissionManageSlashCommands) + return false +} + +// checkOutgoingOAuthConnectionWritePermissions checks if the user has the permissions to write outgoing oauth connections. +// This is a more granular permissions intended for system admins to manage (setup) outgoing oauth connections. +func checkOutgoingOAuthConnectionWritePermissions(c *Context) bool { + if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) { + return true + } + + c.SetPermissionError(model.PermissionManageOutgoingOAuthConnections) + return false } func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfaces.OutgoingOAuthConnectionInterface, bool) { - if !c.App.Config().FeatureFlags.OutgoingOAuthConnections { - c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.feature_flag", nil, "", http.StatusNotImplemented) + if c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && !*c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections { + c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.configuration_disabled", nil, "", http.StatusNotImplemented) return nil, false } @@ -37,8 +72,9 @@ func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfac } type listOutgoingOAuthConnectionsQuery struct { - FromID string - Limit int + FromID string + Limit int + Audience string } // SetDefaults sets the default values for the query. @@ -62,6 +98,7 @@ func (q *listOutgoingOAuthConnectionsQuery) ToFilter() model.OutgoingOAuthConnec return model.OutgoingOAuthConnectionGetConnectionsFilter{ OffsetId: q.FromID, Limit: q.Limit, + Audience: q.Audience, } } @@ -77,16 +114,26 @@ func NewListOutgoingOAuthConnectionsQueryFromURLQuery(values url.Values) (*listO limit := values.Get("limit") if limit != "" { limitInt, err := strconv.Atoi(limit) - if err == nil { + if err != nil { return nil, err } query.Limit = limitInt } + audience := values.Get("audience") + if audience != "" { + query.Audience = audience + } + return query, nil } func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Request) { + teamId := r.URL.Query().Get("team_id") + if !checkOutgoingOAuthConnectionReadPermissions(c, teamId) { + return + } + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) if !ok { return @@ -103,10 +150,25 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req return } - connections, errList := service.GetConnections(c.AppContext, query.ToFilter()) - if errList != nil { - c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError) - return + var connections []*model.OutgoingOAuthConnection + if query.Audience != "" { + // If the consumer expects an audience match, use the `GetConnectionByAudience` method to + // retrieve a single connection. + connection, err := service.GetConnectionForAudience(c.AppContext, query.Audience) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + connections = append(connections, connection) + } else { + // If the consumer does not expect an audience match, use the `GetConnections` method to + // retrieve a list of connections that potentially matches the provided audience. + var errList *model.AppError + connections, errList = service.GetConnections(c.AppContext, query.ToFilter()) + if errList != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError) + return + } } service.SanitizeConnections(connections) @@ -118,6 +180,10 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req } func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) { + if !checkOutgoingOAuthConnectionWritePermissions(c) { + return + } + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) if !ok { return @@ -138,3 +204,216 @@ func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Reque return } } + +func createOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("createOutgoingOauthConnection", audit.Fail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + if !checkOutgoingOAuthConnectionWritePermissions(c) { + return + } + + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + var inputConnection model.OutgoingOAuthConnection + bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes) + if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.input_error", nil, err.Error(), http.StatusBadRequest) + return + } + + audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", &inputConnection) + + inputConnection.CreatorId = c.AppContext.Session().UserId + + connection, err := service.SaveConnection(c.AppContext, &inputConnection) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.Success() + auditRec.AddEventResultState(connection) + auditRec.AddEventObjectType("outgoing_oauth_connection") + c.LogAudit("client_id=" + connection.ClientId) + + service.SanitizeConnection(connection) + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(connection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } +} + +func updateOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("updateOutgoingOAuthConnection", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID) + c.LogAudit("attempt") + + if !checkOutgoingOAuthConnectionWritePermissions(c) { + return + } + + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + c.RequireOutgoingOAuthConnectionId() + if c.Err != nil { + return + } + + var inputConnection model.OutgoingOAuthConnection + bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes) + if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.input_error", nil, err.Error(), http.StatusBadRequest) + return + } + + if inputConnection.Id != c.Params.OutgoingOAuthConnectionID { + c.SetInvalidParam("id") + return + } + + currentConnection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + auditRec.AddEventPriorState(currentConnection) + + currentConnection.Patch(&inputConnection) + + connection, err := service.UpdateConnection(c.AppContext, currentConnection) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.AddEventObjectType("outgoing_oauth_connection") + auditRec.AddEventResultState(connection) + auditRec.Success() + auditLogExtraInfo := "success" + // Audit log changes to clientID/Client Secret + if connection.ClientId != currentConnection.ClientId { + auditLogExtraInfo += " new_client_id=" + connection.ClientId + } + if connection.ClientSecret != currentConnection.ClientSecret { + auditLogExtraInfo += " new_client_secret" + } + c.LogAudit(auditLogExtraInfo) + service.SanitizeConnection(connection) + + if err := json.NewEncoder(w).Encode(connection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } +} + +func deleteOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("deleteOutgoingOAuthConnection", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID) + c.LogAudit("attempt") + + if !checkOutgoingOAuthConnectionWritePermissions(c) { + return + } + + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + c.RequireOutgoingOAuthConnectionId() + if c.Err != nil { + return + } + + connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + auditRec.AddEventPriorState(connection) + + if err := service.DeleteConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.AddEventObjectType("outgoing_oauth_connection") + auditRec.Success() + + ReturnStatusOK(w) +} + +// validateOutgoingOAuthConnectionCredentials validates the credentials of an outgoing oauth connection by requesting a token +// with the provided connection configuration. If the credentials are valid, the request will return a 200 status code and +// if the credentials are invalid, the request will return a 400 status code. +func validateOutgoingOAuthConnectionCredentials(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("validateOutgoingOAuthConnectionCredentials", audit.Fail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + if !checkOutgoingOAuthConnectionWritePermissions(c) { + return + } + + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + // Allow checking connections sent in the body or by id if coming from an already existing + // connection url. + var inputConnection *model.OutgoingOAuthConnection + + bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes) + if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", nil, err.Error(), http.StatusBadRequest) + w.WriteHeader(c.Err.StatusCode) + return + } + + if inputConnection.Id != "" && inputConnection.ClientSecret == "" { + var err *model.AppError + var storedConnection *model.OutgoingOAuthConnection + storedConnection, err = service.GetConnection(c.AppContext, inputConnection.Id) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), http.StatusInternalServerError) + w.WriteHeader(c.Err.StatusCode) + return + } + + inputConnection.ClientSecret = storedConnection.ClientSecret + } + + audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", inputConnection) + + resultStatusCode := http.StatusOK + + // Try to retrieve a token with the provided credentials + // do not store the token, just check if the credentials are valid and the request can be made + _, err := service.RetrieveTokenForConnection(c.AppContext, inputConnection) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), err.StatusCode) + c.Logger.Error("Failed to retrieve token while validating outgoing oauth connection", logr.Err(err)) + resultStatusCode = err.StatusCode + } else { + ReturnStatusOK(w) + } + + auditRec.Success() + auditRec.AddEventResultState(inputConnection) + auditRec.AddEventObjectType("outgoing_oauth_connection") + + w.WriteHeader(resultStatusCode) +} diff --git a/server/channels/api4/outgoing_oauth_connection_test.go b/server/channels/api4/outgoing_oauth_connection_test.go index 83abefca51..88a1151d16 100644 --- a/server/channels/api4/outgoing_oauth_connection_test.go +++ b/server/channels/api4/outgoing_oauth_connection_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/mattermost/mattermost/server/public/model" @@ -21,7 +22,6 @@ import ( func newOutgoingOAuthConnection() *model.OutgoingOAuthConnection { return &model.OutgoingOAuthConnection{ Name: "test", - CreatorId: model.NewId(), ClientId: "test", ClientSecret: "test", OAuthTokenURL: "http://localhost:9999/oauth/token", @@ -42,14 +42,144 @@ func outgoingOauthConnectionsCleanup(t *testing.T, th *TestHelper) { } } +// Helper tests +func TestCheckOutgoingOAuthConnectionReadPermissions(t *testing.T) { + t.Run("no permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + canRead := checkOutgoingOAuthConnectionReadPermissions(c, th.BasicTeam.Id) + require.False(t, canRead) + }) + + t.Run("with management permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + canReadWithTeam := checkOutgoingOAuthConnectionReadPermissions(c, th.BasicTeam.Id) + require.True(t, canReadWithTeam) + + canReadWithoutTeam := checkOutgoingOAuthConnectionReadPermissions(c, "") + require.True(t, canReadWithoutTeam) + }) + + t.Run("with slash command management permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.TeamAdminRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.TeamAdminRoleId) + + canRead := checkOutgoingOAuthConnectionReadPermissions(c, th.BasicTeam.Id) + require.True(t, canRead) + }) + + t.Run("with outgoing webhooks management permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.TeamAdminRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.TeamAdminRoleId) + + canRead := checkOutgoingOAuthConnectionReadPermissions(c, th.BasicTeam.Id) + require.True(t, canRead) + }) +} + +func TestCheckOutgoingOAuthConnectionWritePermissions(t *testing.T) { + t.Run("no permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + canWrite := checkOutgoingOAuthConnectionWritePermissions(c) + require.False(t, canWrite) + }) + + t.Run("with permissions", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c := &Context{} + c.AppContext = th.Context.WithSession(&session) + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + canWrite := checkOutgoingOAuthConnectionWritePermissions(c) + require.True(t, canWrite) + }) +} + // Client tests -func TestOutgoingOAuthConnectionGet(t *testing.T) { +func TestClientOutgoingOAuthConnectionGet(t *testing.T) { t.Run("No license returns 501", func(t *testing.T) { os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTION", "true") defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTION") th := Setup(t).InitBasic() defer th.TearDown() + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.TeamAdminRoleId) + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.TeamAdminRoleId) outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection @@ -58,22 +188,37 @@ func TestOutgoingOAuthConnectionGet(t *testing.T) { }() th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + // th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + th.LoginTeamAdmin() - connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + TeamId: th.BasicTeam.Id, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) require.Error(t, err) require.Nil(t, connections) require.Equal(t, 501, response.StatusCode) }) - t.Run("license but no feature flag returns 501", func(t *testing.T) { + t.Run("license but no config enabled returns 501", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.TeamAdminRoleId) + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.TeamAdminRoleId) + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(false) defer func() { th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig }() th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") @@ -81,16 +226,20 @@ func TestOutgoingOAuthConnectionGet(t *testing.T) { th.App.Srv().SetLicense(license) th.App.Srv().RemoveLicense() - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + th.LoginTeamAdmin() - connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + TeamId: th.BasicTeam.Id, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) require.Error(t, err) require.Nil(t, connections) require.Equal(t, 501, response.StatusCode) }) } -func TestListOutgoingOAutConnection(t *testing.T) { +func TestClientListOutgoingOAuthConnection(t *testing.T) { os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") th := Setup(t).InitBasic() @@ -100,27 +249,160 @@ func TestListOutgoingOAutConnection(t *testing.T) { license.Id = "test-license-id" th.App.Srv().SetLicense(license) - t.Run("empty", func(t *testing.T) { + t.Run("no permissions", func(t *testing.T) { defer outgoingOauthConnectionsCleanup(t, th) + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + TeamId: th.BasicTeam.Id, + } + connection, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) + require.Error(t, err) + require.Nil(t, connection) + require.Equal(t, http.StatusForbidden, response.StatusCode) + }) + + t.Run("manager do not require team id", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface outgoingOauthIface.Mock.On("GetConnections", mock.Anything, mock.Anything).Return([]*model.OutgoingOAuthConnection{}, nil) outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) require.NoError(t, err) require.Equal(t, 200, response.StatusCode) require.Equal(t, 0, len(connections)) }) + t.Run("empty", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.SystemUserRoleId) + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.SystemUserRoleId) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", mock.Anything, mock.Anything).Return([]*model.OutgoingOAuthConnection{}, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + TeamId: th.BasicTeam.Id, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) + require.NoError(t, err) + + require.Equal(t, 200, response.StatusCode) + require.Equal(t, 0, len(connections)) + }) + + t.Run("filter by audience", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.SystemUserRoleId) + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.SystemUserRoleId) + + conn := newOutgoingOAuthConnection() + conn.Audiences = []string{"http://knowhere.com"} + conn.CreatorId = model.NewId() + + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnectionForAudience", mock.Anything, "knowhere.com").Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 1, + Audience: "knowhere.com", + TeamId: th.BasicTeam.Id, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) + require.NoError(t, err) + + require.Equal(t, 200, response.StatusCode) + require.Equal(t, 1, len(connections)) + require.Equal(t, conn, connections[0]) + }) + t.Run("return result", func(t *testing.T) { defer outgoingOauthConnectionsCleanup(t, th) + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingWebhooks.Id, model.SystemUserRoleId) + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.SystemUserRoleId) + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) require.NoError(t, err) @@ -130,9 +412,22 @@ func TestListOutgoingOAutConnection(t *testing.T) { outgoingOauthIface.Mock.On("GetConnections", mock.Anything, mock.Anything).Return([]*model.OutgoingOAuthConnection{conn}, nil) outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + filters := model.OutgoingOAuthConnectionGetConnectionsFilter{ + Limit: 10, + TeamId: th.BasicTeam.Id, + } + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), filters) require.NoError(t, err) require.Equal(t, 200, response.StatusCode) @@ -141,7 +436,7 @@ func TestListOutgoingOAutConnection(t *testing.T) { }) } -func TestGetOutgoingOauthConnection(t *testing.T) { +func TestClientGetOutgoingOAuthConnection(t *testing.T) { os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") th := Setup(t).InitBasic() @@ -152,10 +447,38 @@ func TestGetOutgoingOauthConnection(t *testing.T) { license.Id = "test-license-id" th.App.Srv().SetLicense(license) - t.Run("return result", func(t *testing.T) { + t.Run("no permissions", func(t *testing.T) { defer outgoingOauthConnectionsCleanup(t, th) + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connection, response, err := th.Client.GetOutgoingOAuthConnection(context.Background(), "test") + require.Error(t, err) + require.Nil(t, connection) + require.Equal(t, http.StatusForbidden, response.StatusCode) + }) + + t.Run("return result (management permissions)", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) require.NoError(t, err) @@ -165,13 +488,15 @@ func TestGetOutgoingOauthConnection(t *testing.T) { outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection - defer func() { + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl - }() + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - + th.LoginSystemAdmin() connection, response, err := th.Client.GetOutgoingOAuthConnection(context.Background(), conn.Id) require.NoError(t, err) @@ -182,7 +507,230 @@ func TestGetOutgoingOauthConnection(t *testing.T) { }) } -// API tests +func TestClientCreateOutgoingOAuthConnection(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + defer th.App.Srv().RemoveLicense() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + + t.Run("no permissions", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthIface.Mock.On("SaveConnection", mock.Anything, mock.Anything).Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + connection, response, err := th.Client.CreateOutgoingOAuthConnection(context.Background(), conn) + require.Error(t, err) + require.Nil(t, connection) + require.Equal(t, http.StatusForbidden, response.StatusCode) + }) + + t.Run("ok", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthIface.Mock.On("SaveConnection", mock.Anything, mock.Anything).Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + connection, response, err := th.Client.CreateOutgoingOAuthConnection(context.Background(), conn) + require.NoError(t, err) + require.NotNil(t, connection) + require.Equal(t, http.StatusCreated, response.StatusCode) + require.Equal(t, conn, connection) + }) +} + +func TestClientUpdateOutgoingOAuthConnection(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + defer th.App.Srv().RemoveLicense() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + + t.Run("no permissions", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + connection, response, err := th.Client.UpdateOutgoingOAuthConnection(context.Background(), conn) + require.Error(t, err) + require.Nil(t, connection) + require.Equal(t, http.StatusForbidden, response.StatusCode) + }) + + t.Run("ok", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthIface.Mock.On("GetConnection", mock.Anything, conn.Id).Return(conn, nil) + outgoingOauthIface.Mock.On("UpdateConnection", mock.Anything, conn).Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + updatedConn := conn + updatedConn.Name = "updated name" + + connection, response, err := th.Client.UpdateOutgoingOAuthConnection(context.Background(), conn) + + require.NoError(t, err) + require.NotNil(t, connection) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, updatedConn, connection) + }) +} + +func TestClientDeleteOutgoingOAuthConnection(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + defer th.App.Srv().RemoveLicense() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + + t.Run("no permissions", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + response, err := th.Client.DeleteOutgoingOAuthConnection(context.Background(), conn.Id) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, response.StatusCode) + }) + + t.Run("ok", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthIface.Mock.On("GetConnection", mock.Anything, conn.Id).Return(conn, nil) + outgoingOauthIface.Mock.On("DeleteConnection", mock.Anything, conn.Id).Return(nil) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.LoginSystemAdmin() + + response, err := th.Client.DeleteOutgoingOAuthConnection(context.Background(), conn.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + }) +} + +// Handler tests func TestEnsureOutgoingOAuthConnectionInterface(t *testing.T) { t.Run("no feature flag, no interface, no license", func(t *testing.T) { @@ -200,13 +748,16 @@ func TestEnsureOutgoingOAuthConnectionInterface(t *testing.T) { require.False(t, valid) }) - t.Run("feature flag, no interface, no license", func(t *testing.T) { - os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") - defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") - + t.Run("config, no interface, no license", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + c := &Context{} c.AppContext = th.Context c.App = th.App @@ -219,37 +770,47 @@ func TestEnsureOutgoingOAuthConnectionInterface(t *testing.T) { }) t.Run("feature flag, interface defined, no license", func(t *testing.T) { - os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") - defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") - th := Setup(t).InitBasic() defer th.TearDown() + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + c := &Context{} c.AppContext = th.Context c.App = th.App c.Logger = th.App.Srv().Log() - th.App.Srv().OutgoingOAuthConnection = &mocks.OutgoingOAuthConnectionInterface{} - _, valid := ensureOutgoingOAuthConnectionInterface(c, "api") require.False(t, valid) }) t.Run("feature flag, interface defined, valid license", func(t *testing.T) { - os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") - defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") - th := Setup(t).InitBasic() defer th.TearDown() + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") license.Id = "test-license-id" th.App.Srv().SetLicense(license) defer th.App.Srv().RemoveLicense() - th.App.Srv().OutgoingOAuthConnection = &mocks.OutgoingOAuthConnectionInterface{} - c := &Context{} c.AppContext = th.Context c.App = th.App @@ -261,7 +822,7 @@ func TestEnsureOutgoingOAuthConnectionInterface(t *testing.T) { }) } -func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { +func TestHandlerOutgoingOAuthConnectionListGet(t *testing.T) { os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") th := Setup(t).InitBasic() @@ -279,6 +840,27 @@ func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { conn := newOutgoingOAuthConnection() + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + t.Run("getOutgoingOAuthConnection", func(t *testing.T) { req, err := http.NewRequest("GET", "/", nil) if err != nil { @@ -291,7 +873,7 @@ func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface - outgoingOauthIface.Mock.On("GetConnection", th.Context, c.Params.OutgoingOAuthConnectionID).Return(conn, nil) + outgoingOauthIface.Mock.On("GetConnection", c.AppContext, c.Params.OutgoingOAuthConnectionID).Return(conn, nil) outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) httpRecorder := httptest.NewRecorder() @@ -318,7 +900,34 @@ func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface - outgoingOauthIface.Mock.On("GetConnections", th.Context, mock.Anything).Return(conns, nil) + outgoingOauthIface.Mock.On("GetConnections", c.AppContext, mock.Anything).Return(conns, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listOutgoingOAuthConnections(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(conn)) + }) + + t.Run("listOutgoingOAuthConnections with limit", func(t *testing.T) { + req, err := http.NewRequest("GET", "/?limit=2", nil) + if err != nil { + t.Error(err) + } + + conns := []*model.OutgoingOAuthConnection{conn} + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", c.AppContext, model.OutgoingOAuthConnectionGetConnectionsFilter{Limit: 2}).Return(conns, nil) outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) httpRecorder := httptest.NewRecorder() @@ -335,3 +944,710 @@ func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { require.NoError(t, json.NewEncoder(&buf).Encode(conn)) }) } + +func TestHandlerOutgoingOAuthConnectionListReadOnly(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + conn := newOutgoingOAuthConnection() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.TeamAdminRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOthersOutgoingWebhooks.Id, model.TeamAdminRoleId) + + t.Run("listOutgoingOAuthConnections", func(t *testing.T) { + req, err := http.NewRequest("GET", "/?team_id="+th.BasicTeam.Id, nil) + if err != nil { + t.Error(err) + } + + conns := []*model.OutgoingOAuthConnection{conn} + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", c.AppContext, mock.Anything).Return(conns, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listOutgoingOAuthConnections(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(conn)) + }) + + t.Run("listOutgoingOAuthConnections with limit", func(t *testing.T) { + req, err := http.NewRequest("GET", "/?limit=2&team_id="+th.BasicTeam.Id, nil) + if err != nil { + t.Error(err) + } + + conns := []*model.OutgoingOAuthConnection{conn} + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", c.AppContext, model.OutgoingOAuthConnectionGetConnectionsFilter{Limit: 2}).Return(conns, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listOutgoingOAuthConnections(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(conn)) + }) +} + +func TestHandlerOutgoingOAuthConnectionUpdate(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + t.Run("no permissions", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + req, err := http.NewRequest("PUT", "/", nil) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + c.Params = &web.Params{ + OutgoingOAuthConnectionID: model.NewId(), + } + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + updateOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusForbidden, c.Err.StatusCode) + }) + + t.Run("bad json", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + body.Write([]byte(`{/}`)) + + req, err := http.NewRequest("PUT", "/", body) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + c.Params = &web.Params{ + OutgoingOAuthConnectionID: model.NewId(), + } + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + updateOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusBadRequest, c.Err.StatusCode) + }) + + t.Run("wrong id", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + body.Write([]byte(`{"Id": "` + model.NewId() + `", "name": "changed name"}`)) + + req, err := http.NewRequest("PUT", "/", body) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + c.Params = &web.Params{ + OutgoingOAuthConnectionID: model.NewId(), + } + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + updateOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusBadRequest, c.Err.StatusCode) + }) + + t.Run("ok", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + conn := newOutgoingOAuthConnection() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + conn.Id = model.NewId() // Faking an ID for the connection + t.Cleanup(func() { + conn.Id = "" + }) + + body := &bytes.Buffer{} + + inputConnection := conn + inputConnection.Name = "changed name" + + require.NoError(t, json.NewEncoder(body).Encode(inputConnection)) + + req, err := http.NewRequest("PUT", "/"+conn.Id, body) + if err != nil { + t.Error(err) + } + + c.Params = &web.Params{ + OutgoingOAuthConnectionID: conn.Id, + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnection", c.AppContext, c.Params.OutgoingOAuthConnectionID).Return(conn, nil) + outgoingOauthIface.Mock.On("UpdateConnection", c.AppContext, inputConnection).Return(inputConnection, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + updateOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + }) +} + +func TestHandlerOutgoingOAuthConnectionHandlerCreate(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + + t.Run("no permissions", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + createOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusForbidden, c.Err.StatusCode) + }) + + t.Run("bad json", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + body.Write([]byte(`{/}`)) + + req, err := http.NewRequest("POST", "/", body) + if err != nil { + t.Error(err) + } + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + httpRecorder := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + createOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusBadRequest, c.Err.StatusCode) + }) + + t.Run("ok", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + conn := newOutgoingOAuthConnection() + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + require.NoError(t, json.NewEncoder(body).Encode(conn)) + + req, err := http.NewRequest("POST", "/", body) + if err != nil { + t.Error(err) + } + + // Handler sets the connection creator ID to the session user ID + handlerConn := conn + handlerConn.CreatorId = session.UserId + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("SaveConnection", c.AppContext, handlerConn).Return(handlerConn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + createOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusCreated, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + }) +} + +func TestHandlerOutgoingOAuthConnectionHandlerValidate(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + // Run a server to fake the valid and invalid requests made to the oauth server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimSpace(r.URL.Path) { + case "/valid": + w.WriteHeader(http.StatusOK) + case "/invalid": + w.WriteHeader(http.StatusBadRequest) + default: + http.NotFoundHandler().ServeHTTP(w, r) + } + })) + t.Cleanup(func() { + server.Close() + }) + + t.Run("no permissions", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateOutgoingOAuthConnectionCredentials(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusForbidden, c.Err.StatusCode) + }) + + t.Run("no interface", func(t *testing.T) { + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Error(err) + } + + session := model.Session{ + Id: model.NewId(), + UserId: model.NewId(), + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + }() + th.App.Srv().OutgoingOAuthConnection = nil + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateOutgoingOAuthConnectionCredentials(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusNotImplemented, c.Err.StatusCode) + }) + + t.Run("invalid", func(t *testing.T) { + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn.OAuthTokenURL = server.URL + "/invalid" + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: conn.CreatorId, + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + require.NoError(t, json.NewEncoder(body).Encode(conn)) + req, err := http.NewRequest("POST", "/", body) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("RetrieveTokenForConnection", c.AppContext, conn).Return(&model.OutgoingOAuthConnectionToken{}, model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", nil, "error", http.StatusBadRequest)) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateOutgoingOAuthConnectionCredentials(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusBadRequest, httpRecorder.Code) + }) + + t.Run("success", func(t *testing.T) { + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn.OAuthTokenURL = server.URL + "/valid" + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: conn.CreatorId, + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + require.NoError(t, json.NewEncoder(body).Encode(conn)) + + req, err := http.NewRequest("POST", "/", body) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("RetrieveTokenForConnection", c.AppContext, conn).Return(&model.OutgoingOAuthConnectionToken{}, nil) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateOutgoingOAuthConnectionCredentials(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + }) + + t.Run("success (stored connection)", func(t *testing.T) { + conn := newOutgoingOAuthConnection() + conn.CreatorId = model.NewId() + conn.OAuthTokenURL = server.URL + "/valid" + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + session := model.Session{ + Id: model.NewId(), + UserId: conn.CreatorId, + Roles: model.SystemUserRoleId, + } + c.AppContext = th.Context.WithSession(&session) + c.Params = &web.Params{ + OutgoingOAuthConnectionID: conn.Id, + } + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() + th.AddPermissionToRole(model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId) + + body := &bytes.Buffer{} + require.NoError(t, json.NewEncoder(body).Encode(conn)) + + req, err := http.NewRequest("POST", "/", body) + if err != nil { + t.Error(err) + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnection", c.AppContext, conn.Id).Return(conn, nil) + outgoingOauthIface.Mock.On("RetrieveTokenForConnection", c.AppContext, conn).Return(&model.OutgoingOAuthConnectionToken{}, nil) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateOutgoingOAuthConnectionCredentials(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + }) +} diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index bafff9a97e..a4721e1c4e 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -60,14 +60,13 @@ type Channels struct { // previously fetched notices cachedNotices model.ProductNotices - AccountMigration einterfaces.AccountMigrationInterface - Compliance einterfaces.ComplianceInterface - DataRetention einterfaces.DataRetentionInterface - MessageExport einterfaces.MessageExportInterface - Saml einterfaces.SamlInterface - Notification einterfaces.NotificationInterface - OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface - Ldap einterfaces.LdapInterface + AccountMigration einterfaces.AccountMigrationInterface + Compliance einterfaces.ComplianceInterface + DataRetention einterfaces.DataRetentionInterface + MessageExport einterfaces.MessageExportInterface + Saml einterfaces.SamlInterface + Notification einterfaces.NotificationInterface + Ldap einterfaces.LdapInterface // These are used to prevent concurrent upload requests // for a given upload session which could cause inconsistencies @@ -177,9 +176,6 @@ func NewChannels(services map[product.ServiceKey]any) (*Channels, error) { if notificationInterface != nil { ch.Notification = notificationInterface(New(ServerConnector(ch))) } - if outgoingOauthConnectionInterface != nil { - ch.OutgoingOAuthConnection = outgoingOauthConnectionInterface(New(ServerConnector(ch))) - } if samlInterfaceNew != nil { ch.Saml = samlInterfaceNew(New(ServerConnector(ch))) if err := ch.Saml.ConfigureSP(request.EmptyContext(s.Log())); err != nil { diff --git a/server/channels/app/command.go b/server/channels/app/command.go index 78ae616b6c..cf50f8f917 100644 --- a/server/channels/app/command.go +++ b/server/channels/app/command.go @@ -485,6 +485,23 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second) defer cancel() + var accessToken *model.OutgoingOAuthConnectionToken + + // Retrieve an access token from a connection if one exists to use for the webhook request + if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil { + connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(rctx, cmd.URL) + if err != nil { + a.Log().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err)) + } + + if connection != nil { + accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(rctx, connection) + if err != nil { + a.Log().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err)) + } + } + } + // Prepare the request var req *http.Request var err error @@ -506,7 +523,14 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value } req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Token "+cmd.Token) + if cmd.Token != "" { + req.Header.Set("Authorization", "Token "+cmd.Token) + } + + if accessToken != nil { + req.Header.Set("Authorization", accessToken.AsHeaderValue()) + } + if cmd.Method == model.CommandMethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go index fd8052213b..5e731d5551 100644 --- a/server/channels/app/permissions_migrations.go +++ b/server/channels/app/permissions_migrations.go @@ -1141,6 +1141,21 @@ func (a *App) getAddIPFilterPermissionsMigration() (permissionsMap, error) { return t, nil } +func (a *App) getAddOutgoingOAuthConnectionsPermissions() (permissionsMap, error) { + t := []permissionTransformation{} + + permissionManageOutgoingOAuthConnections := []string{ + model.PermissionManageOutgoingOAuthConnections.Id, + } + + t = append(t, permissionTransformation{ + On: permissionOr(isExactRole(model.SystemAdminRoleId)), + Add: permissionManageOutgoingOAuthConnections, + }) + + return t, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1186,6 +1201,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore}, {Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions}, {Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration}, + {Key: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Migration: a.getAddOutgoingOAuthConnectionsPermissions}, } roles, err := s.Store().Role().GetAll() diff --git a/server/channels/app/server.go b/server/channels/app/server.go index a120378dfe..911392acb4 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -402,6 +402,10 @@ func NewServer(options ...Option) (*Server, error) { s.IPFiltering = ipFilteringInterface(app) } + if outgoingOauthConnectionInterface != nil { + s.OutgoingOAuthConnection = outgoingOauthConnectionInterface(app) + } + s.clusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() { mlog.Info("Cluster leader changed. Determining if job schedulers should be running:", mlog.Bool("isLeader", s.IsLeader())) if s.Jobs != nil { diff --git a/server/channels/app/slashcommands/command_test.go b/server/channels/app/slashcommands/command_test.go index 9f13ac98fc..0751c8d54a 100644 --- a/server/channels/app/slashcommands/command_test.go +++ b/server/channels/app/slashcommands/command_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" ) type InfiniteReader struct { @@ -459,6 +461,51 @@ func TestDoCommandRequest(t *testing.T) { require.NotNil(t, resp) assert.Equal(t, "Hello, World!", resp.Text) }) + + t.Run("with a url that matches an outgoing oauth connection", func(t *testing.T) { + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true) + t.Cleanup(func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig + }) + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + serverCommand := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(w, strings.NewReader(r.Header.Get("Authorization"))) + })) + defer serverCommand.Close() + + connection := &model.OutgoingOAuthConnection{ + Id: model.NewId(), + Name: "test", + ClientId: "test", + ClientSecret: "test", + CreatorId: model.NewId(), + OAuthTokenURL: "fake", + GrantType: model.OutgoingOAuthConnectionGrantTypeClientCredentials, + Audiences: model.StringArray{ + serverCommand.URL, + }, + } + + outgoingOauthIface.Mock.On("GetConnectionForAudience", mock.Anything, serverCommand.URL).Return(connection, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + outgoingOauthIface.Mock.On("RetrieveTokenForConnection", mock.Anything, connection).Return(&model.OutgoingOAuthConnectionToken{ + AccessToken: "token", + TokenType: "type", + }, nil) + + _, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: serverCommand.URL}, url.Values{}) + require.Nil(t, err) + + require.NotNil(t, resp) + // Ensure that the Authorization header was set correctly by reading the body from the command response + // which was set to the Authorization header by the command handler. + assert.Equal(t, "type token", resp.Text) + }) } func TestMentionsToTeamMembers(t *testing.T) { diff --git a/server/channels/app/webhook.go b/server/channels/app/webhook.go index c2a053a215..a4d005dd52 100644 --- a/server/channels/app/webhook.go +++ b/server/channels/app/webhook.go @@ -117,7 +117,27 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa go func() { defer wg.Done() - webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType) + + var accessToken *model.OutgoingOAuthConnectionToken + + // Retrieve an access token from a connection if one exists to use for the webhook request + if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil { + connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(c, url) + if err != nil { + c.Logger().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err)) + return + } + + if connection != nil { + accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(c, connection) + if err != nil { + c.Logger().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err)) + return + } + } + } + + webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType, accessToken) if err != nil { if errors.Is(err, context.DeadlineExceeded) { c.Logger().Error("Outgoing Webhook POST timed out. Consider increasing ServiceSettings.OutgoingIntegrationRequestsTimeout.", mlog.Err(err)) @@ -162,7 +182,7 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa wg.Wait() } -func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string) (*model.OutgoingWebhookResponse, error) { +func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string, accessToken *model.OutgoingOAuthConnectionToken) (*model.OutgoingWebhookResponse, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second) defer cancel() @@ -174,6 +194,10 @@ func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType s req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") + if accessToken != nil { + req.Header.Add("Authorization", accessToken.AsHeaderValue()) + } + resp, err := a.Srv().outgoingWebhookClient.Do(req) if err != nil { return nil, err diff --git a/server/channels/app/webhook_test.go b/server/channels/app/webhook_test.go index 9e3283e0f8..595830ef90 100644 --- a/server/channels/app/webhook_test.go +++ b/server/channels/app/webhook_test.go @@ -6,6 +6,7 @@ package app import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -783,7 +784,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { })) defer server.Close() - resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.NotNil(t, resp) @@ -797,7 +798,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { })) defer server.Close() - _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) @@ -808,7 +809,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { })) defer server.Close() - _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) @@ -819,7 +820,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { })) defer server.Close() - _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) @@ -838,7 +839,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(1) }) - _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.IsType(t, &url.Error{}, err) }) @@ -855,7 +856,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(2) }) - resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.NotNil(t, resp) assert.NotNil(t, resp.Text) @@ -867,8 +868,22 @@ func TestDoOutgoingWebhookRequest(t *testing.T) { })) defer server.Close() - resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json") + resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.Nil(t, resp) }) + + t.Run("with auth token", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(w, strings.NewReader(fmt.Sprintf(`{"text":"%s"}`, r.Header.Get("Authorization")))) + })) + defer server.Close() + + resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", &model.OutgoingOAuthConnectionToken{ + AccessToken: "test", + TokenType: "Bearer", + }) + require.NoError(t, err) + require.Equal(t, `Bearer test`, *resp.Text) + }) } diff --git a/server/channels/store/sqlstore/outgoing_oauth_connection_store.go b/server/channels/store/sqlstore/outgoing_oauth_connection_store.go index 2bf510db5a..7e7ae37a96 100644 --- a/server/channels/store/sqlstore/outgoing_oauth_connection_store.go +++ b/server/channels/store/sqlstore/outgoing_oauth_connection_store.go @@ -5,6 +5,9 @@ package sqlstore import ( "database/sql" + "fmt" + + sq "github.com/mattermost/squirrel" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" @@ -49,9 +52,33 @@ func (s *SqlOutgoingOAuthConnectionStore) UpdateConnection(c request.CTX, conn * return nil, err } - if _, err := s.GetMasterX().NamedExec(`UPDATE OutgoingOAuthConnections SET - Name=:Name, ClientId=:ClientId, ClientSecret=:ClientSecret, UpdateAt=:UpdateAt, OAuthTokenURL=:OAuthTokenURL, GrantType=:GrantType, Audiences=:Audiences - WHERE Id=:Id`, conn); err != nil { + query := s.getQueryBuilder().Update("OutgoingOAuthConnections").Where(sq.Eq{"Id": conn.Id}).Set("UpdateAt", conn.UpdateAt) + if conn.Name != "" { + query = query.Set("Name", conn.Name) + } + if conn.ClientId != "" { + query = query.Set("ClientId", conn.ClientId) + } + if conn.ClientSecret != "" { + query = query.Set("ClientSecret", conn.ClientSecret) + } + if conn.OAuthTokenURL != "" { + query = query.Set("OAuthTokenURL", conn.OAuthTokenURL) + } + if conn.GrantType != "" { + query = query.Set("GrantType", conn.GrantType) + } + if len(conn.Audiences) > 0 { + query = query.Set("Audiences", conn.Audiences) + } + if conn.CredentialsUsername != nil { + query = query.Set("CredentialsUsername", conn.CredentialsUsername) + } + if conn.CredentialsPassword != nil { + query = query.Set("CredentialsPassword", conn.CredentialsPassword) + } + + if _, err := s.GetMasterX().ExecBuilder(query); err != nil { return nil, errors.Wrap(err, "failed to update OutgoingOAuthConnection") } return conn, nil @@ -82,6 +109,10 @@ func (s *SqlOutgoingOAuthConnectionStore) GetConnections(c request.CTX, filters query = query.Where("Id > ?", filters.OffsetId) } + if filters.Audience != "" { + query = query.Where(sq.Like{"Audiences": fmt.Sprint("%", filters.Audience, "%")}) + } + if err := s.GetReplicaX().SelectBuilder(&conns, query); err != nil { return nil, errors.Wrap(err, "failed to get OutgoingOAuthConnections") } diff --git a/server/channels/store/storetest/outgoing_oauth_connection.go b/server/channels/store/storetest/outgoing_oauth_connection.go index 331a72c659..267c51f82e 100644 --- a/server/channels/store/storetest/outgoing_oauth_connection.go +++ b/server/channels/store/storetest/outgoing_oauth_connection.go @@ -52,6 +52,10 @@ func TestOutgoingOAuthConnectionStore(t *testing.T, rctx request.CTX, ss store.S t.Cleanup(cleanupOutgoingOAuthConnections(t, ss)) testGetOutgoingOAuthConnection(t, ss) }) + t.Run("GetConnectionsByAudience", func(t *testing.T) { + t.Cleanup(cleanupOutgoingOAuthConnections(t, ss)) + testGetOutgoingOAuthConnectionByAudience(t, ss) + }) t.Run("GetConnections", func(t *testing.T) { t.Cleanup(cleanupOutgoingOAuthConnections(t, ss)) testGetOutgoingOAuthConnections(t, ss) @@ -159,6 +163,106 @@ func testUpdateOutgoingOAuthConnection(t *testing.T, ss store.Store) { require.NoError(t, err) require.Equal(t, connection, storeConn) }) + + t.Run("patch", func(t *testing.T) { + t.Run("name", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.Name = "Updated Name" + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("client id", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.ClientId = "Updated ClientId" + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("client secret", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.ClientSecret = "Updated ClientSecret" + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("oauth token url", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.OAuthTokenURL = "https://nowhere.com/updated" + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("grant type", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.GrantType = model.OutgoingOAuthConnectionGrantTypeClientCredentials + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("audiences", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + connection.Audiences = model.StringArray{"https://nowhere.com/updated"} + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("credentials username", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + username := "updated username" + connection.CredentialsUsername = &username + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + + t.Run("credentials password", func(t *testing.T) { + connection := newValidOutgoingOAuthConnection() + _, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + password := "updated password" + connection.CredentialsPassword = &password + + updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection) + require.NoError(t, err) + require.Equal(t, connection, updated) + }) + }) } func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) { @@ -172,6 +276,74 @@ func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) { }) } +func runAudienceTests(t *testing.T, ss store.Store, connection *model.OutgoingOAuthConnection) { + c := request.TestContext(t) + + t.Run("find by host only", func(t *testing.T) { + conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com"}) + require.NoError(t, err) + require.Len(t, conn, 1) + require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn) + }) + + t.Run("find by host and path", func(t *testing.T) { + conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com/audience"}) + require.NoError(t, err) + require.Len(t, conn, 1) + require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn) + }) + + t.Run("find by full url", func(t *testing.T) { + conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://knowhere.com/audience"}) + require.NoError(t, err) + require.Len(t, conn, 1) + require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn) + }) + + t.Run("non-existent", func(t *testing.T) { + conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://mattermost.com"}) + require.NoError(t, err) + require.Empty(t, conn) + }) +} + +func testGetOutgoingOAuthConnectionByAudience(t *testing.T, ss store.Store) { + t.Run("get non-existing", func(t *testing.T) { + c := request.TestContext(t) + + nonExistingId := model.NewId() + var expected *store.ErrNotFound + _, err := ss.OutgoingOAuthConnection().GetConnection(c, nonExistingId) + require.ErrorAs(t, err, &expected) + }) + + t.Run("get existing (single audience)", func(t *testing.T) { + t.Cleanup(cleanupOutgoingOAuthConnections(t, ss)) + c := request.TestContext(t) + + connection := newValidOutgoingOAuthConnection() + connection.Audiences = []string{"https://knowhere.com/audience"} + var err error + connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + runAudienceTests(t, ss, connection) + }) + + t.Run("get existing (multiple audiences)", func(t *testing.T) { + t.Cleanup(cleanupOutgoingOAuthConnections(t, ss)) + c := request.TestContext(t) + + connection := newValidOutgoingOAuthConnection() + connection.Audiences = []string{"https://knowhere.com/audience", "https://example.com"} + var err error + connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection) + require.NoError(t, err) + + runAudienceTests(t, ss, connection) + }) +} + func testGetOutgoingOAuthConnections(t *testing.T, ss store.Store) { c := request.TestContext(t) diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index 30766a67cb..825c9f2e8b 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -74,6 +74,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyDeleteEmptyDrafts).Return(&model.System{Name: model.MigrationKeyDeleteEmptyDrafts, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyDeleteOrphanDrafts).Return(&model.System{Name: model.MigrationKeyDeleteOrphanDrafts, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil) + systemStore.On("GetByName", model.MigrationKeyAddOutgoingOAuthConnectionsPermissions).Return(&model.System{Name: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil) diff --git a/server/cmd/mattermost/commands/test.go b/server/cmd/mattermost/commands/test.go index 6d645aeff7..cca8d5bdde 100644 --- a/server/cmd/mattermost/commands/test.go +++ b/server/cmd/mattermost/commands/test.go @@ -103,6 +103,7 @@ func setupClientTests(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true *cfg.ServiceSettings.EnableIncomingWebhooks = false *cfg.ServiceSettings.EnableOutgoingWebhooks = false + *cfg.ServiceSettings.EnableOutgoingOAuthConnections = false } func executeTestCommand(command *exec.Cmd) { diff --git a/server/config/client.go b/server/config/client.go index b1db928075..f2d548fd61 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -29,6 +29,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["GoogleDeveloperKey"] = *c.ServiceSettings.GoogleDeveloperKey props["EnableIncomingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableIncomingWebhooks) props["EnableOutgoingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingWebhooks) + props["EnableOutgoingOAuthConnections"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingOAuthConnections) props["EnableCommands"] = strconv.FormatBool(*c.ServiceSettings.EnableCommands) props["EnablePostUsernameOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostUsernameOverride) props["EnablePostIconOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostIconOverride) diff --git a/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go b/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go index 840fc79a71..695725593d 100644 --- a/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go +++ b/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go @@ -59,6 +59,34 @@ func (_m *OutgoingOAuthConnectionInterface) GetConnection(rctx request.CTX, id s return r0, r1 } +// GetConnectionForAudience provides a mock function with given fields: rctx, url +func (_m *OutgoingOAuthConnectionInterface) GetConnectionForAudience(rctx request.CTX, url string) (*model.OutgoingOAuthConnection, *model.AppError) { + ret := _m.Called(rctx, url) + + var r0 *model.OutgoingOAuthConnection + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.OutgoingOAuthConnection, *model.AppError)); ok { + return rf(rctx, url) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.OutgoingOAuthConnection); ok { + r0 = rf(rctx, url) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnection) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, url) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetConnections provides a mock function with given fields: rctx, filters func (_m *OutgoingOAuthConnectionInterface) GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, *model.AppError) { ret := _m.Called(rctx, filters) @@ -87,6 +115,34 @@ func (_m *OutgoingOAuthConnectionInterface) GetConnections(rctx request.CTX, fil return r0, r1 } +// RetrieveTokenForConnection provides a mock function with given fields: rctx, conn +func (_m *OutgoingOAuthConnectionInterface) RetrieveTokenForConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError) { + ret := _m.Called(rctx, conn) + + var r0 *model.OutgoingOAuthConnectionToken + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError)); ok { + return rf(rctx, conn) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) *model.OutgoingOAuthConnectionToken); ok { + r0 = rf(rctx, conn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnectionToken) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnection) *model.AppError); ok { + r1 = rf(rctx, conn) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // SanitizeConnection provides a mock function with given fields: conn func (_m *OutgoingOAuthConnectionInterface) SanitizeConnection(conn *model.OutgoingOAuthConnection) { _m.Called(conn) diff --git a/server/einterfaces/outgoing_oauth_connection.go b/server/einterfaces/outgoing_oauth_connection.go index b9264e1a26..5cd41960f5 100644 --- a/server/einterfaces/outgoing_oauth_connection.go +++ b/server/einterfaces/outgoing_oauth_connection.go @@ -17,4 +17,7 @@ type OutgoingOAuthConnectionInterface interface { SanitizeConnection(conn *model.OutgoingOAuthConnection) SanitizeConnections(conns []*model.OutgoingOAuthConnection) + + GetConnectionForAudience(rctx request.CTX, url string) (*model.OutgoingOAuthConnection, *model.AppError) + RetrieveTokenForConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError) } diff --git a/server/enterprise/external_imports.go b/server/enterprise/external_imports.go index ea6bef7c39..ae02565354 100644 --- a/server/enterprise/external_imports.go +++ b/server/enterprise/external_imports.go @@ -42,4 +42,6 @@ import ( _ "github.com/mattermost/enterprise/license" // Needed to ensure the init() method in the EE gets run _ "github.com/mattermost/enterprise/ip_filtering" + // Needed to ensure the init() method in the EE gets run + _ "github.com/mattermost/enterprise/outgoing_oauth_connections" ) diff --git a/server/i18n/en.json b/server/i18n/en.json index 195bd1ff93..e4a4c0a5d3 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1661,6 +1661,18 @@ "id": "api.context.mfa_required.app_error", "translation": "Multi-factor authentication is required on this server." }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.app_error", + "translation": "There was an error while creating the outgoing OAuth connection." + }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.input_error", + "translation": "Invalid input parameters." + }, + { + "id": "api.context.outgoing_oauth_connection.delete_connection.app_error", + "translation": "There was an error while deleting the outgoing OAuth connection." + }, { "id": "api.context.outgoing_oauth_connection.list_connections.app_error", "translation": "There was an error while listing outgoing OAuth connections." @@ -1670,8 +1682,24 @@ "translation": "Invalid input parameters." }, { - "id": "api.context.outgoing_oauth_connection.not_available.feature_flag", - "translation": "This feature is restricted by a feature flag." + "id": "api.context.outgoing_oauth_connection.not_available.configuration_disabled", + "translation": "Outgoing OAuth connections are not available on this server." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.app_error", + "translation": "There was an error while updating the outgoing OAuth connection." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.input_error", + "translation": "Invalid input parameters." + }, + { + "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", + "translation": "There was an error while validating the outgoing OAuth connection credentials." + }, + { + "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", + "translation": "Couldn't retrieve credentials with the specified connection configuration." }, { "id": "api.context.permissions.app_error", @@ -8302,10 +8330,26 @@ "id": "ent.migration.migratetosaml.username_already_used_by_other_user", "translation": "Username already used by another Mattermost user." }, + { + "id": "ent.outgoing_oauth_connections.authenticate.app_error", + "translation": "There was an error while authenticating the outgoing oauth connection: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.app_error", + "translation": "There is already an outgoing oauth connection for the provided audience." + }, + { + "id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.not_found", + "translation": "There is no outgoing oauth connection for the provided audience." + }, { "id": "ent.outgoing_oauth_connections.delete_connection.app_error", "translation": "There was an error while deleting the outgoing oauth connection." }, + { + "id": "ent.outgoing_oauth_connections.feature_disabled", + "translation": "Outgoing OAuth connections are not available on this server." + }, { "id": "ent.outgoing_oauth_connections.get_connection.app_error", "translation": "There was an error retrieving the outgoing oauth connection." @@ -8314,17 +8358,45 @@ "id": "ent.outgoing_oauth_connections.get_connection.not_found.app_error", "translation": "The outgoing oauth connection was not found." }, + { + "id": "ent.outgoing_oauth_connections.get_connection_for_audience.app_error", + "translation": "There was an error retrieving the outgoing oauth connection for the audience." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection_for_audience.not_found.app_error", + "translation": "The outgoing oauth connection for the provided audience was not found." + }, { "id": "ent.outgoing_oauth_connections.get_connections.app_error", "translation": "There was an error retrieving the outgoing oauth connections." }, + { + "id": "ent.outgoing_oauth_connections.license_disable.app_error", + "translation": "Your license does not support outgoing oauth connections." + }, { "id": "ent.outgoing_oauth_connections.save_connection.app_error", - "translation": "There was an error saving the outgoing oauth connection." + "translation": "There was an error saving the outgoing oauth connection: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.audience_duplicated", + "translation": "There is already an outgoing oauth connection for the provided audience: {{ .Audience }}" + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.audience_invalid", + "translation": "The provided audience is invalid: {{ .Error }}" }, { "id": "ent.outgoing_oauth_connections.update_connection.app_error", - "translation": "There was an error updating the outgoing oauth connection." + "translation": "There was an error updating the outgoing oauth connection: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.audience_duplicated", + "translation": "There is already an outgoing oauth connection for the provided audience: {{ .Audience }}" + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.audience_invalid", + "translation": "The provided audience is invalid: {{ .Error }}" }, { "id": "ent.saml.attribute.app_error", @@ -9668,7 +9740,7 @@ }, { "id": "model.outgoing_oauth_connection.is_valid.audience.error", - "translation": "Some audience URL is incorrect." + "translation": "Audience URL is invalid: {{ .Url }}" }, { "id": "model.outgoing_oauth_connection.is_valid.client_id.error", diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 9ba4420bc2..4ce8be5ced 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -410,6 +410,7 @@ func (ts *TelemetryService) trackConfig() { "enable_insecure_outgoing_connections": *cfg.ServiceSettings.EnableInsecureOutgoingConnections, "enable_incoming_webhooks": cfg.ServiceSettings.EnableIncomingWebhooks, "enable_outgoing_webhooks": cfg.ServiceSettings.EnableOutgoingWebhooks, + "enable_outgoing_oauth_connections": cfg.ServiceSettings.EnableOutgoingOAuthConnections, "enable_commands": *cfg.ServiceSettings.EnableCommands, "outgoing_integrations_requests_timeout": cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout, "enable_post_username_override": cfg.ServiceSettings.EnablePostUsernameOverride, diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 6cfa9b4eaa..f2999c19a8 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -485,7 +485,7 @@ func (c *Client4) outgoingOAuthConnectionsRoute() string { } func (c *Client4) outgoingOAuthConnectionRoute(id string) string { - return fmt.Sprintf("/oauth/outgoing_connections/%s", id) + return fmt.Sprintf("%s/%s", c.outgoingOAuthConnectionsRoute(), id) } func (c *Client4) jobsRoute() string { @@ -6023,8 +6023,8 @@ func (c *Client4) GetOAuthAccessToken(ctx context.Context, data url.Values) (*Ac // OutgoingOAuthConnection section // GetOutgoingOAuthConnections retrieves the outgoing OAuth connections. -func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, fromID string, limit int) ([]*OutgoingOAuthConnection, *Response, error) { - r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute(), "") +func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, filters OutgoingOAuthConnectionGetConnectionsFilter) ([]*OutgoingOAuthConnection, *Response, error) { + r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute()+"?"+filters.ToURLValues().Encode(), "") if err != nil { return nil, BuildResponse(r), err } @@ -6050,6 +6050,53 @@ func (c *Client4) GetOutgoingOAuthConnection(ctx context.Context, id string) (*O return connection, BuildResponse(r), nil } +// DeleteOutgoingOAuthConnection deletes the outgoing OAuth connection with the given ID. +func (c *Client4) DeleteOutgoingOAuthConnection(ctx context.Context, id string) (*Response, error) { + r, err := c.DoAPIDelete(ctx, c.outgoingOAuthConnectionRoute(id)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + +// UpdateOutgoingOAuthConnection updates the outgoing OAuth connection with the given ID. +func (c *Client4) UpdateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) { + buf, err := json.Marshal(connection) + if err != nil { + return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPutBytes(ctx, c.outgoingOAuthConnectionRoute(connection.Id), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var resultConnection OutgoingOAuthConnection + if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil { + return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &resultConnection, BuildResponse(r), nil +} + +// CreateOutgoingOAuthConnection creates a new outgoing OAuth connection. +func (c *Client4) CreateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) { + buf, err := json.Marshal(connection) + if err != nil { + return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(ctx, c.outgoingOAuthConnectionsRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var resultConnection OutgoingOAuthConnection + if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil { + return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &resultConnection, BuildResponse(r), nil +} + // Elasticsearch Section // TestElasticsearch will attempt to connect to the configured Elasticsearch server and return OK if configured. diff --git a/server/public/model/config.go b/server/public/model/config.go index c658f06eb3..864663f581 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -312,6 +312,7 @@ type ServiceSettings struct { EnableOAuthServiceProvider *bool `access:"integrations_integration_management"` EnableIncomingWebhooks *bool `access:"integrations_integration_management"` EnableOutgoingWebhooks *bool `access:"integrations_integration_management"` + EnableOutgoingOAuthConnections *bool `access:"integrations_integration_management"` EnableCommands *bool `access:"integrations_integration_management"` OutgoingIntegrationRequestsTimeout *int64 `access:"integrations_integration_management"` // In seconds. EnablePostUsernameOverride *bool `access:"integrations_integration_management"` @@ -515,6 +516,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { s.EnableOutgoingWebhooks = NewBool(true) } + if s.EnableOutgoingOAuthConnections == nil { + s.EnableOutgoingOAuthConnections = NewBool(false) + } + if s.OutgoingIntegrationRequestsTimeout == nil { s.OutgoingIntegrationRequestsTimeout = NewInt64(OutgoingIntegrationRequestsDefaultTimeout) } diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 4adcd9ebf8..bc4ac21ec9 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -50,8 +50,6 @@ type FeatureFlags struct { ConsumePostHook bool CloudAnnualRenewals bool - - OutgoingOAuthConnections bool } func (f *FeatureFlags) SetDefaults() { @@ -71,7 +69,6 @@ func (f *FeatureFlags) SetDefaults() { f.CloudIPFiltering = false f.ConsumePostHook = false f.CloudAnnualRenewals = false - f.OutgoingOAuthConnections = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/model/migration.go b/server/public/model/migration.go index 817e49040f..89c8d24094 100644 --- a/server/public/model/migration.go +++ b/server/public/model/migration.go @@ -46,4 +46,5 @@ const ( MigrationKeyDeleteEmptyDrafts = "delete_empty_drafts_migration" MigrationKeyDeleteOrphanDrafts = "delete_orphan_drafts_migration" MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions" + MigrationKeyAddOutgoingOAuthConnectionsPermissions = "add_outgoing_oauth_connections_permissions" ) diff --git a/server/public/model/outgoing_oauth_connection.go b/server/public/model/outgoing_oauth_connection.go index 30afc29665..ff4c41cf54 100644 --- a/server/public/model/outgoing_oauth_connection.go +++ b/server/public/model/outgoing_oauth_connection.go @@ -4,7 +4,9 @@ package model import ( + "fmt" "net/http" + "net/url" "unicode/utf8" ) @@ -49,12 +51,42 @@ func (oa *OutgoingOAuthConnection) Auditable() map[string]interface{} { // Sanitize removes any sensitive fields from the OutgoingOAuthConnection object. func (oa *OutgoingOAuthConnection) Sanitize() { - oa.ClientId = "" oa.ClientSecret = "" - oa.CredentialsUsername = nil oa.CredentialsPassword = nil } +// Patch updates the OutgoingOAuthConnection object with the non-empty fields from the given connection. +func (oa *OutgoingOAuthConnection) Patch(conn *OutgoingOAuthConnection) { + if conn == nil { + return + } + + if conn.Name != "" { + oa.Name = conn.Name + } + if conn.ClientId != "" { + oa.ClientId = conn.ClientId + } + if conn.ClientSecret != "" { + oa.ClientSecret = conn.ClientSecret + } + if conn.OAuthTokenURL != "" { + oa.OAuthTokenURL = conn.OAuthTokenURL + } + if conn.GrantType != "" { + oa.GrantType = conn.GrantType + } + if len(conn.Audiences) > 0 { + oa.Audiences = conn.Audiences + } + if conn.CredentialsUsername != nil { + oa.CredentialsUsername = conn.CredentialsUsername + } + if conn.CredentialsPassword != nil { + oa.CredentialsPassword = conn.CredentialsPassword + } +} + // IsValid validates the object and returns an error if it isn't properly configured func (oa *OutgoingOAuthConnection) IsValid() *AppError { if !IsValidId(oa.Id) { @@ -85,11 +117,11 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.client_secret.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if oa.OAuthTokenURL == "" || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 { + if !IsValidHTTPURL(oa.OAuthTokenURL) || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.oauth_token_url.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if err := oa.IsValidGrantType(); err != nil { + if err := oa.HasValidGrantType(); err != nil { return err } @@ -100,7 +132,7 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError { if len(oa.Audiences) > 0 { for _, audience := range oa.Audiences { if !IsValidHTTPURL(audience) { - return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", nil, "id="+oa.Id, http.StatusBadRequest) + return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", map[string]any{"Url": audience}, "id="+oa.Id, http.StatusBadRequest) } } } @@ -108,8 +140,8 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError { return nil } -// IsValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured -func (oa *OutgoingOAuthConnection) IsValidGrantType() *AppError { +// HasValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured +func (oa *OutgoingOAuthConnection) HasValidGrantType() *AppError { if !oa.GrantType.IsValid() { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.grant_type.error", nil, "id="+oa.Id, http.StatusBadRequest) } @@ -149,6 +181,12 @@ func (oa *OutgoingOAuthConnection) Etag() string { type OutgoingOAuthConnectionGetConnectionsFilter struct { OffsetId string Limit int + Audience string + + // TeamId is not used as a filter but as a way to check if the current user has permission to + // access the outgoing oauth connection for the given team in order to use them in the slash + // commands and outgoing webhooks. + TeamId string } // SetDefaults sets the default values for the filter @@ -157,3 +195,36 @@ func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) SetDefaults() { oaf.Limit = defaultGetConnectionsLimit } } + +// ToURLValues converts the filter to url.Values +func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) ToURLValues() url.Values { + v := url.Values{} + + if oaf.Limit > 0 { + v.Set("limit", fmt.Sprintf("%d", oaf.Limit)) + } + + if oaf.OffsetId != "" { + v.Set("offset_id", oaf.OffsetId) + } + + if oaf.Audience != "" { + v.Set("audience", oaf.Audience) + } + + if oaf.TeamId != "" { + v.Set("team_id", oaf.TeamId) + } + return v +} + +// OutgoingOAuthConnectionToken is used to return the token for an outgoing connection oauth +// authentication request +type OutgoingOAuthConnectionToken struct { + AccessToken string + TokenType string +} + +func (ooct *OutgoingOAuthConnectionToken) AsHeaderValue() string { + return ooct.TokenType + " " + ooct.AccessToken +} diff --git a/server/public/model/outgoing_oauth_connection_test.go b/server/public/model/outgoing_oauth_connection_test.go index a46930c4e8..cda8da0741 100644 --- a/server/public/model/outgoing_oauth_connection_test.go +++ b/server/public/model/outgoing_oauth_connection_test.go @@ -13,16 +13,18 @@ var ( func newValidOutgoingOAuthConnection() *OutgoingOAuthConnection { return &OutgoingOAuthConnection{ - Id: NewId(), - CreatorId: NewId(), - Name: "Test Connection", - ClientId: NewId(), - ClientSecret: NewId(), - OAuthTokenURL: "https://nowhere.com/oauth/token", - GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials, - CreateAt: GetMillis(), - UpdateAt: GetMillis(), - Audiences: []string{"https://nowhere.com"}, + Id: NewId(), + CreatorId: NewId(), + Name: "Test Connection", + ClientId: NewId(), + ClientSecret: NewId(), + CredentialsUsername: NewString(NewId()), + CredentialsPassword: NewString(NewId()), + OAuthTokenURL: "https://nowhere.com/oauth/token", + GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials, + CreateAt: GetMillis(), + UpdateAt: GetMillis(), + Audiences: []string{"https://nowhere.com"}, } } @@ -318,8 +320,66 @@ func TestOutgoingOAuthConnectionSanitize(t *testing.T) { oa := newValidOutgoingOAuthConnection() oa.Sanitize() - require.Empty(t, oa.ClientId) + require.NotEmpty(t, oa.ClientId) require.Empty(t, oa.ClientSecret) - require.Empty(t, oa.CredentialsUsername) + require.NotEmpty(t, oa.CredentialsUsername) require.Empty(t, oa.CredentialsPassword) } + +func TestOutgoingOAuthConnectionPatch(t *testing.T) { + t.Run("name", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{Name: "new name"}) + + require.Equal(t, "new name", oa.Name) + }) + + t.Run("client_id", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{ClientId: "new client id"}) + + require.Equal(t, "new client id", oa.ClientId) + }) + + t.Run("client_secret", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{ClientSecret: "new client secret"}) + + require.Equal(t, "new client secret", oa.ClientSecret) + }) + + t.Run("oauth_token_url", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{OAuthTokenURL: "new oauth token url"}) + + require.Equal(t, "new oauth token url", oa.OAuthTokenURL) + }) + + t.Run("grant_type", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{GrantType: OutgoingOAuthConnectionGrantTypePassword}) + + require.Equal(t, OutgoingOAuthConnectionGrantTypePassword, oa.GrantType) + }) + + t.Run("audiences", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{Audiences: StringArray{"new audience"}}) + + require.Equal(t, StringArray{"new audience"}, oa.Audiences) + }) + + t.Run("credentials_username", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{CredentialsUsername: &someString}) + + require.Equal(t, &someString, oa.CredentialsUsername) + }) + + t.Run("credentials_password", func(t *testing.T) { + oa := newValidOutgoingOAuthConnection() + oa.Patch(&OutgoingOAuthConnection{CredentialsPassword: &someString}) + + require.Equal(t, &someString, oa.CredentialsPassword) + }) +} diff --git a/server/public/model/permission.go b/server/public/model/permission.go index ad34ae8760..c3252c8c24 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -388,6 +388,8 @@ var ChannelModeratedPermissionsMap map[string]string var SysconsoleReadPermissions []*Permission var SysconsoleWritePermissions []*Permission +var PermissionManageOutgoingOAuthConnections *Permission + func initializePermissions() { PermissionInviteUser = &Permission{ "invite_user", @@ -2125,6 +2127,13 @@ func initializePermissions() { PermissionScopeSystem, } + PermissionManageOutgoingOAuthConnections = &Permission{ + "manage_outgoing_oauth_connections", + "authentication.permissions.manage_outgoing_oauth_connections.name", + "authentication.permissions.manage_outgoing_oauth_connections.description", + PermissionScopeSystem, + } + SysconsoleReadPermissions = []*Permission{ PermissionSysconsoleReadAboutEditionAndLicense, PermissionSysconsoleReadBilling, @@ -2317,6 +2326,7 @@ func initializePermissions() { PermissionReadLicenseInformation, PermissionManageLicenseInformation, PermissionCreateCustomGroup, + PermissionManageOutgoingOAuthConnections, } TeamScopedPermissions := []*Permission{ diff --git a/server/public/model/role.go b/server/public/model/role.go index d79a561937..454a91bb57 100644 --- a/server/public/model/role.go +++ b/server/public/model/role.go @@ -342,6 +342,7 @@ func init() { PermissionSysconsoleWriteIntegrationsCors.Id, PermissionSysconsoleReadProductsBoards.Id, PermissionSysconsoleWriteProductsBoards.Id, + PermissionManageOutgoingOAuthConnections.Id, } SystemCustomGroupAdminDefaultPermissions = []string{ diff --git a/webapp/channels/src/actions/integration_actions.tsx b/webapp/channels/src/actions/integration_actions.tsx index 5ec363c963..5185b729e9 100644 --- a/webapp/channels/src/actions/integration_actions.tsx +++ b/webapp/channels/src/actions/integration_actions.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp} from '@mattermost/types/integrations'; +import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations'; import * as IntegrationActions from 'mattermost-redux/actions/integrations'; import {getProfilesByIds} from 'mattermost-redux/actions/users'; @@ -137,3 +137,34 @@ export function loadProfilesForOAuthApps(apps: OAuthApp[]): ActionFuncAsync { return {data: null}; }; } + +export function loadOutgoingOAuthConnectionsAndProfiles(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync { + return async (dispatch) => { + const {data} = await dispatch(IntegrationActions.getOutgoingOAuthConnections(teamId, page, perPage)); + if (data) { + dispatch(loadProfilesForOutgoingOAuthConnections(data)); + } + return {data: null}; + }; +} + +export function loadProfilesForOutgoingOAuthConnections(connections: OutgoingOAuthConnection[]): ActionFuncAsync { + return async (dispatch, getState) => { + const state = getState(); + const profilesToLoad: {[key: string]: boolean} = {}; + for (let i = 0; i < connections.length; i++) { + const app = connections[i]; + if (!getUser(state, app.creator_id)) { + profilesToLoad[app.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return {data: null}; + } + + dispatch(getProfilesByIds(list)); + return {data: null}; + }; +} diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 8fe106e8a5..52fcd76135 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -5179,6 +5179,19 @@ const AdminDefinition: AdminDefinitionType = { help_text_markdown: false, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.INTEGRATION_MANAGEMENT)), }, + { + type: 'bool', + key: 'ServiceSettings.EnableOutgoingOAuthConnections', + label: defineMessage({id: 'admin.service.outgoingOAuthConnectionsTitle', defaultMessage: 'Enable Outgoing OAuth Connections: '}), + help_text: defineMessage({id: 'admin.service.outgoingOAuthConnectionsDesc', defaultMessage: 'When true, outgoing webhooks and slash commands will use set up oauth connections to authenticate with third party services. See documentation to learn more.'}), + help_text_values: { + link: (text: string) => ( + {text} + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.INTEGRATION_MANAGEMENT)), + }, { type: 'bool', key: 'ServiceSettings.EnableCommands', diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx index b74c1f5bb5..828414cfec 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx @@ -222,6 +222,9 @@ export default class PermissionsTree extends React.PureComponent { if (config.EnableOAuthServiceProvider === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_OAUTH)) { integrationsGroup.permissions.push(Permissions.MANAGE_OAUTH); } + if (config.EnableOutgoingOAuthConnections === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS)) { + integrationsGroup.permissions.push(Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS); + } if (config.EnableCommands === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_SLASH_COMMANDS)) { integrationsGroup.permissions.push(Permissions.MANAGE_SLASH_COMMANDS); } @@ -234,6 +237,7 @@ export default class PermissionsTree extends React.PureComponent { if (config.EnableCustomEmoji === 'true' && !integrationsGroup.permissions.includes(Permissions.DELETE_OTHERS_EMOJIS)) { integrationsGroup.permissions.push(Permissions.DELETE_OTHERS_EMOJIS); } + if (config.EnableGuestAccounts === 'true' && !teamsGroup.permissions.includes(Permissions.INVITE_GUEST)) { teamsGroup.permissions.push(Permissions.INVITE_GUEST); } diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx index 65e4589660..745b2d8b9d 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx @@ -615,4 +615,14 @@ export const permissionRolesStrings: Record { @@ -179,6 +183,24 @@ const BackstageController = (props: Props) => { path={`${props.match.url}/oauth2-apps/edit`} component={EditOauthApp} /> + + + { id: 'team-id', name: 'team_name', }), - user: TestHelper.getUserMock({}), enableCustomEmoji: false, enableIncomingWebhooks: false, enableOutgoingWebhooks: false, @@ -24,6 +23,7 @@ describe('components/backstage/components/BackstageSidebar', () => { enableOAuthServiceProvider: false, canCreateOrDeleteCustomEmoji: false, canManageIntegrations: false, + enableOutgoingOAuthConnections: false, }; describe('custom emoji', () => { @@ -146,6 +146,30 @@ describe('components/backstage/components/BackstageSidebar', () => { }); }); + describe('outgoing oauth connections', () => { + const testCases = [ + {canManageIntegrations: false, enableOutgoingOAuthConnections: false, expectedResult: false}, + {canManageIntegrations: false, enableOutgoingOAuthConnections: true, expectedResult: false}, + {canManageIntegrations: true, enableOutgoingOAuthConnections: false, expectedResult: false}, + {canManageIntegrations: true, enableOutgoingOAuthConnections: true, expectedResult: true}, + ]; + + testCases.forEach((testCase) => { + it(`when outgoing oauth connections is ${testCase.enableOutgoingOAuthConnections} and can manage integrations is ${testCase.canManageIntegrations}`, () => { + const props = { + ...defaultProps, + enableOutgoingOAuthConnections: testCase.enableOutgoingOAuthConnections, + canManageIntegrations: testCase.canManageIntegrations, + }; + const wrapper = shallow( + , + ); + + expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(testCase.expectedResult); + }); + }); + }); + describe('bots', () => { const testCases = [ {canManageIntegrations: false, expectedResult: false}, @@ -176,6 +200,7 @@ describe('components/backstage/components/BackstageSidebar', () => { enableCommands: true, enableOAuthServiceProvider: true, canManageIntegrations: true, + enableOutgoingOAuthConnections: true, }; const wrapper = shallow( , @@ -185,6 +210,7 @@ describe('components/backstage/components/BackstageSidebar', () => { expect(wrapper.find(BackstageCategory).find({name: 'outgoing_webhooks'}).exists()).toBe(true); expect(wrapper.find(BackstageCategory).find({name: 'commands'}).exists()).toBe(true); expect(wrapper.find(BackstageCategory).find({name: 'oauth2-apps'}).exists()).toBe(true); + expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(true); expect(wrapper.find(BackstageCategory).find({name: 'bots'}).exists()).toBe(true); }); @@ -195,6 +221,7 @@ describe('components/backstage/components/BackstageSidebar', () => { enableOutgoingWebhooks: true, enableCommands: true, enableOAuthServiceProvider: true, + enableOutgoingOAuthConnections: true, canManageIntegrations: false, }; const wrapper = shallow( @@ -205,6 +232,7 @@ describe('components/backstage/components/BackstageSidebar', () => { expect(wrapper.find(BackstageCategory).find({name: 'outgoing_webhooks'}).exists()).toBe(false); expect(wrapper.find(BackstageCategory).find({name: 'commands'}).exists()).toBe(false); expect(wrapper.find(BackstageCategory).find({name: 'oauth2-apps'}).exists()).toBe(false); + expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(false); expect(wrapper.find(BackstageCategory).find({name: 'bots'}).exists()).toBe(false); }); }); diff --git a/webapp/channels/src/components/backstage/components/backstage_sidebar.tsx b/webapp/channels/src/components/backstage/components/backstage_sidebar.tsx index 7f290af5f7..a27d1e9252 100644 --- a/webapp/channels/src/components/backstage/components/backstage_sidebar.tsx +++ b/webapp/channels/src/components/backstage/components/backstage_sidebar.tsx @@ -5,7 +5,6 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import type {Team} from '@mattermost/types/teams'; -import type {UserProfile} from '@mattermost/types/users'; import {Permissions} from 'mattermost-redux/constants'; @@ -17,12 +16,12 @@ import BackstageSection from './backstage_section'; type Props = { team: Team; - user: UserProfile; enableCustomEmoji: boolean; enableIncomingWebhooks: boolean; enableOutgoingWebhooks: boolean; enableCommands: boolean; enableOAuthServiceProvider: boolean; + enableOutgoingOAuthConnections: boolean; canCreateOrDeleteCustomEmoji: boolean; canManageIntegrations: boolean; } @@ -156,6 +155,28 @@ export default class BackstageSidebar extends React.PureComponent { ); + let outgoingOAuthConnections: JSX.Element | null = null; + if (this.props.enableOutgoingOAuthConnections) { + outgoingOAuthConnections = ( + + + } + id='outgoingOauthConnections' + /> + + ); + } + return ( { {commands} {oauthApps} {botAccounts} + {outgoingOAuthConnections} ); } diff --git a/webapp/channels/src/components/backstage/index.ts b/webapp/channels/src/components/backstage/index.ts index 68600146dd..73e9e65e7d 100644 --- a/webapp/channels/src/components/backstage/index.ts +++ b/webapp/channels/src/components/backstage/index.ts @@ -26,6 +26,7 @@ function mapStateToProps(state: GlobalState) { const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true'; const enableCommands = config.EnableCommands === 'true'; const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true'; + const enableOutgoingOAuthConnections = config.EnableOutgoingOAuthConnections === 'true'; let canCreateOrDeleteCustomEmoji = (haveISystemPermission(state, {permission: Permissions.CREATE_EMOJIS}) || haveISystemPermission(state, {permission: Permissions.DELETE_EMOJIS})); if (!canCreateOrDeleteCustomEmoji) { @@ -37,7 +38,7 @@ function mapStateToProps(state: GlobalState) { } } - const canManageTeamIntegrations = (haveITeamPermission(state, '', Permissions.MANAGE_SLASH_COMMANDS) || haveITeamPermission(state, '', Permissions.MANAGE_OAUTH) || haveITeamPermission(state, '', Permissions.MANAGE_INCOMING_WEBHOOKS) || haveITeamPermission(state, '', Permissions.MANAGE_OUTGOING_WEBHOOKS)); + const canManageTeamIntegrations = (haveITeamPermission(state, team.id, Permissions.MANAGE_SLASH_COMMANDS) || haveITeamPermission(state, team.id, Permissions.MANAGE_OAUTH) || haveITeamPermission(state, team.id, Permissions.MANAGE_INCOMING_WEBHOOKS) || haveITeamPermission(state, team.id, Permissions.MANAGE_OUTGOING_WEBHOOKS)); const canManageSystemBots = (haveISystemPermission(state, {permission: Permissions.MANAGE_BOTS}) || haveISystemPermission(state, {permission: Permissions.MANAGE_OTHERS_BOTS})); const canManageIntegrations = canManageTeamIntegrations || canManageSystemBots; @@ -50,6 +51,7 @@ function mapStateToProps(state: GlobalState) { enableOutgoingWebhooks, enableCommands, enableOAuthServiceProvider, + enableOutgoingOAuthConnections, canCreateOrDeleteCustomEmoji, canManageIntegrations, }; diff --git a/webapp/channels/src/components/integrations/__snapshots__/abstract_command.test.tsx.snap b/webapp/channels/src/components/integrations/__snapshots__/abstract_command.test.tsx.snap index 1ab86adfb1..eaff78757b 100644 --- a/webapp/channels/src/components/integrations/__snapshots__/abstract_command.test.tsx.snap +++ b/webapp/channels/src/components/integrations/__snapshots__/abstract_command.test.tsx.snap @@ -169,13 +169,9 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
-
+
+ +
-
+
+ +
-
+
+ +
-
- +
+ display_name - - - /trigger auto_complete_hint -
+ + - /trigger auto_complete_hint +
@@ -116,18 +116,18 @@ exports[`components/integrations/InstalledCommand should call onRegenToken funct
-
- +
+ display_name - - - /trigger auto_complete_hint -
+ + - /trigger auto_complete_hint +
@@ -224,18 +224,18 @@ exports[`components/integrations/InstalledCommand should match snapshot 1`] = `
-
- +
+ display_name - - - /trigger auto_complete_hint -
+ + - /trigger auto_complete_hint +
-
- +
+ command_display_name - - - /trigger -
+ + - /trigger +
{ this.state = this.getStateFromCommand(this.props.initialCommand || {}); } - getStateFromCommand = (command: Props['initialCommand']) => { + getStateFromCommand = (command: Props['initialCommand']): State => { return { displayName: command?.display_name ?? '', description: command?.description ?? '', @@ -523,11 +525,7 @@ export class AbstractCommand extends React.PureComponent { />
- { defaultMessage='Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run.' />
+
+ outgoing OAuth connections.'} + values={{ + link: (text: string) => ( + {text} + ), + }} + /> +
diff --git a/webapp/channels/src/components/integrations/abstract_outgoing_webhook.test.tsx b/webapp/channels/src/components/integrations/abstract_outgoing_webhook.test.tsx index 6104ee5f20..df02bc21f2 100644 --- a/webapp/channels/src/components/integrations/abstract_outgoing_webhook.test.tsx +++ b/webapp/channels/src/components/integrations/abstract_outgoing_webhook.test.tsx @@ -148,7 +148,6 @@ describe('components/integrations/AbstractOutgoingWebhook', () => { const selector = wrapper.find('#triggerWhen'); selector.simulate('change', {target: {value: 1}}); - console.log('selector: ', selector.debug()); expect(wrapper.state('triggerWhen')).toBe(1); }); diff --git a/webapp/channels/src/components/integrations/bots/bot.tsx b/webapp/channels/src/components/integrations/bots/bot.tsx index 37ec5c420b..4aac96d177 100644 --- a/webapp/channels/src/components/integrations/bots/bot.tsx +++ b/webapp/channels/src/components/integrations/bots/bot.tsx @@ -522,6 +522,7 @@ export default class Bot extends React.PureComponent { defaultMessage='Delete' /> } + modalClass='integrations-backstage-modal' show={this.state.confirmingId !== ''} onConfirm={this.revokeTokenConfirmed} onCancel={this.closeConfirm} diff --git a/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap b/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap index 7812a3dc62..cfb605f3bd 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap +++ b/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap @@ -347,3 +347,114 @@ exports[`components/integrations/ConfirmIntegration should match snapshot, outgo
`; + +exports[`components/integrations/ConfirmIntegration should match snapshot, outgoingOAuthConnections case 1`] = ` +
+ + + + + + +
+

+ +

+

+ +

+

+ +
+ +

+

+ +

+

+ +

+
+ + + +
+
+
+`; diff --git a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.test.tsx b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.test.tsx index a25cd41d62..4a4dd89d2d 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.test.tsx +++ b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.test.tsx @@ -5,7 +5,7 @@ import {shallow} from 'enzyme'; import React from 'react'; import type {Bot} from '@mattermost/types/bots'; -import type {IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations'; +import type {IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations'; import type {IDMappedObjects} from '@mattermost/types/utilities'; import ConfirmIntegration from 'components/integrations/confirm_integration/confirm_integration'; @@ -43,6 +43,16 @@ describe('components/integrations/ConfirmIntegration', () => { client_secret: '<==secret==>', callback_urls: ['https://someCallback', 'https://anotherCallback'], }; + const outgoingOAuthConnection = { + id, + audiences: ['https://myaudience.com'], + client_id: 'someid', + client_secret: '', + grant_type: 'client_credentials', + name: 'My OAuth Connection', + oauth_token_url: 'https://tokenurl.com', + }; + const userId = 'b5tpgt4iepf45jt768jz84djhd'; const bot = TestHelper.getBotMock({ user_id: userId, @@ -50,6 +60,7 @@ describe('components/integrations/ConfirmIntegration', () => { }); const commands = {[id]: TestHelper.getCommandMock({id, token})}; const oauthApps = {[id]: oauthApp} as unknown as IDMappedObjects; + const outgoingOAuthConnections = {[id]: outgoingOAuthConnection} as unknown as IDMappedObjects; const incomingHooks: IDMappedObjects = {[id]: TestHelper.getIncomingWebhookMock({id})}; const outgoingHooks = {[id]: {id, token}} as unknown as IDMappedObjects; const bots: Record = {[userId]: bot}; @@ -59,6 +70,7 @@ describe('components/integrations/ConfirmIntegration', () => { location, commands, oauthApps, + outgoingOAuthConnections, incomingHooks, outgoingHooks, bots, @@ -82,6 +94,14 @@ describe('components/integrations/ConfirmIntegration', () => { expect(container.querySelector('.word-break--all')).toHaveTextContent('URL(s): https://someCallback, https://anotherCallback'); }); + test('should match snapshot, outgoingOAuthConnections case', () => { + props.location.search = getSearchString('outgoing-oauth2-connections'); + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + test('should match snapshot, commands case', () => { props.location.search = getSearchString('commands'); const wrapper = shallow( diff --git a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx index 639ac46632..cdeb15b686 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx +++ b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx @@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl'; import {Link, useHistory} from 'react-router-dom'; import type {Bot} from '@mattermost/types/bots'; -import type {Command, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations'; +import type {Command, IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations'; import type {Team} from '@mattermost/types/teams'; import type {IDMappedObjects} from '@mattermost/types/utilities'; @@ -26,9 +26,10 @@ type Props = { incomingHooks: IDMappedObjects; outgoingHooks: IDMappedObjects; bots: Record; + outgoingOAuthConnections: Record; } -const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots}: Props): JSX.Element | null => { +const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots, outgoingOAuthConnections}: Props): JSX.Element | null => { const history = useHistory(); const type = (new URLSearchParams(location.search)).get('type') || ''; @@ -56,6 +57,7 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, const incomingHook = incomingHooks[id]; const outgoingHook = outgoingHooks[id]; const oauthApp = oauthApps[id]; + const outgoingOAuthConnection = outgoingOAuthConnections[id]; const bot = bots[id]; if (type === Constants.Integrations.COMMAND && command) { @@ -243,6 +245,95 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, />

); + } else if (type === Constants.Integrations.OUTGOING_OAUTH_CONNECTIONS && outgoingOAuthConnection) { + const clientId = outgoingOAuthConnection.client_id; + const clientSecret = outgoingOAuthConnection.client_secret; + const username = outgoingOAuthConnection.credentials_username; + const password = outgoingOAuthConnection.credentials_password; + + headerText = ( + + ); + + helpText = []; + helpText.push( +

+ ( + + {msg} + + ), + }} + /> +

, + ); + helpText.push( +

+ +
+ +

, + ); + + if (outgoingOAuthConnection.grant_type === 'password') { + helpText.push( +

+ + +
+ +

, + ); + } + + tokenText = ( + <> +

+ +

+

+ +

+ + ); } else if (type === Constants.Integrations.BOT && bot) { const botToken = (new URLSearchParams(location.search)).get('token') || ''; diff --git a/webapp/channels/src/components/integrations/confirm_integration/index.ts b/webapp/channels/src/components/integrations/confirm_integration/index.ts index e9af637f41..4a427b72bf 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/index.ts +++ b/webapp/channels/src/components/integrations/confirm_integration/index.ts @@ -4,7 +4,7 @@ import {connect} from 'react-redux'; import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots'; -import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks} from 'mattermost-redux/selectors/entities/integrations'; +import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks, getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations'; import type {GlobalState} from 'types/store'; @@ -17,6 +17,7 @@ function mapStateToProps(state: GlobalState) { incomingHooks: getIncomingHooks(state), outgoingHooks: getOutgoingHooks(state), bots: getBotAccounts(state), + outgoingOAuthConnections: getOutgoingOAuthConnections(state), }; } diff --git a/webapp/channels/src/components/integrations/delete_integration_link/delete_integration_link.tsx b/webapp/channels/src/components/integrations/delete_integration_link/delete_integration_link.tsx index f22f1f34fd..59d5a768af 100644 --- a/webapp/channels/src/components/integrations/delete_integration_link/delete_integration_link.tsx +++ b/webapp/channels/src/components/integrations/delete_integration_link/delete_integration_link.tsx @@ -14,6 +14,7 @@ const ModalId = 'delete_integration_confirm'; type Props = { confirmButtonText?: React.ReactNode; linkText?: React.ReactNode; + subtitleText?: React.ReactNode; modalMessage?: React.ReactNode; modalTitle?: React.ReactNode; onDelete: () => void; @@ -25,7 +26,7 @@ export default function DeleteIntegrationLink(props: Props) { confirmButtonText = ( ), linkText = ( @@ -50,11 +51,22 @@ export default function DeleteIntegrationLink(props: Props) { modalId: ModalId, dialogProps: { confirmButtonText, + confirmButtonClass: 'btn btn-danger', + modalClass: 'integrations-backstage-modal', message: ( -
- - {props.modalMessage} -
+ <> + {props.subtitleText && ( +

+ {props.subtitleText} +

+ )} +
+ + + {props.modalMessage} + +
+ ), onConfirm: onDelete, title: modalTitle, diff --git a/webapp/channels/src/components/integrations/edit_command/__snapshots__/edit_command.test.tsx.snap b/webapp/channels/src/components/integrations/edit_command/__snapshots__/edit_command.test.tsx.snap index a73dba4e23..b4a2443e8b 100644 --- a/webapp/channels/src/components/integrations/edit_command/__snapshots__/edit_command.test.tsx.snap +++ b/webapp/channels/src/components/integrations/edit_command/__snapshots__/edit_command.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/EditCommand should have match renderExtra 1`] = id="update_command.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -85,7 +85,7 @@ exports[`components/integrations/EditCommand should match snapshot 1`] = ` id="update_command.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} diff --git a/webapp/channels/src/components/integrations/edit_command/edit_command.tsx b/webapp/channels/src/components/integrations/edit_command/edit_command.tsx index 32fc775bcc..67349e099f 100644 --- a/webapp/channels/src/components/integrations/edit_command/edit_command.tsx +++ b/webapp/channels/src/components/integrations/edit_command/edit_command.tsx @@ -157,6 +157,7 @@ export default class EditCommand extends React.PureComponent { title={confirmTitle} message={confirmMessage} confirmButtonText={confirmButton} + modalClass='integrations-backstage-modal' show={this.state.showConfirmModal} onConfirm={this.submitCommand} onCancel={this.confirmModalDismissed} diff --git a/webapp/channels/src/components/integrations/edit_incoming_webhook/edit_incoming_webhook.tsx b/webapp/channels/src/components/integrations/edit_incoming_webhook/edit_incoming_webhook.tsx index c4ba51d8a5..8ffd5056a9 100644 --- a/webapp/channels/src/components/integrations/edit_incoming_webhook/edit_incoming_webhook.tsx +++ b/webapp/channels/src/components/integrations/edit_incoming_webhook/edit_incoming_webhook.tsx @@ -65,7 +65,6 @@ type Props = { }; type State = { - showConfirmModal: boolean; serverError: string; }; @@ -76,7 +75,6 @@ export default class EditIncomingWebhook extends React.PureComponent } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -82,7 +82,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot 1`] = ` id="update_oauth_app.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -158,7 +158,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot when EnableO id="update_oauth_app.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -234,7 +234,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot, loading 1`] id="update_oauth_app.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} diff --git a/webapp/channels/src/components/integrations/edit_oauth_app/edit_oauth_app.tsx b/webapp/channels/src/components/integrations/edit_oauth_app/edit_oauth_app.tsx index 83cfa20eab..692ed10a8e 100644 --- a/webapp/channels/src/components/integrations/edit_oauth_app/edit_oauth_app.tsx +++ b/webapp/channels/src/components/integrations/edit_oauth_app/edit_oauth_app.tsx @@ -127,6 +127,7 @@ export default class EditOAuthApp extends React.PureComponent { title={confirmTitle} message={confirmMessage} confirmButtonText={confirmButton} + modalClass='integrations-backstage-modal' show={this.state.showConfirmModal} onConfirm={this.submitOAuthApp} onCancel={this.confirmModalDismissed} diff --git a/webapp/channels/src/components/integrations/edit_outgoing_webhook/__snapshots__/edit_outgoing_webhook.test.tsx.snap b/webapp/channels/src/components/integrations/edit_outgoing_webhook/__snapshots__/edit_outgoing_webhook.test.tsx.snap index cdd5427e2e..cb931ba4c5 100644 --- a/webapp/channels/src/components/integrations/edit_outgoing_webhook/__snapshots__/edit_outgoing_webhook.test.tsx.snap +++ b/webapp/channels/src/components/integrations/edit_outgoing_webhook/__snapshots__/edit_outgoing_webhook.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/EditOutgoingWebhook should have match renderExt id="update_outgoing_webhook.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -92,7 +92,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot 1`] = id="update_outgoing_webhook.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -191,7 +191,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot when id="update_outgoing_webhook.question" /> } - modalClass="" + modalClass="integrations-backstage-modal" onCancel={[Function]} onConfirm={[Function]} show={false} diff --git a/webapp/channels/src/components/integrations/edit_outgoing_webhook/edit_outgoing_webhook.tsx b/webapp/channels/src/components/integrations/edit_outgoing_webhook/edit_outgoing_webhook.tsx index 18c0df19fb..a08a7157c3 100644 --- a/webapp/channels/src/components/integrations/edit_outgoing_webhook/edit_outgoing_webhook.tsx +++ b/webapp/channels/src/components/integrations/edit_outgoing_webhook/edit_outgoing_webhook.tsx @@ -163,6 +163,7 @@ export default class EditOutgoingWebhook extends React.PureComponent {
-
- +
+ {name} - - {trigger} -
+ + {trigger} + {actions}
{description} diff --git a/webapp/channels/src/components/integrations/integrations.tsx b/webapp/channels/src/components/integrations/integrations.tsx index 3e4fdbf638..c946ad571e 100644 --- a/webapp/channels/src/components/integrations/integrations.tsx +++ b/webapp/channels/src/components/integrations/integrations.tsx @@ -15,6 +15,7 @@ import TeamPermissionGate from 'components/permissions_gates/team_permission_gat import BotAccountsIcon from 'images/bot_default_icon.png'; import IncomingWebhookIcon from 'images/incoming_webhook.jpg'; import OAuthIcon from 'images/oauth_icon.png'; +import OutgoingOAuthConnectionsIcon from 'images/outgoing_oauth_connection.png'; import OutgoingWebhookIcon from 'images/outgoing_webhook.jpg'; import SlashCommandIcon from 'images/slash_command_icon.jpg'; import * as Utils from 'utils/utils'; @@ -27,6 +28,7 @@ type Props = { enableOutgoingWebhooks: boolean; enableCommands: boolean; enableOAuthServiceProvider: boolean; + enableOutgoingOAuthConnections: boolean; team: Team; } @@ -154,6 +156,34 @@ export default class Integrations extends React.PureComponent { ); } + if (this.props.enableOutgoingOAuthConnections) { + options.push( + + + } + description={ + + } + link={'/' + this.props.team.name + '/integrations/outgoing-oauth2-connections'} + /> + , + ); + } + options.push( + + + + renderExtra +
+ } + serverError="" + submitAction={[MockFunction]} + team={ + Object { + "allow_open_invite": false, + "allowed_domains": "", + "company_name": "", + "create_at": 0, + "delete_at": 0, + "description": "", + "display_name": "name", + "email": "", + "group_constrained": false, + "id": "facxd9wpzpbpfp8pad78xj75pr", + "invite_id": "", + "name": "test", + "scheme_id": "id", + "type": "O", + "update_at": 0, + } + } + > +
+ +
+

+ + + + + + Outgoing OAuth Connections + + + + + + + + + + + Header + + +

+
+
+
+
+
+ +
+ +
+ + + Specify the name for your OAuth connection. + + +
+
+
+
+ +
+ +
+ + + Specify the Client ID for your OAuth connection. + + +
+
+
+
+ +
+ + + + +
+ + + Specify the Client Secret for your OAuth connection. + + +
+
+
+
+ +
+ +
+ + + Specify the OAuth Token URL for your OAuth connection. + + +
+
+ + + +
+
+
+
+ +
+