Commit cccc9bad authored by Imre Farkas's avatar Imre Farkas

Merge branch 'emilyring-token-graphql-create' into 'master'

Cluster Token create mutation for GraphQl

See merge request gitlab-org/gitlab!38820
parents eb3ed388 aa3aae38
......@@ -1725,11 +1725,73 @@ type ClusterAgentDeletePayload {
errors: [String!]!
}
type ClusterAgentToken {
"""
Cluster agent this token is associated with
"""
clusterAgent: ClusterAgent
"""
Timestamp the token was created
"""
createdAt: Time
"""
Global ID of the token
"""
id: ClustersAgentTokenID!
}
"""
Autogenerated input type of ClusterAgentTokenCreate
"""
input ClusterAgentTokenCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of the cluster agent that will be associated with the new token
"""
clusterAgentId: ClustersAgentID!
}
"""
Autogenerated return type of ClusterAgentTokenCreate
"""
type ClusterAgentTokenCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
Token secret value. Make sure you save it - you won't be able to access it again
"""
secret: String
"""
Token created after mutation
"""
token: ClusterAgentToken
}
"""
Identifier of Clusters::Agent
"""
scalar ClustersAgentID
"""
Identifier of Clusters::AgentToken
"""
scalar ClustersAgentTokenID
type Commit {
"""
Author of the commit
......@@ -9797,6 +9859,7 @@ type Mutation {
boardListCreate(input: BoardListCreateInput!): BoardListCreatePayload
boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload
clusterAgentDelete(input: ClusterAgentDeleteInput!): ClusterAgentDeletePayload
clusterAgentTokenCreate(input: ClusterAgentTokenCreateInput!): ClusterAgentTokenCreatePayload
commitCreate(input: CommitCreateInput!): CommitCreatePayload
configureSast(input: ConfigureSastInput!): ConfigureSastPayload
createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload
......
......@@ -4729,6 +4729,181 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ClusterAgentToken",
"description": null,
"fields": [
{
"name": "clusterAgent",
"description": "Cluster agent this token is associated with",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgent",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp the token was created",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "Global ID of the token",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ClustersAgentTokenID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ClusterAgentTokenCreateInput",
"description": "Autogenerated input type of ClusterAgentTokenCreate",
"fields": null,
"inputFields": [
{
"name": "clusterAgentId",
"description": "Global ID of the cluster agent that will be associated with the new token",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ClustersAgentID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ClusterAgentTokenCreatePayload",
"description": "Autogenerated return type of ClusterAgentTokenCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "secret",
"description": "Token secret value. Make sure you save it - you won't be able to access it again",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "token",
"description": "Token created after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgentToken",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "ClustersAgentID",
......@@ -4739,6 +4914,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "ClustersAgentTokenID",
"description": "Identifier of Clusters::AgentToken",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Commit",
......@@ -27747,6 +27932,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clusterAgentTokenCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "ClusterAgentTokenCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgentTokenCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "commitCreate",
"description": null,
......@@ -295,6 +295,25 @@ Autogenerated return type of ClusterAgentDelete
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## ClusterAgentToken
| Name | Type | Description |
| --- | ---- | ---------- |
| `clusterAgent` | ClusterAgent | Cluster agent this token is associated with |
| `createdAt` | Time | Timestamp the token was created |
| `id` | ClustersAgentTokenID! | Global ID of the token |
## ClusterAgentTokenCreatePayload
Autogenerated return type of ClusterAgentTokenCreate
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `secret` | String | Token secret value. Make sure you save it - you won't be able to access it again |
| `token` | ClusterAgentToken | Token created after mutation |
## Commit
| Name | Type | Description |
......
......@@ -8,6 +8,7 @@ module EE
prepended do
mount_mutation ::Mutations::Clusters::Agents::Create
mount_mutation ::Mutations::Clusters::Agents::Delete
mount_mutation ::Mutations::Clusters::AgentTokens::Create
mount_mutation ::Mutations::Issues::SetIteration
mount_mutation ::Mutations::Issues::SetWeight
mount_mutation ::Mutations::Issues::SetEpic
......
# frozen_string_literal: true
module Mutations
module Clusters
module AgentTokens
class Create < BaseMutation
graphql_name 'ClusterAgentTokenCreate'
authorize :create_cluster
argument :cluster_agent_id,
::Types::GlobalIDType[::Clusters::Agent],
required: true,
description: 'Global ID of the cluster agent that will be associated with the new token'
field :secret,
GraphQL::STRING_TYPE,
null: true,
description: "Token secret value. Make sure you save it - you won't be able to access it again"
field :token,
Types::Clusters::AgentTokenType,
null: true,
description: 'Token created after mutation'
def resolve(cluster_agent_id:)
cluster_agent = authorized_find!(id: cluster_agent_id)
result = ::Clusters::AgentTokens::CreateService
.new(container: cluster_agent.project, current_user: current_user)
.execute(cluster_agent)
payload = result.payload
{
secret: payload[:secret],
token: payload[:token],
errors: Array.wrap(result.message)
}
end
private
def find_object(id:)
GitlabSchema.find_by_gid(id)
end
end
end
end
end
# frozen_string_literal: true
module Types
module Clusters
class AgentTokenType < BaseObject
graphql_name 'ClusterAgentToken'
authorize :admin_cluster
field :cluster_agent,
Types::Clusters::AgentType,
description: 'Cluster agent this token is associated with',
null: true,
resolve: -> (token, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Clusters::Agent, token.agent_id).find }
field :created_at,
Types::TimeType,
null: true,
description: 'Timestamp the token was created'
field :id,
::Types::GlobalIDType[::Clusters::AgentToken],
null: false,
description: 'Global ID of the token'
end
end
end
# frozen_string_literal: true
module Clusters
class AgentTokenPolicy < BasePolicy
alias_method :token, :subject
delegate { token.agent }
end
end
# frozen_string_literal: true
module Clusters
module AgentTokens
class CreateService < ::BaseContainerService
def execute(cluster_agent)
return error_feature_not_available unless container.feature_available?(:cluster_agents)
return error_no_permissions unless current_user.can?(:create_cluster, container)
token = ::Clusters::AgentToken.new(agent: cluster_agent)
if token.save
ServiceResponse.success(payload: { secret: token.token, token: token })
else
ServiceResponse.error(message: token.errors.full_messages)
end
end
private
def error_feature_not_available
ServiceResponse.error(message: s_('ClusterAgent|This feature is only available for premium plans'))
end
def error_no_permissions
ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project'))
end
end
end
end
---
title: Cluster token create mutation for GraphQL
merge_request: 38820
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::AgentTokens::Create do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let_it_be(:cluster_agent) { create(:cluster_agent) }
let_it_be(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:create_cluster) }
describe '#resolve' do
subject { mutation.resolve(cluster_agent_id: cluster_agent.to_global_id) }
context 'without token permissions' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'without premium plan' do
before do
stub_licensed_features(cluster_agents: false)
cluster_agent.project.add_maintainer(user)
end
it { expect(subject[:secret]).to be_nil }
it { expect(subject[:errors]).to eq(['This feature is only available for premium plans']) }
end
context 'with premium plan and user permissions' do
before do
stub_licensed_features(cluster_agents: true)
cluster_agent.project.add_maintainer(user)
end
it 'creates a new token', :aggregate_failures do
expect { subject }.to change { ::Clusters::AgentToken.count }.by(1)
expect(subject[:secret]).not_to be_nil
expect(subject[:errors]).to eq([])
end
context 'invalid params' do
subject { mutation.resolve(cluster_agent_id: cluster_agent.id) }
it 'generates an error message when id invalid', :aggregate_failures do
expect { subject }.to raise_error(NoMethodError)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ClusterAgentToken'] do
let(:fields) { %i[cluster_agent created_at id] }
it { expect(described_class.graphql_name).to eq('ClusterAgentToken') }
it { expect(described_class).to require_graphql_authorizations(:admin_cluster) }
it { expect(described_class).to have_graphql_fields(fields) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentTokenPolicy do
let_it_be(:token) { create(:cluster_agent_token) }
let(:user) { create(:user) }
let(:policy) { described_class.new(user, token) }
let(:project) { token.agent.project }
describe 'rules' do
context 'when developer' do
before do
project.add_developer(user)
end
it { expect(policy).to be_disallowed :admin_cluster }
it { expect(policy).to be_disallowed :read_cluster }
end
context 'when maintainer' do
before do
project.add_maintainer(user)
end
it { expect(policy).to be_allowed :admin_cluster }
it { expect(policy).to be_allowed :read_cluster }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create a new cluster agent token' do
include GraphqlHelpers
let_it_be(:cluster_agent) { create(:cluster_agent) }
let_it_be(:current_user) { create(:user) }
let(:mutation) do
graphql_mutation(
:cluster_agent_token_create,
{ cluster_agent_id: cluster_agent.to_global_id.to_s }
)
end
def mutation_response
graphql_mutation_response(:cluster_agent_token_create)
end
context 'without user permissions' do
it_behaves_like 'a mutation that returns top-level errors',
errors: ["The resource that you are attempting to access does not exist "\
"or you don't have permission to perform this action"]
it 'does not create a token' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::AgentToken, :count)
end
end
context 'without premium plan' do
before do
stub_licensed_features(cluster_agents: false)
cluster_agent.project.add_maintainer(current_user)
end
it 'does not create a token and returns error message', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::AgentToken, :count)
expect(mutation_response['errors']).to eq(['This feature is only available for premium plans'])
end
end
context 'with project permissions' do
before do
stub_licensed_features(cluster_agents: true)
cluster_agent.project.add_maintainer(current_user)
end
it 'creates a new token', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::AgentToken.count }.by(1)
expect(mutation_response['secret']).not_to be_nil
expect(mutation_response['errors']).to eq([])
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentTokens::CreateService do
subject(:service) { described_class.new(container: project, current_user: user) }
let_it_be(:user) { create(:user) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
before do
stub_licensed_features(cluster_agents: false)
end
describe '#execute' do
context 'without premium plan' do
it 'does not create a new token' do
expect { service.execute(cluster_agent) }.not_to change(Clusters::AgentToken, :count)
end
it 'returns missing license error' do
result = service.execute(cluster_agent)
expect(result.status).to eq(:error)
expect(result.message).to eq('This feature is only available for premium plans')
end
context 'with premium plan' do
before do
stub_licensed_features(cluster_agents: true)
end
it 'does not create a new token due to user permissions' do
expect { service.execute(cluster_agent) }.not_to change(::Clusters::AgentToken, :count)
end
it 'returns permission errors', :aggregate_failures do
result = service.execute(cluster_agent)
expect(result.status).to eq(:error)
expect(result.message).to eq('User has insufficient permissions to create a token for this project')
end
context 'with user permissions' do
before do
project.add_maintainer(user)
end
it 'creates a new token' do
expect { service.execute(cluster_agent) }.to change { ::Clusters::AgentToken.count }.by(1)
end
it 'returns success status', :aggregate_failures do
result = service.execute(cluster_agent)
expect(result.status).to eq(:success)
expect(result.payload[:secret]).not_to be_nil
end
end
end
end
end
end
......@@ -5156,6 +5156,9 @@ msgstr ""
msgid "ClusterAgent|This feature is only available for premium plans"
msgstr ""
msgid "ClusterAgent|User has insufficient permissions to create a token for this project"
msgstr ""
msgid "ClusterAgent|You have insufficient permissions to create a cluster agent for this project"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment