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