Commit 48e7ce95 authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Thong Kuah

Add table for grafana api tokens

In preparation for supporting the ability to embed Grafana metrics
in GitLab Flavored Markdown, we need to allow a way for users to
store credentials for us to access the HTTP API for their grafana
instance. We expect users to save two key attributes:
an authorization token and the url of their grafana instance.

This work adds the table and model for storing this auth config.
parent 9ff8b86e
......@@ -63,7 +63,9 @@ module Projects
:api_host,
:token,
project: [:slug, :name, :organization_slug, :organization_name]
]
],
grafana_integration_attributes: [:token, :grafana_url]
}
end
end
......
......@@ -354,6 +354,14 @@ module ProjectsHelper
@project.metrics_setting_external_dashboard_url
end
def grafana_integration_url
@project.grafana_integration&.grafana_url
end
def grafana_integration_token
@project.grafana_integration&.token
end
private
def get_project_nav_tabs(project, current_user)
......
# frozen_string_literal: true
class GrafanaIntegration < ApplicationRecord
belongs_to :project
attr_encrypted :token,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32
validates :grafana_url,
length: { maximum: 1024 },
addressable_url: { enforce_sanitization: true, ascii_only: true }
validates :token, :project, presence: true
end
......@@ -195,6 +195,7 @@ class Project < ApplicationRecord
has_one :project_repository, inverse_of: :project
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
has_one :grafana_integration, inverse_of: :project
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
......@@ -311,6 +312,7 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
......
......@@ -12,7 +12,9 @@ module Projects
private
def project_update_params
error_tracking_params.merge(metrics_setting_params)
error_tracking_params
.merge(metrics_setting_params)
.merge(grafana_integration_params)
end
def metrics_setting_params
......@@ -44,6 +46,14 @@ module Projects
}
}
end
def grafana_integration_params
return {} unless attrs = params[:grafana_integration_attributes]
destroy = attrs[:grafana_url].blank? && attrs[:token].blank?
{ grafana_integration_attributes: attrs.merge(_destroy: destroy) }
end
end
end
end
......
---
title: Create table for grafana api token for metrics embeds
merge_request: 17234
author:
type: added
......@@ -16,6 +16,9 @@ en:
api_url: "Sentry API URL"
project/metrics_setting:
external_dashboard_url: "External dashboard URL"
project/grafana_integration:
token: "Grafana HTTP API Token"
grafana_url: "Grafana API URL"
views:
pagination:
previous: "Prev"
......
# frozen_string_literal: true
class CreateGrafanaIntegrations < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :grafana_integrations do |t|
t.references :project, index: true, foreign_key: { on_delete: :cascade }, unique: true, null: false
t.timestamps_with_timezone null: false
t.string :encrypted_token, limit: 255, null: false
t.string :encrypted_token_iv, limit: 255, null: false
t.string :grafana_url, null: false, limit: 1024
end
end
end
......@@ -1704,6 +1704,16 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do
t.index ["project_id"], name: "index_gpg_signatures_on_project_id"
end
create_table "grafana_integrations", force: :cascade do |t|
t.bigint "project_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "encrypted_token", limit: 255, null: false
t.string "encrypted_token_iv", limit: 255, null: false
t.string "grafana_url", limit: 1024, null: false
t.index ["project_id"], name: "index_grafana_integrations_on_project_id"
end
create_table "group_custom_attributes", id: :serial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
......@@ -3997,6 +4007,7 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do
add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify
add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify
add_foreign_key "gpg_signatures", "projects", on_delete: :cascade
add_foreign_key "grafana_integrations", "projects", on_delete: :cascade
add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade
add_foreign_key "import_export_uploads", "projects", on_delete: :cascade
......
......@@ -125,6 +125,11 @@ module Gitlab
# If the addr can't be resolved or the url is invalid (i.e http://1.1.1.1.1)
# we block the url
raise BlockedUrlError, "Host cannot be resolved or invalid"
rescue ArgumentError => error
# Addrinfo.getaddrinfo errors if the domain exceeds 1024 characters.
raise unless error.message.include?('hostname too long')
raise BlockedUrlError, "Host is too long (maximum is 1024 characters)"
end
def validate_local_request(
......
......@@ -180,6 +180,21 @@ describe Projects::Settings::OperationsController do
end
end
context 'grafana integration' do
describe 'PATCH #update' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: 'https://grafana.gitlab.com',
token: 'eyJrIjoicDRlRTREdjhhOEZ5WjZPWXUzazJOSW0zZHJUejVOd3IiLCJuIjoiVGVzdCBLZXkiLCJpZCI6MX0='
}
}
end
it_behaves_like 'PATCHable'
end
end
private
def project_params(project, params = {})
......
# frozen_string_literal: true
FactoryBot.define do
factory :grafana_integration, class: GrafanaIntegration do
project
grafana_url { 'https://grafana.com' }
token { SecureRandom.hex(10) }
end
end
......@@ -902,4 +902,40 @@ describe ProjectsHelper do
end
end
end
describe '#grafana_integration_url' do
let(:project) { create(:project) }
before do
helper.instance_variable_set(:@project, project)
end
subject { helper.grafana_integration_url }
it { is_expected.to eq(nil) }
context 'grafana integration exists' do
let!(:grafana_integration) { create(:grafana_integration, project: project) }
it { is_expected.to eq(grafana_integration.grafana_url) }
end
end
describe '#grafana_integration_token' do
let(:project) { create(:project) }
before do
helper.instance_variable_set(:@project, project)
end
subject { helper.grafana_integration_token }
it { is_expected.to eq(nil) }
context 'grafana integration exists' do
let!(:grafana_integration) { create(:grafana_integration, project: project) }
it { is_expected.to eq(grafana_integration.token) }
end
end
end
......@@ -411,6 +411,7 @@ project:
- external_pull_requests
- pages_metadatum
- alerts_service
- grafana_integration
award_emoji:
- awardable
- user
......
......@@ -62,6 +62,14 @@ describe Gitlab::UrlBlocker do
expect { subject }.to raise_error(described_class::BlockedUrlError)
end
end
context 'when domain is too long' do
let(:import_url) { 'https://example' + 'a' * 1024 + '.com' }
it 'raises an error' do
expect { subject }.to raise_error(described_class::BlockedUrlError)
end
end
end
context 'when the URL hostname is an IP address' do
......
# frozen_string_literal: true
require 'spec_helper'
describe GrafanaIntegration do
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:token) }
it 'disallows invalid urls for grafana_url' do
unsafe_url = %{https://replaceme.com/'><script>alert(document.cookie)</script>}
non_ascii_url = 'http://gitlab.com/api/0/projects/project1/something€'
blank_url = ''
excessively_long_url = 'https://grafan' + 'a' * 1024 + '.com'
is_expected.not_to allow_values(
unsafe_url,
non_ascii_url,
blank_url,
excessively_long_url
).for(:grafana_url)
end
it 'allows valid urls for grafana_url' do
external_url = 'http://grafana.com/'
internal_url = 'http://192.168.1.1'
is_expected.to allow_value(
external_url,
internal_url
).for(:grafana_url)
end
end
end
......@@ -170,5 +170,61 @@ describe Projects::Operations::UpdateService do
expect(project.reload.name).to eq(original_name)
end
end
context 'grafana integration' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: 'http://new.grafana.com',
token: 'VerySecureToken='
}
}
end
context 'without existing grafana integration' do
it 'creates an integration' do
expect(result[:status]).to eq(:success)
expected_attrs = params[:grafana_integration_attributes]
integration = project.reload.grafana_integration
expect(integration.grafana_url).to eq(expected_attrs[:grafana_url])
expect(integration.token).to eq(expected_attrs[:token])
end
end
context 'with an existing grafana integration' do
before do
create(:grafana_integration, project: project)
end
it 'updates the settings' do
expect(result[:status]).to eq(:success)
expected_attrs = params[:grafana_integration_attributes]
integration = project.reload.grafana_integration
expect(integration.grafana_url).to eq(expected_attrs[:grafana_url])
expect(integration.token).to eq(expected_attrs[:token])
end
context 'with all grafana attributes blank in params' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: '',
token: ''
}
}
end
it 'destroys the metrics_setting entry in DB' do
expect(result[:status]).to eq(:success)
expect(project.reload.grafana_integration).to be_nil
end
end
end
end
end
end
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