From 063d558240da1371f039eb10c943bac4edb746e6 Mon Sep 17 00:00:00 2001
From: Drew Blessing <drew@gitlab.com>
Date: Fri, 21 Feb 2025 07:55:37 +0000
Subject: [PATCH] Expose resource access token resource type and id

---
 lib/api/entities/resource_access_token.rb     | 34 ++++++++++++++++---
 .../public_api/v4/resource_access_token.json  | 10 +++++-
 .../self_rotation_spec.rb                     |  9 +++--
 .../api/resource_access_tokens_spec.rb        | 34 ++++++++++++++-----
 4 files changed, 69 insertions(+), 18 deletions(-)

diff --git a/lib/api/entities/resource_access_token.rb b/lib/api/entities/resource_access_token.rb
index 7e2a1e0075a3..fe6387dfc807 100644
--- a/lib/api/entities/resource_access_token.rb
+++ b/lib/api/entities/resource_access_token.rb
@@ -4,12 +4,36 @@ module API
   module Entities
     class ResourceAccessToken < Entities::PersonalAccessToken
       expose :access_level,
-        documentation: { type: 'integer',
-                         example: 40,
-                         description: 'Access level. Valid values are 10 (Guest), 20 (Reporter), 30 (Developer) \
+        documentation: {
+          type: 'integer',
+          example: 40,
+          description: 'Access level. Valid values are 10 (Guest), 20 (Reporter), 30 (Developer) \
       , 40 (Maintainer), and 50 (Owner). Defaults to 40.',
-                         values: [10, 20, 30, 40, 50] } do |token, options|
-        options[:resource].member(token.user).access_level
+          values: [10, 20, 30, 40, 50]
+        } do |token, _options|
+        token.user.members.first.access_level
+      end
+
+      expose :resource_type,
+        documentation: {
+          type: 'string',
+          example: 'project',
+          description: 'Whether a token belongs to a project or group',
+          values: %w[project group]
+        } do |token, _options|
+        token.user.bot_namespace && token.user.bot_namespace.is_a?(::Namespaces::ProjectNamespace) ? 'project' : 'group'
+      end
+
+      expose :resource_id,
+        documentation: {
+          type: 'integer',
+          example: 1234,
+          description: 'The ID of the project or group'
+        } do |token, _options|
+        bot_namespace = token.user.bot_namespace
+        next unless bot_namespace
+
+        bot_namespace.is_a?(::Namespaces::ProjectNamespace) ? bot_namespace.project.id : bot_namespace.id
       end
     end
   end
diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json
index 61a73ef89ce2..819668f02f97 100644
--- a/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json
+++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json
@@ -11,7 +11,9 @@
     "revoked",
     "access_level",
     "scopes",
-    "last_used_at"
+    "last_used_at",
+    "resource_type",
+    "resource_id"
   ],
   "properties": {
     "id": {
@@ -61,6 +63,12 @@
         "null"
       ],
       "format": "date-time"
+    },
+    "resource_type": {
+      "type": "string"
+    },
+    "resource_id": {
+      "type": "integer"
     }
   },
   "additionalProperties": false
diff --git a/spec/requests/api/resource_access_tokens/self_rotation_spec.rb b/spec/requests/api/resource_access_tokens/self_rotation_spec.rb
index 62f3dadb679f..d95da2c750ee 100644
--- a/spec/requests/api/resource_access_tokens/self_rotation_spec.rb
+++ b/spec/requests/api/resource_access_tokens/self_rotation_spec.rb
@@ -7,9 +7,6 @@
   let(:expiry_date) { Time.zone.today + 1.week }
   let(:params) { {} }
 
-  let_it_be(:current_user) { create(:user, :project_bot) }
-  let_it_be(:other_user) { create(:user, :project_bot) }
-
   subject(:rotate_token) { post(api(path, personal_access_token: token), params: params) }
 
   shared_examples 'rotating token succeeds' do
@@ -173,6 +170,9 @@
 
   context 'when the resource is a project' do
     let_it_be(:resource) { create(:project) }
+    let_it_be(:namespace) { resource.project_namespace }
+    let_it_be(:current_user) { create(:user, :project_bot, bot_namespace: namespace) }
+    let_it_be(:other_user) { create(:user, :project_bot, bot_namespace: namespace) }
 
     before_all { resource.add_guest(current_user) }
 
@@ -181,6 +181,9 @@
 
   context 'when the resource is a group' do
     let_it_be(:resource) { create(:group) }
+    let_it_be(:namespace) { resource }
+    let_it_be(:current_user) { create(:user, :project_bot, bot_namespace: namespace) }
+    let_it_be(:other_user) { create(:user, :project_bot, bot_namespace: namespace) }
 
     before_all { resource.add_guest(current_user) }
 
diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb
index fb5e839a3a90..dd0d5fe61c9c 100644
--- a/spec/requests/api/resource_access_tokens_spec.rb
+++ b/spec/requests/api/resource_access_tokens_spec.rb
@@ -11,7 +11,7 @@
       subject(:get_tokens) { get api("/#{source_type}s/#{resource_id}/access_tokens", user) }
 
       context "when the user has valid permissions" do
-        let_it_be(:project_bot) { create(:user, :project_bot) }
+        let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
         let_it_be(:active_access_tokens) { create_list(:personal_access_token, 5, user: project_bot) }
         let_it_be(:expired_token) { create(:personal_access_token, :expired, user: project_bot) }
         let_it_be(:revoked_token) { create(:personal_access_token, :revoked, user: project_bot) }
