connectors_sdk 8.3.0.0.pre.20220414T060419Z → 8.3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/connectors_sdk/atlassian/config.rb +27 -0
  3. data/lib/connectors_sdk/atlassian/custom_client.rb +87 -0
  4. data/lib/connectors_sdk/base/adapter.rb +7 -8
  5. data/lib/connectors_sdk/base/authorization.rb +89 -0
  6. data/lib/connectors_sdk/base/connector.rb +166 -0
  7. data/lib/connectors_sdk/base/custom_client.rb +1 -2
  8. data/lib/connectors_sdk/base/extractor.rb +6 -2
  9. data/lib/connectors_sdk/base/registry.rb +9 -4
  10. data/lib/connectors_sdk/confluence/adapter.rb +216 -0
  11. data/lib/connectors_sdk/confluence/custom_client.rb +143 -0
  12. data/lib/connectors_sdk/confluence/extractor.rb +265 -0
  13. data/lib/connectors_sdk/confluence_cloud/authorization.rb +64 -0
  14. data/lib/connectors_sdk/confluence_cloud/connector.rb +84 -0
  15. data/lib/connectors_sdk/confluence_cloud/custom_client.rb +61 -0
  16. data/lib/connectors_sdk/confluence_cloud/extractor.rb +59 -0
  17. data/lib/connectors_sdk/helpers/atlassian_time_formatter.rb +10 -0
  18. data/lib/connectors_sdk/office365/adapter.rb +7 -7
  19. data/lib/connectors_sdk/office365/config.rb +1 -0
  20. data/lib/connectors_sdk/office365/custom_client.rb +20 -39
  21. data/lib/connectors_sdk/office365/extractor.rb +18 -34
  22. data/lib/connectors_sdk/share_point/adapter.rb +24 -12
  23. data/lib/connectors_sdk/share_point/authorization.rb +14 -62
  24. data/lib/connectors_sdk/share_point/connector.rb +82 -0
  25. data/lib/connectors_sdk/share_point/extractor.rb +2 -2
  26. data/lib/connectors_sdk/stub_connector/connector.rb +62 -0
  27. data/lib/connectors_shared/constants.rb +12 -0
  28. data/lib/connectors_shared/exception_tracking.rb +4 -4
  29. data/lib/connectors_shared/extraction_utils.rb +109 -0
  30. data/lib/connectors_shared/job_status.rb +18 -0
  31. data/lib/connectors_shared/middleware/basic_auth.rb +27 -0
  32. data/lib/connectors_shared/middleware/bearer_auth.rb +27 -0
  33. data/lib/connectors_shared/middleware/restrict_hostnames.rb +73 -0
  34. data/lib/connectors_shared/monitor.rb +3 -3
  35. data/lib/connectors_shared.rb +1 -0
  36. data/lib/stubs/enterprise_search/exception_tracking.rb +43 -0
  37. metadata +30 -16
  38. data/lib/connectors_sdk/base/.config.rb.un~ +0 -0
  39. data/lib/connectors_sdk/base/.connectors.rb.un~ +0 -0
  40. data/lib/connectors_sdk/base/.registry.rb.un~ +0 -0
  41. data/lib/connectors_sdk/share_point/.http_call_wrapper.rb.un~ +0 -0
  42. data/lib/connectors_sdk/share_point/http_call_wrapper.rb +0 -117
