From b6975871c940116f5efbe53cdc19a3a440ba9fba Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas <jvargas@gitlab.com> Date: Fri, 1 Nov 2024 23:54:40 +0000 Subject: [PATCH] Add GraphQL queries and specs This connects the GraphQL queries to display the authentication logs as well as adds empty states --- .../token_access/components/auth_log.vue | 229 ++++++++++++++++++ .../components/token_access_app.vue | 3 + .../queries/get_auth_logs.query.graphql | 22 ++ app/assets/javascripts/token_access/index.js | 3 +- app/views/ci/token_access/_index.html.haml | 2 +- doc/ci/jobs/ci_job_token.md | 16 ++ locale/gitlab.pot | 23 ++ spec/frontend/token_access/auth_log_spec.js | 137 +++++++++++ spec/frontend/token_access/mock_data.js | 32 +++ 9 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/token_access/components/auth_log.vue create mode 100644 app/assets/javascripts/token_access/graphql/queries/get_auth_logs.query.graphql create mode 100644 spec/frontend/token_access/auth_log_spec.js diff --git a/app/assets/javascripts/token_access/components/auth_log.vue b/app/assets/javascripts/token_access/components/auth_log.vue new file mode 100644 index 0000000000000..5e2855d667570 --- /dev/null +++ b/app/assets/javascripts/token_access/components/auth_log.vue @@ -0,0 +1,229 @@ +<script> +import { + GlButton, + GlIcon, + GlKeysetPagination, + GlLink, + GlSprintf, + GlTableLite, + GlTooltipDirective, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__, __, sprintf, n__ } from '~/locale'; +import CrudComponent from '~/vue_shared/components/crud_component.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import getAuthLogsQuery from '../graphql/queries/get_auth_logs.query.graphql'; + +const ENTRIES_PER_PAGE = 20; + +export default { + fields: [ + { + key: 'fullPath', + label: __('Project'), + tdClass: 'gl-w-2/3', + }, + { + key: 'lastAuthorizedAt', + label: __('Date'), + tdClass: 'gl-w-1/3 gl-text-left !gl-align-middle', + }, + ], + name: 'CiJobTokensAuthLog', + tokenLogCsvFileName: 'token_log_report.csv', + ciJobTokenHelpPage: helpPagePath('ci/jobs/ci_job_token', { + anchor: 'job-token-authentication-log', + }), + components: { + GlButton, + GlIcon, + GlKeysetPagination, + GlLink, + GlSprintf, + GlTableLite, + CrudComponent, + ProjectAvatar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + fullPath: { + default: '', + }, + csvDownloadPath: { + default: '', + }, + }, + data() { + return { + authLogs: [], + cursor: { + first: ENTRIES_PER_PAGE, + after: null, + last: null, + before: null, + }, + pageInfo: {}, + logCount: 0, + }; + }, + apollo: { + authLogs: { + query: getAuthLogsQuery, + variables() { + return this.queryVariables; + }, + update({ project }) { + this.logCount = project.ciJobTokenAuthLogs?.count; + this.pageInfo = project.ciJobTokenAuthLogs?.pageInfo; + + return project.ciJobTokenAuthLogs?.nodes || []; + }, + error() { + createAlert({ + message: s__('CICD|There was a problem fetching authentication logs.'), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.authLogs.loading; + }, + showPagination() { + return (this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage) && this.logCount <= 100; + }, + queryVariables() { + return { + fullPath: this.fullPath, + ...this.cursor, + }; + }, + logCountTooltip() { + return sprintf( + n__('%{count} event has ocurred', '%{count} events have ocurred', this.logCount), + { + count: this.logCount, + }, + ); + }, + displayLogEventsTable() { + return !this.isLoading && this.logCount > 0 && this.logCount <= 100; + }, + }, + methods: { + nextPage(item) { + this.cursor = { + first: ENTRIES_PER_PAGE, + after: item, + last: null, + before: null, + }; + }, + prevPage(item) { + this.cursor = { + first: null, + after: null, + last: ENTRIES_PER_PAGE, + before: item, + }; + }, + }, +}; +</script> +<template> + <div> + <crud-component :title="s__('CICD|Authentication log')" class="gl-mt-5"> + <template #count> + <span + v-gl-tooltip + :title="logCountTooltip" + class="gl-inline-flex gl-items-center gl-gap-2 gl-text-sm gl-text-subtle" + data-testid="log-count" + > + <gl-icon name="log" /> + {{ logCount }} + </span> + </template> + <template #actions> + <gl-button + v-if="!isLoading && logCount > 0" + is-unsafe-link + size="small" + :href="csvDownloadPath" + :title="__('Download CSV')" + :download="$options.tokenLogCsvFileName" + data-testid="auth-log-download-csv-button" + >{{ __('Download CSV') }}</gl-button + > + </template> + <template #description> + <gl-sprintf + :message=" + s__( + 'CICD|Authentication events from the last 30 days. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="$options.ciJobTokenHelpPage" class="inline-link" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </template> + + <gl-table-lite + v-if="displayLogEventsTable" + :items="authLogs" + :fields="$options.fields" + :tbody-tr-attr="{ 'data-testid': 'auth-logs-table-row' }" + class="gl-mb-0" + fixed + > + <template #cell(fullPath)="{ item }"> + <div class="gl-inline-flex gl-items-center"> + <gl-icon name="project" class="gl-mr-3 gl-shrink-0" /> + <project-avatar + :alt="item.originProject.name" + :project-avatar-url="item.originProject.avatarUrl" + :project-id="item.originProject.id" + :project-name="item.originProject.name" + class="gl-mr-3" + :size="24" + /> + <span class="gl-text-gray-900">{{ item.originProject.fullPath }}</span> + </div> + </template> + </gl-table-lite> + <div + v-if="!isLoading && logCount > 100" + class="gl-text-center" + data-testid="auth-logs-too-many-text" + > + {{ + s__( + 'CICD|There are too many entries to display. Download the CSV file to view the full log.', + ) + }} + </div> + <div + v-if="!isLoading && logCount === 0" + class="gl-text-center" + data-testid="auth-logs-no-events" + > + {{ s__('CICD|No authentication events in the last 30 days.') }} + </div> + </crud-component> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pageInfo" + class="gl-mt-3 gl-self-center" + data-testid="auth-log-pagination" + @prev="prevPage" + @next="nextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue index 482a7e4925967..2b7a1c2d4f8c7 100644 --- a/app/assets/javascripts/token_access/components/token_access_app.vue +++ b/app/assets/javascripts/token_access/components/token_access_app.vue @@ -3,10 +3,12 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import TokenPermissions from './token_permissions.vue'; import OutboundTokenAccess from './outbound_token_access.vue'; import InboundTokenAccess from './inbound_token_access.vue'; +import AuthLog from './auth_log.vue'; export default { name: 'TokenAccessApp', components: { + AuthLog, InboundTokenAccess, OutboundTokenAccess, TokenPermissions, @@ -18,6 +20,7 @@ export default { <div> <token-permissions v-if="glFeatures.allowPushRepositoryForJobToken" /> <inbound-token-access /> + <auth-log /> <outbound-token-access /> </div> </template> diff --git a/app/assets/javascripts/token_access/graphql/queries/get_auth_logs.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_auth_logs.query.graphql new file mode 100644 index 0000000000000..f693145a7033a --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_auth_logs.query.graphql @@ -0,0 +1,22 @@ +query getAuthLogs($fullPath: ID!, $first: Int, $last: Int, $after: String, $before: String) { + project(fullPath: $fullPath) { + id + ciJobTokenAuthLogs(first: $first, last: $last, after: $after, before: $before) { + count + nodes { + lastAuthorizedAt + originProject { + fullPath + path + avatarUrl + name + id + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +} diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index e25c1b8db34e1..8a459fd8483c5 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -19,7 +19,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { return false; } - const { fullPath, enforceAllowlist } = containerEl.dataset; + const { fullPath, csvDownloadPath, enforceAllowlist } = containerEl.dataset; return new Vue({ el: containerEl, @@ -28,6 +28,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { provide: { enforceAllowlist: JSON.parse(enforceAllowlist), fullPath, + csvDownloadPath, }, render(createElement) { return createElement(TokenAccessApp); diff --git a/app/views/ci/token_access/_index.html.haml b/app/views/ci/token_access/_index.html.haml index acd5676fc099e..6bd4da2db9658 100644 --- a/app/views/ci/token_access/_index.html.haml +++ b/app/views/ci/token_access/_index.html.haml @@ -1 +1 @@ -#js-ci-token-access-app{ data: { full_path: @project.full_path, enforce_allowlist: Gitlab::CurrentSettings.enforce_ci_inbound_job_token_scope_enabled?.to_s } } +#js-ci-token-access-app{ data: { full_path: @project.full_path, csv_download_path: export_job_token_authorizations_namespace_project_settings_ci_cd_path(@project), enforce_allowlist: Gitlab::CurrentSettings.enforce_ci_inbound_job_token_scope_enabled?.to_s } } diff --git a/doc/ci/jobs/ci_job_token.md b/doc/ci/jobs/ci_job_token.md index 1eb696d592151..84c4561bba65c 100644 --- a/doc/ci/jobs/ci_job_token.md +++ b/doc/ci/jobs/ci_job_token.md @@ -289,6 +289,22 @@ To configure the job token scope: 1. Optional. Add existing projects to the token's access scope. The user adding a project must have the Maintainer role in both projects. +## Job token authentication log + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467292/) in GitLab 17.6. + +You can track which other projects use a CI/CD job token to authenticate with your project +in an authentication log. To check the log: + +1. On the left sidebar, select **Search or go to** and find your project. +1. Select **Settings > CI/CD**. +1. Expand **Job token permissions**. The **Authentication log** section displays the + the list of other projects that accessed your project by authenticating with a job token. +1. Optional. Select **Download CSV** to download the full authentication log in CSV format. + +The authentication log displays a maximum of 100 authentication events. If the number of events +is more than 100, download the CSV file to view the log. + ## Troubleshooting CI job token failures are usually shown as responses like `404 Not Found` or similar: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0b272ccd198a7..5c7c988da2a87 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -690,6 +690,11 @@ msgid_plural "%{count} contacts" msgstr[0] "" msgstr[1] "" +msgid "%{count} event has ocurred" +msgid_plural "%{count} events have ocurred" +msgstr[0] "" +msgstr[1] "" + msgid "%{count} files touched" msgstr "" @@ -10506,6 +10511,12 @@ msgstr "" msgid "CICD|Allow Git push requests to the repository" msgstr "" +msgid "CICD|Authentication events from the last 30 days. %{linkStart}Learn more.%{linkEnd}" +msgstr "" + +msgid "CICD|Authentication log" +msgstr "" + msgid "CICD|Authorized groups and projects" msgstr "" @@ -10569,6 +10580,9 @@ msgstr "" msgid "CICD|Maintainer" msgstr "" +msgid "CICD|No authentication events in the last 30 days." +msgstr "" + msgid "CICD|Only this project and any groups and projects in the allowlist" msgstr "" @@ -10596,6 +10610,12 @@ msgstr "" msgid "CICD|There are several CI/CD limits in place." msgstr "" +msgid "CICD|There are too many entries to display. Download the CSV file to view the full log." +msgstr "" + +msgid "CICD|There was a problem fetching authentication logs." +msgstr "" + msgid "CICD|Unprotected branches will not have access to the cache from protected branches." msgstr "" @@ -20113,6 +20133,9 @@ msgstr "" msgid "Download (%{size})" msgstr "" +msgid "Download CSV" +msgstr "" + msgid "Download PDF" msgstr "" diff --git a/spec/frontend/token_access/auth_log_spec.js b/spec/frontend/token_access/auth_log_spec.js new file mode 100644 index 0000000000000..ed17c7fb1db1b --- /dev/null +++ b/spec/frontend/token_access/auth_log_spec.js @@ -0,0 +1,137 @@ +import { GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import AuthLog from '~/token_access/components/auth_log.vue'; +import getAuthLogsQuery from '~/token_access/graphql/queries/get_auth_logs.query.graphql'; +import CrudComponent from '~/vue_shared/components/crud_component.vue'; +import { mockAuthLogsResponse } from './mock_data'; + +const projectPath = 'root/my-repo'; +const csvDownloadPath = '/root/my-repo/-/settings/ci_cd/export_job_token_authorizations'; +const message = 'An error occurred'; +const error = new Error(message); + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('TokenAccess component', () => { + let wrapper; + + const getAuthLogsQueryResponseHandler = jest.fn().mockResolvedValue(mockAuthLogsResponse()); + const getAuthLogsQueryEmptyResponseHandler = jest.fn().mockResolvedValue(); + const failureHandler = jest.fn().mockRejectedValue(error); + + const createMockApolloProvider = (requestHandlers) => { + return createMockApollo(requestHandlers); + }; + const mockToastShow = jest.fn(); + + const findGlTable = () => wrapper.findComponent(GlTableLite); + const findAllTableRows = () => wrapper.findAllByTestId('auth-logs-table-row'); + const findCrudComponentBody = () => wrapper.findByTestId('crud-body'); + const findDownloadButton = () => wrapper.findByTestId('auth-log-download-csv-button'); + const findPagination = () => wrapper.findByTestId('auth-log-pagination'); + + const createComponent = (requestHandlers, mountFn = shallowMountExtended) => { + wrapper = mountFn(AuthLog, { + provide: { + fullPath: projectPath, + csvDownloadPath, + }, + apolloProvider: createMockApolloProvider(requestHandlers), + mocks: { + $toast: { + show: mockToastShow, + }, + }, + stubs: { CrudComponent }, + }); + }; + + describe('queries', () => { + it('fetches the authentication events correctly', async () => { + const expectedVariables = { + fullPath: projectPath, + after: null, + before: null, + first: 20, + last: null, + }; + + createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended); + + await waitForPromises(); + + expect(getAuthLogsQueryResponseHandler).toHaveBeenCalledWith(expectedVariables); + }); + + it('handles fetch scope error correctly', async () => { + createComponent([[getAuthLogsQuery, failureHandler]]); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching authentication logs.', + }); + }); + }); + + describe('Authentication log', () => { + it('displays an empty state when no data is available', async () => { + createComponent([[getAuthLogsQuery, getAuthLogsQueryEmptyResponseHandler]], mountExtended); + + await waitForPromises(); + + expect(findCrudComponentBody().text()).toContain( + 'No authentication events in the last 30 days.', + ); + }); + + it('displays a table when data is available', async () => { + createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended); + + await waitForPromises(); + + expect(findGlTable().exists()).toBe(true); + expect(findAllTableRows()).toHaveLength( + mockAuthLogsResponse().data.project.ciJobTokenAuthLogs.nodes.length, + ); + }); + + it('displays pagination controls', async () => { + const getAuthLogsQueryResponseHandlerWithPagination = jest + .fn() + .mockResolvedValue(mockAuthLogsResponse(true)); + + createComponent( + [[getAuthLogsQuery, getAuthLogsQueryResponseHandlerWithPagination]], + mountExtended, + ); + + await waitForPromises(); + + expect(findPagination().exists()).toBe(true); + }); + + it('displays a download button when there is at least one event available', async () => { + createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended); + + await waitForPromises(); + + expect(findDownloadButton().exists()).toBe(true); + expect(findDownloadButton().props()).toEqual( + expect.objectContaining({ + isUnsafeLink: true, + category: 'primary', + variant: 'default', + size: 'small', + }), + ); + }); + }); +}); diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js index a297fdfd4a54a..dd67999851388 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -324,3 +324,35 @@ export const mockPermissionsMutationResponse = ({ }, }, }); + +export const mockAuthLogsResponse = (hasNextPage = false) => ({ + data: { + project: { + id: 'gid://gitlab/Project/26', + __typename: 'Project', + ciJobTokenAuthLogs: { + __typename: 'CiJobTokenAuthLogConnection', + count: 1, + nodes: [ + { + __typename: 'CiJobTokenAuthLog', + lastAuthorizedAt: '2024-10-25', + originProject: { + __typename: 'Project', + fullPath: 'root/project-that-triggers-external-pipeline', + path: 'project-that-triggers-external-pipeline', + avatarUrl: null, + name: 'project-that-triggers-external-pipeline', + id: 'gid://gitlab/Project/26', + }, + }, + ], + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjEifQ', + hasNextPage, + }, + }, + }, + }, +}); -- GitLab