@@ -49,8 +49,12 @@
 
           if source_type == 'project'
             expect(api_get_token["access_level"]).to eq(resource.team.max_member_access(token.user.id))
+            expect(api_get_token["resource_type"]).to eq('project')
+            expect(api_get_token["resource_id"]).to eq(namespace.project.id)
           else
             expect(api_get_token["access_level"]).to eq(resource.max_member_access_for_user(token.user))
+            expect(api_get_token["resource_type"]).to eq('group')
+            expect(api_get_token["resource_id"]).to eq(namespace.id)
           end
 
           expect(api_get_token["expires_at"]).to eq(token.expires_at.to_date.iso8601)
@@ -71,7 +75,7 @@
         end
 
         context "when tokens belong to a different #{source_type}" do
-          let_it_be(:bot) { create(:user, :project_bot) }
+          let_it_be(:bot) { create(:user, :project_bot, bot_namespace: other_resource_namespace) }
           let_it_be(:token) { create(:personal_access_token, user: bot) }
 
           before do
@@ -151,7 +155,7 @@
 
       context "when the user does not have valid permissions" do
         let_it_be(:user) { user_non_priviledged }
-        let_it_be(:project_bot) { create(:user, :project_bot) }
+        let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
         let_it_be(:access_tokens) { create_list(:personal_access_token, 3, user: project_bot) }
         let_it_be(:resource_id) { resource.id }
 
@@ -170,7 +174,7 @@
     context "GET #{source_type}s/:id/access_tokens/:token_id" do
       subject(:get_token) { get api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) }
 
-      let_it_be(:project_bot) { create(:user, :project_bot) }
+      let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
       let_it_be(:token) { create(:personal_access_token, user: project_bot) }
       let_it_be(:resource_id) { resource.id }
       let_it_be(:token_id) { token.id }
@@ -195,15 +199,19 @@
 
           if source_type == 'project'
             expect(json_response["access_level"]).to eq(resource.team.max_member_access(token.user.id))
+            expect(json_response["resource_type"]).to eq('project')
+            expect(json_response["resource_id"]).to eq(namespace.project.id)
           else
             expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(token.user))
+            expect(json_response["resource_type"]).to eq('group')
+            expect(json_response["resource_id"]).to eq(namespace.id)
           end
 
           expect(json_response["expires_at"]).to eq(token.expires_at.to_date.iso8601)
         end
 
         context "when using #{source_type} access token to GET other #{source_type} access token" do
-          let_it_be(:other_project_bot) { create(:user, :project_bot) }
+          let_it_be(:other_project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
           let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) }
           let_it_be(:token_id) { other_token.id }
 
@@ -222,8 +230,12 @@
 
             if source_type == 'project'
               expect(json_response["access_level"]).to eq(resource.team.max_member_access(other_token.user.id))
+              expect(json_response["resource_type"]).to eq('project')
+              expect(json_response["resource_id"]).to eq(namespace.project.id)
             else
               expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(other_token.user))
+              expect(json_response["resource_type"]).to eq('group')
+              expect(json_response["resource_id"]).to eq(namespace.id)
             end
 
             expect(json_response["expires_at"]).to eq(other_token.expires_at.to_date.iso8601)
@@ -267,7 +279,7 @@
     context "DELETE #{source_type}s/:id/access_tokens/:token_id", :sidekiq_inline do
       subject(:delete_token) { delete api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) }
 
-      let_it_be(:project_bot) { create(:user, :project_bot) }
+      let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
       let_it_be(:token) { create(:personal_access_token, user: project_bot) }
       let_it_be(:resource_id) { resource.id }
       let_it_be(:token_id) { token.id }
@@ -286,7 +298,7 @@
         end
 
         context "when using #{source_type} access token to DELETE other #{source_type} access token" do
-          let_it_be(:other_project_bot) { create(:user, :project_bot) }
+          let_it_be(:other_project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
           let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) }
           let_it_be(:token_id) { other_token.id }
 
@@ -482,7 +494,7 @@
         end
 
         context "when a #{source_type} access token tries to create another #{source_type} access token" do
-          let_it_be(:project_bot) { create(:user, :project_bot) }
+          let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
           let_it_be(:user) { project_bot }
 
           before do
@@ -504,7 +516,7 @@
     end
 
     context "POST #{source_type}s/:id/access_tokens/:token_id/rotate" do
-      let_it_be(:project_bot) { create(:user, :project_bot) }
+      let_it_be(:project_bot) { create(:user, :project_bot, bot_namespace: namespace) }
       let_it_be(:token) { create(:personal_access_token, user: project_bot) }
       let_it_be(:resource_id) { resource.id }
       let_it_be(:token_id) { token.id }
@@ -667,7 +679,9 @@
 
   context 'when the resource is a project' do
     let_it_be(:resource) { create(:project, group: create(:group)) }
+    let_it_be(:namespace) { resource.project_namespace }
     let_it_be(:other_resource) { create(:project) }
+    let_it_be(:other_resource_namespace) { other_resource.project_namespace }
     let_it_be(:unknown_resource) { create(:project) }
 
     before_all do
@@ -681,7 +695,9 @@
 
   context 'when the resource is a group' do
     let_it_be(:resource) { create(:group) }
+    let_it_be(:namespace) { resource }
     let_it_be(:other_resource) { create(:group) }
+    let_it_be(:other_resource_namespace) { other_resource }
     let_it_be(:unknown_resource) { create(:project) }
 
     before_all do
-- 
GitLab