@@ -0,0 +1,216 @@
1
+ #
2
+ # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3
+ # or more contributor license agreements. Licensed under the Elastic License;
4
+ # you may not use this file except in compliance with the Elastic License.
5
+ #
6
+
7
+ # frozen_string_literal: true
8
+
9
+ require 'connectors_sdk/base/adapter'
10
+ require 'connectors_shared/extraction_utils'
11
+ require 'nokogiri'
12
+
13
+ module ConnectorsSdk
14
+ module Confluence
15
+ class Adapter < ConnectorsSdk::Base::Adapter
16
+
17
+ MAX_CONTENT_COMMENTS_TO_INDEX = 50
18
+ LEADING_SLASH_REGEXP = /\A\//
19
+
20
+ generate_id_helpers :confluence_space, 'confluence_space'
21
+ generate_id_helpers :confluence_content, 'confluence_content'
22
+ generate_id_helpers :confluence_attachment, 'confluence_attachment'
23
+
24
+ def self.es_document_from_confluence_space(space, base_url, permissions = [])
25
+ SpaceNode.new(:node => space, :base_url => base_url, :permissions => permissions).to_es_document
26
+ end
27
+
28
+ def self.es_document_from_confluence_content(content, base_url, restrictions = [])
29
+ ContentNode.new(:node => content, :base_url => base_url, :permissions => restrictions).to_es_document
30
+ end
31
+
32
+ def self.es_document_from_confluence_attachment(attachment, base_url, restrictions = [])
33
+ AttachmentNode.new(:node => attachment, :base_url => base_url, :permissions => restrictions).to_es_document
34
+ end
35
+
36
+ class Node
37
+ attr_reader :node, :base_url, :permissions
38
+
39
+ def initialize(node:, base_url:, permissions: [])
40
+ @node = node
41
+ @base_url = base_url
42
+ @base_url = "#{base_url}/" unless @base_url.ends_with?('/')
43
+ @permissions = permissions
44
+ end
45
+
46
+ def to_es_document
47
+ {
48
+ :id => id,
49
+ :title => title,
50
+ :url => url,
51
+ :type => ConnectorsSdk::Base::Adapter.normalize_enum(type),
52
+ }.merge(fields)
53
+ end
54
+
55
+ def id
56
+ raise NotImplementedError
57
+ end
58
+
59
+ def type
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def title
64
+ raise NotImplementedError
65
+ end
66
+
67
+ def url
68
+ raise NotImplementedError
69
+ end
70
+
71
+ def fields
72
+ {}
73
+ end
74
+
75
+ protected
76
+
77
+ def permissions_hash
78
+ permissions.blank? ? {} : { ConnectorsShared::Constants::ALLOW_FIELD => permissions }
79
+ end
80
+ end
81
+
82
+ class SpaceNode < Node
83
+ def id
84
+ Confluence::Adapter.confluence_space_id_to_es_id(node.fetch('key'))
85
+ end
86
+
87
+ def type
88
+ 'space'
89
+ end
90
+
91
+ def title
92
+ node.name
93
+ end
94
+
95
+ def url
96
+ Addressable::URI.join(base_url, (node._links.webui || node._links.self).gsub(LEADING_SLASH_REGEXP, '')).to_s
97
+ end
98
+
99
+ def path
100
+ title
101
+ end
102
+
103
+ def fields
104
+ permissions_hash
105
+ end
106
+ end
107
+
108
+ class ContentNode < Node
109
+ def id
110
+ Confluence::Adapter.confluence_content_id_to_es_id(node.id)
111
+ end
112
+
113
+ def type
114
+ case node.type
115
+ when 'page'
116
+ node.type
117
+ when 'blogpost'
118
+ 'blog post'
119
+ else
120
+ ConnectorsShared::ExceptionTracking.capture_message("Unknown confluence type: #{node.type}")
121
+ nil
122
+ end
123
+ end
124
+
125
+ def title
126
+ node.title
127
+ end
128
+
129
+ def url
130
+ Addressable::URI.join(base_url, node._links.webui.gsub(LEADING_SLASH_REGEXP, '')).to_s
131
+ end
132
+
133
+ def body
134
+ text_from_html(node.body.export_view.value)
135
+ end
136
+
137
+ def comments
138
+ node.children&.comment&.results&.slice(0, MAX_CONTENT_COMMENTS_TO_INDEX)&.map do |comment|
139
+ text_from_html(comment.body.export_view.value)
140
+ end&.join("\n")
141
+ end
142
+
143
+ def description
144
+ [
145
+ node.space&.name,
146
+ node.ancestors&.map(&:title) # 'attachment' type nodes do not have `ancestors`, making this logic incomplete
147
+ ].flatten.select(&:present?).join('/')
148
+ end
149
+
150
+ def path
151
+ [
152
+ description,
153
+ title
154
+ ].select(&:present?).join('/')
155
+ end
156
+
157
+ def fields
158
+ {
159
+ :description => description,
160
+ :body => body,
161
+ :comments => comments,
162
+ :created_by => node.history&.createdBy&.displayName,
163
+ :project => node.space.try!(:[], :key),
164
+
165
+ :created_at => ConnectorsSdk::Base::Adapter.normalize_date(node.history&.createdDate),
166
+ :last_updated => ConnectorsSdk::Base::Adapter.normalize_date(node.history&.lastUpdated&.when)
167
+ }.merge(permissions_hash)
168
+ end
169
+
170
+ private
171
+
172
+ def text_from_html(raw_html)
173
+ ConnectorsShared::ExtractionUtils.node_descendant_text(Nokogiri::HTML(raw_html))
174
+ end
175
+ end
176
+
177
+ class AttachmentNode < ContentNode
178
+ def id
179
+ Confluence::Adapter.confluence_attachment_id_to_es_id(node.id)
180
+ end
181
+
182
+ def type
183
+ 'attachment'
184
+ end
185
+
186
+ def fields
187
+ mime_type = [
188
+ node.extensions.mediaType,
189
+ ConnectorsSdk::Base::Adapter.mime_type_for_file(node.title)
190
+ ].detect(&:present?)
191
+ extension = ConnectorsSdk::Base::Adapter.extension_for_file(node.title)
192
+
193
+ {
194
+ :size => node.extensions.fileSize,
195
+ :container => node&.container&.title,
196
+
197
+ :description => description,
198
+ :comments => comments,
199
+ :created_by => node.history&.createdBy&.displayName,
200
+ :project => node.space.try!(:[], :key),
201
+
202
+ :created_at => ConnectorsSdk::Base::Adapter.normalize_date(node.history&.createdDate),
203
+ :last_updated => ConnectorsSdk::Base::Adapter.normalize_date(node.history&.lastUpdated&.when)
204
+ }.merge(permissions_hash).tap do |data|
205
+ data[:mime_type] = mime_type if mime_type.present?
206
+ data[:extension] = extension if extension.present?
207
+ end
208
+ end
209
+
210
+ def to_es_document
211
+ super.merge(:_fields_to_preserve => ConnectorsSdk::Confluence::Adapter.fields_to_preserve)
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,143 @@
1
+ #
2
+ # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3
+ # or more contributor license agreements. Licensed under the Elastic License;
4
+ # you may not use this file except in compliance with the Elastic License.
5
+ #
6
+
7
+ # frozen_string_literal: true
8
+
9
+ require 'active_support/json'
10
+ require 'active_support/core_ext/array'
11
+ require 'active_support/core_ext/object'
12
+ require 'hashie/mash'
13
+
14
+ require 'connectors_sdk/atlassian/custom_client'
15
+
16
+ module ConnectorsSdk
17
+ module Confluence
18
+ class CustomClient < ConnectorsSdk::Atlassian::CustomClient
19
+ SEARCH_ENDPOINT = 'rest/api/search'
20
+ CONTENT_SEARCH_ENDPOINT = 'rest/api/content/search'
21
+ CONTENT_EXPAND_FIELDS = %w(body.export_view history.lastUpdated ancestors space children.comment.body.export_view container).freeze
22
+
23
+ def content(space:, types: [], start_at: 0, order_field_and_direction: 'created asc', updated_after: nil, next_value: nil)
24
+ search_helper(
25
+ :endpoint => CONTENT_SEARCH_ENDPOINT,
26
+ :space => space,
27
+ :types => types,
28
+ :start_at => start_at,
29
+ :order_field_and_direction => order_field_and_direction,
30
+ :updated_after => updated_after,
31
+ :next_value => next_value
32
+ )
33
+ end
34
+
35
+ def content_by_id(content_id, include_permissions: false, expand_fields: CONTENT_EXPAND_FIELDS)
36
+ endpoint = "rest/api/content/#{Faraday::Utils.escape(content_id)}"
37
+ if include_permissions
38
+ expand_fields = expand_fields.dup
39
+ expand_fields << 'restrictions.read.restrictions.user'
40
+ expand_fields << 'restrictions.read.restrictions.group'
41
+ end
42
+ response = begin
43
+ parse_and_raise_if_necessary!(get(endpoint, :status => 'any', :expand => expand_fields.join(',')))
44
+ rescue ContentConvertibleError
45
+ # Confluence has a bug when trying to expand `container` for certain items:
46
+ # https://jira.atlassian.com/browse/CONFSERVER-40475
47
+ Connectors::Stats.increment('custom_client.confluence.error.content_convertible')
48
+ parse_and_raise_if_necessary!(get(endpoint, :status => 'any', :expand => (expand_fields - ['container']).join(',')))
49
+ end
50
+ Hashie::Mash.new(response)
51
+ end
52
+
53
+ def spaces(start_at: 0, limit: 50, space_keys: nil, include_permissions: false)
54
+ params = {
55
+ :start => start_at,
56
+ :limit => limit
57
+ }
58
+ params[:spaceKey] = space_keys if space_keys.present?
59
+ params[:expand] = 'permissions' if include_permissions
60
+ response = get('rest/api/space', params)
61
+ Hashie::Mash.new(parse_and_raise_if_necessary!(response))
62
+ end
63
+
64
+ def search(
65
+ space: nil,
66
+ start_at: 0,
67
+ limit: 50,
68
+ types: [],
69
+ expand_fields: [],
70
+ order_field_and_direction: 'created asc',
71
+ updated_after: nil
72
+ )
73
+ search_helper(
74
+ :endpoint => SEARCH_ENDPOINT,
75
+ :space => space,
76
+ :start_at => start_at,
77
+ :limit => limit,
78
+ :types => types,
79
+ :expand_fields => expand_fields,
80
+ :order_field_and_direction => order_field_and_direction,
81
+ :updated_after => updated_after
82
+ )
83
+ end
84
+
85
+ def content_search(cql, expand: [], limit: 25)
86
+ response = get(CONTENT_SEARCH_ENDPOINT, :cql => cql, :expand => expand.join(','), :limit => limit)
87
+ Hashie::Mash.new(parse_and_raise_if_necessary!(response))
88
+ end
89
+
90
+ def me
91
+ response = get('rest/api/user/current')
92
+ Hashie::Mash.new(parse_and_raise_if_necessary!(response))
93
+ end
94
+
95
+ private
96
+
97
+ def search_helper(
98
+ endpoint:,
99
+ space: nil,
100
+ start_at: 0,
101
+ limit: 50,
102
+ types: [],
103
+ expand_fields: [],
104
+ order_field_and_direction: 'created asc',
105
+ updated_after: nil,
106
+ next_value: nil
107
+ )
108
+
109
+ response =
110
+ if next_value.present?
111
+ get(next_value.reverse.chomp('/').reverse)
112
+ else
113
+ params = {
114
+ :cql => generate_cql(:space => space, :types => types, :order_field_and_direction => order_field_and_direction, :updated_after => updated_after),
115
+ :start => start_at,
116
+ :expand => expand_fields.join(','),
117
+ :limit => limit
118
+ }
119
+
120
+ get(endpoint, params)
121
+ end
122
+
123
+ Hashie::Mash.new(parse_and_raise_if_necessary!(response))
124
+ end
125
+
126
+ def generate_cql(space: nil, types: nil, order_field_and_direction: nil, updated_after: nil)
127
+ query_conditions = []
128
+ query_conditions << "space=\"#{space}\"" if space
129
+ query_conditions << "type in (#{types.join(',')})" if types.any?
130
+ query_conditions << "lastmodified > \"#{format_date(updated_after)}\"" if updated_after.present?
131
+
132
+ query_parts = [query_conditions.join(' AND ')]
133
+ query_parts << "order by #{order_field_and_direction}" if order_field_and_direction
134
+
135
+ query_parts.join(' ').strip
136
+ end
137
+
138
+ def format_date(date)
139
+ DateTime.parse(date).strftime('%Y-%m-%d %H:%M')
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,265 @@
1
+ #
2
+ # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3
+ # or more contributor license agreements. Licensed under the Elastic License;
4
+ # you may not use this file except in compliance with the Elastic License.
5
+ #
6
+
7
+ # frozen_string_literal: true
8
+
9
+ require 'connectors_sdk/atlassian/custom_client'
10
+ require 'connectors_sdk/confluence/adapter'
11
+ require 'connectors_sdk/base/extractor'
12
+
13
+ module ConnectorsSdk
14
+ module Confluence
15
+ class Extractor < ConnectorsSdk::Base::Extractor
16
+ CONTENT_OFFSET_CURSOR_KEY = 'content'
17
+ CONTENT_NEXT_CURSOR_KEY = 'next'
18
+ CONTENT_MODIFIED_SINCE_NEXT_CURSOR_KEY = 'modified_since_next'
19
+ CONTENT_MODIFIED_SINCE_CURSOR_KEY = 'content_modified_at'
20
+
21
+ ConnectorsSdk::Base::Extractor::TRANSIENT_SERVER_ERROR_CLASSES << Atlassian::CustomClient::ServiceUnavailableError
22
+
23
+ def yield_document_changes(modified_since: nil)
24
+ @space_permissions_cache = {}
25
+ @content_restriction_cache = {}
26
+ yield_spaces do |space|
27
+ yield_single_document_change(:identifier => "Confluence Space: #{space&.fetch(:key)} (#{space&.webui})") do
28
+ permissions = config.index_permissions ? get_space_permissions(space) : []
29
+ yield :create_or_update, Confluence::Adapter.es_document_from_confluence_space(space, content_base_url, permissions)
30
+ end
31
+
32
+ yield_content_for_space(
33
+ :space => space[:key],
34
+ :types => %w(page blogpost attachment),
35
+ :modified_since => modified_since
36
+ ) do |content|
37
+ restrictions = config.index_permissions ? get_content_restrictions(content) : []
38
+ if content.type == 'attachment'
39
+ document = Confluence::Adapter.es_document_from_confluence_attachment(content, content_base_url, restrictions)
40
+ download_args = download_args_and_proc(
41
+ id: document.fetch(:id),
42
+ name: content.title,
43
+ size: content.extensions.fileSize,
44
+ download_args: { content: content }
45
+ ) do |args|
46
+ download(args)
47
+ end
48
+ yield :create_or_update, document, download_args
49
+ else
50
+ yield :create_or_update, Confluence::Adapter.es_document_from_confluence_content(content, content_base_url, restrictions)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def yield_deleted_ids(ids)
57
+ id_groups = ids.group_by do |id|
58
+ if Confluence::Adapter.es_id_is_confluence_space_id?(id)
59
+ :space
60
+ elsif Confluence::Adapter.es_id_is_confluence_content_id?(id)
61
+ :content
62
+ elsif Confluence::Adapter.es_id_is_confluence_attachment_id?(id)
63
+ :attachment
64
+ else
65
+ :unknown
66
+ end
67
+ end
68
+
69
+ %i(space content attachment).each do |group|
70
+ confluence_ids = Array(id_groups[group]).map { |id| Confluence::Adapter.public_send("es_id_to_confluence_#{group}_id", id) }
71
+ get_ids_for_deleted(confluence_ids, group).each do |deleted_id|
72
+ yield Confluence::Adapter.public_send("confluence_#{group}_id_to_es_id", deleted_id)
73
+ end
74
+ end
75
+ end
76
+
77
+ def download(item)
78
+ content = item[:content]
79
+ client.download("#{content._links.base}#{content._links.download}").body
80
+ end
81
+
82
+ private
83
+
84
+ def content_base_url
85
+ config.base_url
86
+ end
87
+
88
+ def yield_spaces
89
+ @space_cursor ||= 0
90
+ loop do
91
+ response = client.spaces(:start_at => @space_cursor, :include_permissions => config.index_permissions)
92
+ response.results.each do |space|
93
+ yield space
94
+ @space_cursor += 1
95
+ end
96
+ break unless should_continue_looping?(response)
97
+ log_info("Requesting more spaces with cursor: #{@space_cursor}")
98
+ end
99
+ end
100
+
101
+ def yield_content_for_space(space:, types:, modified_since:)
102
+ loop do
103
+ response = client.content(
104
+ :space => space,
105
+ :types => types,
106
+ :order_field_and_direction => modified_since ? 'lastmodified asc' : 'created asc',
107
+ cursoring_param(:modified_since => modified_since, :space => space) => cursoring_value(:modified_since => modified_since, :space => space)
108
+ )
109
+
110
+ response.results.each do |result|
111
+ yield_single_document_change(:identifier => "Confluence ID: #{result&.id} (#{result&.webui})") do
112
+ content = client.content_by_id(result.id, :include_permissions => config.index_permissions)
113
+ yield content if content.status == 'current'
114
+ end
115
+ end
116
+ update_content_cursors(space, response, modified_since)
117
+
118
+ break unless should_continue_looping?(response)
119
+ log_info("Requesting more content for space #{space} with cursor: #{cursoring_param(:modified_since => modified_since, :space => space)} #{cursoring_value(:modified_since => modified_since, :space => space)}")
120
+ end
121
+ end
122
+
123
+ def next_cursor_key(modified_since:)
124
+ modified_since ? CONTENT_MODIFIED_SINCE_NEXT_CURSOR_KEY : CONTENT_NEXT_CURSOR_KEY
125
+ end
126
+
127
+ def cursoring_param(modified_since:, space:)
128
+ return :next_value if config.cursors.dig(next_cursor_key(:modified_since => modified_since), space).present?
129
+
130
+ modified_since ? :updated_after : :start_at
131
+ end
132
+
133
+ def cursoring_value(modified_since:, space:)
134
+ next_value = config.cursors.dig(next_cursor_key(:modified_since => modified_since), space)
135
+ return next_value if next_value.present?
136
+
137
+ get_content_cursors(space, modified_since)[space]
138
+ end
139
+
140
+ def update_content_cursors(space, response, modified_since)
141
+ if response._links&.next.present?
142
+ config.cursors[next_cursor_key(:modified_since => modified_since)] ||= {}
143
+ config.cursors[next_cursor_key(:modified_since => modified_since)][space] = response._links.next
144
+ end
145
+
146
+ if response.results && modified_since
147
+ updated_cursor = response.results.last&.history&.lastUpdated&.when
148
+ config.cursors[CONTENT_MODIFIED_SINCE_CURSOR_KEY][space] = updated_cursor if updated_cursor
149
+ else
150
+ config.cursors[CONTENT_OFFSET_CURSOR_KEY][space] += response.results.size
151
+ end
152
+ end
153
+
154
+ def get_content_cursors(space, modified_since)
155
+ modified_since ? get_content_modified_since_cursors(space, modified_since) : get_content_offset_cursors(space)
156
+ end
157
+
158
+ def get_content_offset_cursors(space)
159
+ config.cursors[CONTENT_OFFSET_CURSOR_KEY] ||= {}
160
+ config.cursors[CONTENT_OFFSET_CURSOR_KEY][space] ||= 0
161
+ config.cursors[CONTENT_OFFSET_CURSOR_KEY]
162
+ end
163
+
164
+ def get_content_modified_since_cursors(space, modified_since)
165
+ config.cursors[CONTENT_MODIFIED_SINCE_CURSOR_KEY] ||= {}
166
+ config.cursors[CONTENT_MODIFIED_SINCE_CURSOR_KEY][space] ||= modified_since.to_s
167
+ config.cursors[CONTENT_MODIFIED_SINCE_CURSOR_KEY]
168
+ end
169
+
170
+ def should_continue_looping?(response)
171
+ response.results&.size.to_i > 0 && response._links.next.present?
172
+ end
173
+
174
+ def get_ids_for_deleted(search_ids, group)
175
+ return [] if search_ids.empty?
176
+
177
+ response, id_sym =
178
+ if group == :space
179
+ [client.spaces(:space_keys => search_ids, :limit => search_ids.size), :key]
180
+ else
181
+ [client.content_search("id in (#{search_ids.join(',')})", :limit => search_ids.size), :id]
182
+ end
183
+ found_ids = response.results.map { |result| result.fetch(id_sym) }.map(&:to_s)
184
+ search_ids - found_ids
185
+ end
186
+
187
+ def get_space_permissions(space)
188
+ space_permissions = space.permissions&.select { |permission| %w(read administer).include?(permission.operation.operation) }
189
+ if space_permissions.nil? || space_permissions.any?(&:anonymousAccess)
190
+ @space_permissions_cache[space.fetch('key')] = []
191
+ return []
192
+ end
193
+
194
+ subjects = space_permissions.flat_map(&:subjects)
195
+ permissions = [
196
+ subjects.select { |subject| subject.has_key?(:user) }.map { |subject| subject.user.results.map { |user| "user:#{user.accountId}" } },
197
+ subjects.select { |subject| subject.has_key?(:group) }.map { |subject| subject.group.results.map { |group| "group:#{group.name}" } }
198
+ ].flatten.uniq
199
+ @space_permissions_cache[space.fetch('key')] = permissions
200
+ end
201
+
202
+ def get_user_spaces(user_id)
203
+ (@space_permissions_cache || {}).select { |_, permissions| "user:#{user_id}".in?(permissions) }.keys.uniq
204
+ end
205
+
206
+ def get_group_spaces(group_name)
207
+ (@space_permissions_cache || {}).select { |_, permissions| "group:#{group_name}".in?(permissions) }.keys.uniq
208
+ end
209
+
210
+ # get_content_restrictions returns the final restriction of the content, taking into consideration inherited restrictions
211
+ # if the content is an attachment, the final restriction will will be either space permissions
212
+ # or (in case they aren't empty) the intersection of
213
+ # 1. its restriction
214
+ # 2. its container's restriction
215
+ # 3. its container's ancestors' restriction
216
+ # if the content is a page or blog post, the final restriction will be either space permissions
217
+ # or (in case they aren't empty) the intersection of
218
+ # 1. its restrictions
219
+ # 2. its ancestors' restrictions
220
+ def get_content_restrictions(content)
221
+ restrictions = []
222
+ restrictions << extract_restrictions(content.restrictions&.read&.restrictions)
223
+
224
+ # space permissions should always be available in cache
225
+ space_key = content.space&.fetch('key')
226
+ space_restrictions = (@space_permissions_cache[space_key] || [])
227
+
228
+ ancestors = content.ancestors || []
229
+
230
+ if content.type == 'attachment'
231
+ if content.container&.type == 'global'
232
+ log_info("Skipping ancestor restrictions as it is a space and should already be cached: [#{content.id}] in [#{content.container&.type}_#{content.container&.id}]")
233
+ else
234
+ container = client.content_by_id(content.container&.id, :include_permissions => true)
235
+ @content_restriction_cache[container.id] ||= extract_restrictions(container.restrictions&.read&.restrictions)
236
+ restrictions << @content_restriction_cache[container.id]
237
+ ancestors = container.ancestors
238
+ end
239
+ end
240
+
241
+ ancestors.each do |ancestor|
242
+ restrictions << get_restrictions_by_content_id(ancestor.id)
243
+ end
244
+
245
+ combined_restrictions = restrictions.select(&:any?).reduce(:&)
246
+ @content_restriction_cache[content.id] = combined_restrictions || []
247
+ (combined_restrictions || space_restrictions).map { |restriction| "#{space_key}/#{restriction}" }
248
+ end
249
+
250
+ def get_restrictions_by_content_id(content_id)
251
+ @content_restriction_cache[content_id] ||= begin
252
+ content = client.content_by_id(content_id, :include_permissions => true)
253
+ extract_restrictions(content.restrictions&.read&.restrictions)
254
+ end
255
+ end
256
+
257
+ def extract_restrictions(restrictions)
258
+ [
259
+ restrictions&.user&.results&.map { |user| "user:#{user.accountId}" },
260
+ restrictions&.group&.results&.map { |group| "group:#{group.name}" }
261
+ ].flatten.compact.uniq
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,64 @@
1
+ #
2
+ # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3
+ # or more contributor license agreements. Licensed under the Elastic License;
4
+ # you may not use this file except in compliance with the Elastic License.
5
+ #
6
+
7
+ # frozen_string_literal: true
8
+
9
+ require 'connectors_sdk/base/authorization'
10
+
11
+ module ConnectorsSdk
12
+ module ConfluenceCloud
13
+ class Authorization < ConnectorsSdk::Base::Authorization
14
+ class << self
15
+ def access_token(params)
16
+ tokens = super
17
+ tokens.merge(:cloud_id => fetch_cloud_id(tokens['access_token'], params[:external_connector_base_url]))
18
+ end
19
+
20
+ def oauth_scope
21
+ %w[
22
+ offline_access
23
+
24
+ read:confluence-content.all
25
+ read:confluence-content.summary
26
+ read:confluence-props
27
+ read:confluence-space.summary
28
+ read:confluence-user
29
+ readonly:content.attachment:confluence
30
+ search:confluence
31
+ ]
32
+ end
33
+
34
+ private
35
+
36
+ def authorization_url
37
+ 'https://auth.atlassian.com/authorize'
38
+ end
39
+
40
+ def token_credential_uri
41
+ 'https://auth.atlassian.com/oauth/token'
42
+ end
43
+
44
+ def additional_parameters
45
+ { :prompt => 'consent', :audience => 'api.atlassian.com' }
46
+ end
47
+
48
+ def fetch_cloud_id(access_token, base_url)
49
+ response = HTTPClient.new.get(
50
+ 'https://api.atlassian.com/oauth/token/accessible-resources',
51
+ nil,
52
+ 'Accept' => 'application/json',
53
+ 'Authorization' => "Bearer #{access_token}"
54
+ )
55
+ raise 'unable to fetch cloud id' unless HTTP::Status.successful?(response.status)
56
+ json = JSON.parse(response.body)
57
+
58
+ site = json.find { |sites| sites['url'] == base_url } || {}
59
+ site.fetch('id') { raise 'unable to fetch cloud id' }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end