##// END OF EJS Templates
release: merge back stable branch into default
marcink -
r2343:33e1a76f merge default
parent child Browse files
Show More
@@ -0,0 +1,45 b''
1 |RCE| 4.10.3 |RNS|
2 ------------------
3
4 Release Date
5 ^^^^^^^^^^^^
6
7 - 2017-11-11
8
9
10 New Features
11 ^^^^^^^^^^^^
12
13
14
15 General
16 ^^^^^^^
17
18 - ldap: increase timeouts and timelimits for operations
19
20
21 Security
22 ^^^^^^^^
23
24 - security(low): fix self xss on repo downloads picker for svn case.
25
26
27 Performance
28 ^^^^^^^^^^^
29
30
31
32 Fixes
33 ^^^^^
34
35
36 - Pull requests: loosen permissions on creation of PR, fixing regression.
37 - LDAP: fix regression in ldap search filter implementation after upgrade to
38 newer version of python-ldap library.
39
40
41 Upgrade notes
42 ^^^^^^^^^^^^^
43
44 - Changes helpers to support regression in PR creation and increase
45 LDAP server timeouts, no potential problems with upgrade.
@@ -1,26 +1,27 b''
1 1 1bd3e92b7e2e2d2024152b34bb88dff1db544a71 v4.0.0
2 2 170c5398320ea6cddd50955e88d408794c21d43a v4.0.1
3 3 c3fe200198f5aa34cf2e4066df2881a9cefe3704 v4.1.0
4 4 7fd5c850745e2ea821fb4406af5f4bff9b0a7526 v4.1.1
5 5 41c87da28a179953df86061d817bc35533c66dd2 v4.1.2
6 6 baaf9f5bcea3bae0ef12ae20c8b270482e62abb6 v4.2.0
7 7 32a70c7e56844a825f61df496ee5eaf8c3c4e189 v4.2.1
8 8 fa695cdb411d294679ac081d595ac654e5613b03 v4.3.0
9 9 0e4dc11b58cad833c513fe17bac39e6850edf959 v4.3.1
10 10 8a876f48f5cb1d018b837db28ff928500cb32cfb v4.4.0
11 11 8dd86b410b1aac086ffdfc524ef300f896af5047 v4.4.1
12 12 d2514226abc8d3b4f6fb57765f47d1b6fb360a05 v4.4.2
13 13 27d783325930af6dad2741476c0d0b1b7c8415c2 v4.5.0
14 14 7f2016f352abcbdba4a19d4039c386e9629449da v4.5.1
15 15 416fec799314c70a5c780fb28b3357b08869333a v4.5.2
16 16 27c3b85fafc83143e6678fbc3da69e1615bcac55 v4.6.0
17 17 5ad13deb9118c2a5243d4032d4d9cc174e5872db v4.6.1
18 18 2be921e01fa24bb102696ada596f87464c3666f6 v4.7.0
19 19 7198bdec29c2872c974431d55200d0398354cdb1 v4.7.1
20 20 bd1c8d230fe741c2dfd7100a0ef39fd0774fd581 v4.7.2
21 21 9731914f89765d9628dc4dddc84bc9402aa124c8 v4.8.0
22 22 c5a2b7d0e4bbdebc4a62d7b624befe375207b659 v4.9.0
23 23 d9aa3b27ac9f7e78359775c75fedf7bfece232f1 v4.9.1
24 24 4ba4d74981cec5d6b28b158f875a2540952c2f74 v4.10.0
25 25 0a6821cbd6b0b3c21503002f88800679fa35ab63 v4.10.1
26 26 434ad90ec8d621f4416074b84f6e9ce03964defb v4.10.2
27 68baee10e698da2724c6e0f698c03a6abb993bf2 v4.10.3
@@ -1,103 +1,104 b''
1 1 .. _rhodecode-release-notes-ref:
2 2
3 3 Release Notes
4 4 =============
5 5
6 6 |RCE| 4.x Versions
7 7 ------------------
8 8
9 9 .. toctree::
10 10 :maxdepth: 1
11 11
12 release-notes-4.10.3.rst
12 13 release-notes-4.10.2.rst
13 14 release-notes-4.10.1.rst
14 15 release-notes-4.10.0.rst
15 16 release-notes-4.9.1.rst
16 17 release-notes-4.9.0.rst
17 18 release-notes-4.8.0.rst
18 19 release-notes-4.7.2.rst
19 20 release-notes-4.7.1.rst
20 21 release-notes-4.7.0.rst
21 22 release-notes-4.6.1.rst
22 23 release-notes-4.6.0.rst
23 24 release-notes-4.5.2.rst
24 25 release-notes-4.5.1.rst
25 26 release-notes-4.5.0.rst
26 27 release-notes-4.4.2.rst
27 28 release-notes-4.4.1.rst
28 29 release-notes-4.4.0.rst
29 30 release-notes-4.3.1.rst
30 31 release-notes-4.3.0.rst
31 32 release-notes-4.2.1.rst
32 33 release-notes-4.2.0.rst
33 34 release-notes-4.1.2.rst
34 35 release-notes-4.1.1.rst
35 36 release-notes-4.1.0.rst
36 37 release-notes-4.0.1.rst
37 38 release-notes-4.0.0.rst
38 39
39 40 |RCE| 3.x Versions
40 41 ------------------
41 42
42 43 .. toctree::
43 44 :maxdepth: 1
44 45
45 46 release-notes-3.8.4.rst
46 47 release-notes-3.8.3.rst
47 48 release-notes-3.8.2.rst
48 49 release-notes-3.8.1.rst
49 50 release-notes-3.8.0.rst
50 51 release-notes-3.7.1.rst
51 52 release-notes-3.7.0.rst
52 53 release-notes-3.6.1.rst
53 54 release-notes-3.6.0.rst
54 55 release-notes-3.5.2.rst
55 56 release-notes-3.5.1.rst
56 57 release-notes-3.5.0.rst
57 58 release-notes-3.4.1.rst
58 59 release-notes-3.4.0.rst
59 60 release-notes-3.3.4.rst
60 61 release-notes-3.3.3.rst
61 62 release-notes-3.3.2.rst
62 63 release-notes-3.3.1.rst
63 64 release-notes-3.3.0.rst
64 65 release-notes-3.2.3.rst
65 66 release-notes-3.2.2.rst
66 67 release-notes-3.2.1.rst
67 68 release-notes-3.2.0.rst
68 69 release-notes-3.1.1.rst
69 70 release-notes-3.1.0.rst
70 71 release-notes-3.0.2.rst
71 72 release-notes-3.0.1.rst
72 73 release-notes-3.0.0.rst
73 74
74 75 |RCE| 2.x Versions
75 76 ------------------
76 77
77 78 .. toctree::
78 79 :maxdepth: 1
79 80
80 81 release-notes-2.2.8.rst
81 82 release-notes-2.2.7.rst
82 83 release-notes-2.2.6.rst
83 84 release-notes-2.2.5.rst
84 85 release-notes-2.2.4.rst
85 86 release-notes-2.2.3.rst
86 87 release-notes-2.2.2.rst
87 88 release-notes-2.2.1.rst
88 89 release-notes-2.2.0.rst
89 90 release-notes-2.1.0.rst
90 91 release-notes-2.0.2.rst
91 92 release-notes-2.0.1.rst
92 93 release-notes-2.0.0.rst
93 94
94 95 |RCE| 1.x Versions
95 96 ------------------
96 97
97 98 .. toctree::
98 99 :maxdepth: 1
99 100
100 101 release-notes-1.7.2.rst
101 102 release-notes-1.7.1.rst
102 103 release-notes-1.7.0.rst
103 104 release-notes-1.6.0.rst
@@ -1,1235 +1,1236 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode import events
33 33 from rhodecode.apps._base import RepoAppView, DataGridAppView
34 34
35 35 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
36 36 from rhodecode.lib.base import vcs_operation_context
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.auth import (
39 39 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
40 40 NotAnonymous, CSRFRequired)
41 41 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
42 42 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
43 43 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
44 44 RepositoryRequirementError, NodeDoesNotExistError, EmptyRepositoryError)
45 45 from rhodecode.model.changeset_status import ChangesetStatusModel
46 46 from rhodecode.model.comment import CommentsModel
47 47 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
48 48 ChangesetComment, ChangesetStatus, Repository)
49 49 from rhodecode.model.forms import PullRequestForm
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
52 52 from rhodecode.model.scm import ScmModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class RepoPullRequestsView(RepoAppView, DataGridAppView):
58 58
59 59 def load_default_context(self):
60 60 c = self._get_local_tmpl_context(include_app_defaults=True)
61 61 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
62 62 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
63 63 self._register_global_c(c)
64 64 return c
65 65
66 66 def _get_pull_requests_list(
67 67 self, repo_name, source, filter_type, opened_by, statuses):
68 68
69 69 draw, start, limit = self._extract_chunk(self.request)
70 70 search_q, order_by, order_dir = self._extract_ordering(self.request)
71 71 _render = self.request.get_partial_renderer(
72 72 'rhodecode:templates/data_table/_dt_elements.mako')
73 73
74 74 # pagination
75 75
76 76 if filter_type == 'awaiting_review':
77 77 pull_requests = PullRequestModel().get_awaiting_review(
78 78 repo_name, source=source, opened_by=opened_by,
79 79 statuses=statuses, offset=start, length=limit,
80 80 order_by=order_by, order_dir=order_dir)
81 81 pull_requests_total_count = PullRequestModel().count_awaiting_review(
82 82 repo_name, source=source, statuses=statuses,
83 83 opened_by=opened_by)
84 84 elif filter_type == 'awaiting_my_review':
85 85 pull_requests = PullRequestModel().get_awaiting_my_review(
86 86 repo_name, source=source, opened_by=opened_by,
87 87 user_id=self._rhodecode_user.user_id, statuses=statuses,
88 88 offset=start, length=limit, order_by=order_by,
89 89 order_dir=order_dir)
90 90 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
91 91 repo_name, source=source, user_id=self._rhodecode_user.user_id,
92 92 statuses=statuses, opened_by=opened_by)
93 93 else:
94 94 pull_requests = PullRequestModel().get_all(
95 95 repo_name, source=source, opened_by=opened_by,
96 96 statuses=statuses, offset=start, length=limit,
97 97 order_by=order_by, order_dir=order_dir)
98 98 pull_requests_total_count = PullRequestModel().count_all(
99 99 repo_name, source=source, statuses=statuses,
100 100 opened_by=opened_by)
101 101
102 102 data = []
103 103 comments_model = CommentsModel()
104 104 for pr in pull_requests:
105 105 comments = comments_model.get_all_comments(
106 106 self.db_repo.repo_id, pull_request=pr)
107 107
108 108 data.append({
109 109 'name': _render('pullrequest_name',
110 110 pr.pull_request_id, pr.target_repo.repo_name),
111 111 'name_raw': pr.pull_request_id,
112 112 'status': _render('pullrequest_status',
113 113 pr.calculated_review_status()),
114 114 'title': _render(
115 115 'pullrequest_title', pr.title, pr.description),
116 116 'description': h.escape(pr.description),
117 117 'updated_on': _render('pullrequest_updated_on',
118 118 h.datetime_to_time(pr.updated_on)),
119 119 'updated_on_raw': h.datetime_to_time(pr.updated_on),
120 120 'created_on': _render('pullrequest_updated_on',
121 121 h.datetime_to_time(pr.created_on)),
122 122 'created_on_raw': h.datetime_to_time(pr.created_on),
123 123 'author': _render('pullrequest_author',
124 124 pr.author.full_contact, ),
125 125 'author_raw': pr.author.full_name,
126 126 'comments': _render('pullrequest_comments', len(comments)),
127 127 'comments_raw': len(comments),
128 128 'closed': pr.is_closed(),
129 129 })
130 130
131 131 data = ({
132 132 'draw': draw,
133 133 'data': data,
134 134 'recordsTotal': pull_requests_total_count,
135 135 'recordsFiltered': pull_requests_total_count,
136 136 })
137 137 return data
138 138
139 139 @LoginRequired()
140 140 @HasRepoPermissionAnyDecorator(
141 141 'repository.read', 'repository.write', 'repository.admin')
142 142 @view_config(
143 143 route_name='pullrequest_show_all', request_method='GET',
144 144 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
145 145 def pull_request_list(self):
146 146 c = self.load_default_context()
147 147
148 148 req_get = self.request.GET
149 149 c.source = str2bool(req_get.get('source'))
150 150 c.closed = str2bool(req_get.get('closed'))
151 151 c.my = str2bool(req_get.get('my'))
152 152 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
153 153 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
154 154
155 155 c.active = 'open'
156 156 if c.my:
157 157 c.active = 'my'
158 158 if c.closed:
159 159 c.active = 'closed'
160 160 if c.awaiting_review and not c.source:
161 161 c.active = 'awaiting'
162 162 if c.source and not c.awaiting_review:
163 163 c.active = 'source'
164 164 if c.awaiting_my_review:
165 165 c.active = 'awaiting_my'
166 166
167 167 return self._get_template_context(c)
168 168
169 169 @LoginRequired()
170 170 @HasRepoPermissionAnyDecorator(
171 171 'repository.read', 'repository.write', 'repository.admin')
172 172 @view_config(
173 173 route_name='pullrequest_show_all_data', request_method='GET',
174 174 renderer='json_ext', xhr=True)
175 175 def pull_request_list_data(self):
176 176
177 177 # additional filters
178 178 req_get = self.request.GET
179 179 source = str2bool(req_get.get('source'))
180 180 closed = str2bool(req_get.get('closed'))
181 181 my = str2bool(req_get.get('my'))
182 182 awaiting_review = str2bool(req_get.get('awaiting_review'))
183 183 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
184 184
185 185 filter_type = 'awaiting_review' if awaiting_review \
186 186 else 'awaiting_my_review' if awaiting_my_review \
187 187 else None
188 188
189 189 opened_by = None
190 190 if my:
191 191 opened_by = [self._rhodecode_user.user_id]
192 192
193 193 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
194 194 if closed:
195 195 statuses = [PullRequest.STATUS_CLOSED]
196 196
197 197 data = self._get_pull_requests_list(
198 198 repo_name=self.db_repo_name, source=source,
199 199 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
200 200
201 201 return data
202 202
203 203 def _get_pr_version(self, pull_request_id, version=None):
204 204 at_version = None
205 205
206 206 if version and version == 'latest':
207 207 pull_request_ver = PullRequest.get(pull_request_id)
208 208 pull_request_obj = pull_request_ver
209 209 _org_pull_request_obj = pull_request_obj
210 210 at_version = 'latest'
211 211 elif version:
212 212 pull_request_ver = PullRequestVersion.get_or_404(version)
213 213 pull_request_obj = pull_request_ver
214 214 _org_pull_request_obj = pull_request_ver.pull_request
215 215 at_version = pull_request_ver.pull_request_version_id
216 216 else:
217 217 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
218 218 pull_request_id)
219 219
220 220 pull_request_display_obj = PullRequest.get_pr_display_object(
221 221 pull_request_obj, _org_pull_request_obj)
222 222
223 223 return _org_pull_request_obj, pull_request_obj, \
224 224 pull_request_display_obj, at_version
225 225
226 226 def _get_diffset(self, source_repo_name, source_repo,
227 227 source_ref_id, target_ref_id,
228 228 target_commit, source_commit, diff_limit, fulldiff,
229 229 file_limit, display_inline_comments):
230 230
231 231 vcs_diff = PullRequestModel().get_diff(
232 232 source_repo, source_ref_id, target_ref_id)
233 233
234 234 diff_processor = diffs.DiffProcessor(
235 235 vcs_diff, format='newdiff', diff_limit=diff_limit,
236 236 file_limit=file_limit, show_full_diff=fulldiff)
237 237
238 238 _parsed = diff_processor.prepare()
239 239
240 240 def _node_getter(commit):
241 241 def get_node(fname):
242 242 try:
243 243 return commit.get_node(fname)
244 244 except NodeDoesNotExistError:
245 245 return None
246 246
247 247 return get_node
248 248
249 249 diffset = codeblocks.DiffSet(
250 250 repo_name=self.db_repo_name,
251 251 source_repo_name=source_repo_name,
252 252 source_node_getter=_node_getter(target_commit),
253 253 target_node_getter=_node_getter(source_commit),
254 254 comments=display_inline_comments
255 255 )
256 256 diffset = diffset.render_patchset(
257 257 _parsed, target_commit.raw_id, source_commit.raw_id)
258 258
259 259 return diffset
260 260
261 261 @LoginRequired()
262 262 @HasRepoPermissionAnyDecorator(
263 263 'repository.read', 'repository.write', 'repository.admin')
264 264 @view_config(
265 265 route_name='pullrequest_show', request_method='GET',
266 266 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
267 267 def pull_request_show(self):
268 268 pull_request_id = self.request.matchdict['pull_request_id']
269 269
270 270 c = self.load_default_context()
271 271
272 272 version = self.request.GET.get('version')
273 273 from_version = self.request.GET.get('from_version') or version
274 274 merge_checks = self.request.GET.get('merge_checks')
275 275 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
276 276
277 277 (pull_request_latest,
278 278 pull_request_at_ver,
279 279 pull_request_display_obj,
280 280 at_version) = self._get_pr_version(
281 281 pull_request_id, version=version)
282 282 pr_closed = pull_request_latest.is_closed()
283 283
284 284 if pr_closed and (version or from_version):
285 285 # not allow to browse versions
286 286 raise HTTPFound(h.route_path(
287 287 'pullrequest_show', repo_name=self.db_repo_name,
288 288 pull_request_id=pull_request_id))
289 289
290 290 versions = pull_request_display_obj.versions()
291 291
292 292 c.at_version = at_version
293 293 c.at_version_num = (at_version
294 294 if at_version and at_version != 'latest'
295 295 else None)
296 296 c.at_version_pos = ChangesetComment.get_index_from_version(
297 297 c.at_version_num, versions)
298 298
299 299 (prev_pull_request_latest,
300 300 prev_pull_request_at_ver,
301 301 prev_pull_request_display_obj,
302 302 prev_at_version) = self._get_pr_version(
303 303 pull_request_id, version=from_version)
304 304
305 305 c.from_version = prev_at_version
306 306 c.from_version_num = (prev_at_version
307 307 if prev_at_version and prev_at_version != 'latest'
308 308 else None)
309 309 c.from_version_pos = ChangesetComment.get_index_from_version(
310 310 c.from_version_num, versions)
311 311
312 312 # define if we're in COMPARE mode or VIEW at version mode
313 313 compare = at_version != prev_at_version
314 314
315 315 # pull_requests repo_name we opened it against
316 316 # ie. target_repo must match
317 317 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
318 318 raise HTTPNotFound()
319 319
320 320 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
321 321 pull_request_at_ver)
322 322
323 323 c.pull_request = pull_request_display_obj
324 324 c.pull_request_latest = pull_request_latest
325 325
326 326 if compare or (at_version and not at_version == 'latest'):
327 327 c.allowed_to_change_status = False
328 328 c.allowed_to_update = False
329 329 c.allowed_to_merge = False
330 330 c.allowed_to_delete = False
331 331 c.allowed_to_comment = False
332 332 c.allowed_to_close = False
333 333 else:
334 334 can_change_status = PullRequestModel().check_user_change_status(
335 335 pull_request_at_ver, self._rhodecode_user)
336 336 c.allowed_to_change_status = can_change_status and not pr_closed
337 337
338 338 c.allowed_to_update = PullRequestModel().check_user_update(
339 339 pull_request_latest, self._rhodecode_user) and not pr_closed
340 340 c.allowed_to_merge = PullRequestModel().check_user_merge(
341 341 pull_request_latest, self._rhodecode_user) and not pr_closed
342 342 c.allowed_to_delete = PullRequestModel().check_user_delete(
343 343 pull_request_latest, self._rhodecode_user) and not pr_closed
344 344 c.allowed_to_comment = not pr_closed
345 345 c.allowed_to_close = c.allowed_to_merge and not pr_closed
346 346
347 347 c.forbid_adding_reviewers = False
348 348 c.forbid_author_to_review = False
349 349 c.forbid_commit_author_to_review = False
350 350
351 351 if pull_request_latest.reviewer_data and \
352 352 'rules' in pull_request_latest.reviewer_data:
353 353 rules = pull_request_latest.reviewer_data['rules'] or {}
354 354 try:
355 355 c.forbid_adding_reviewers = rules.get(
356 356 'forbid_adding_reviewers')
357 357 c.forbid_author_to_review = rules.get(
358 358 'forbid_author_to_review')
359 359 c.forbid_commit_author_to_review = rules.get(
360 360 'forbid_commit_author_to_review')
361 361 except Exception:
362 362 pass
363 363
364 364 # check merge capabilities
365 365 _merge_check = MergeCheck.validate(
366 366 pull_request_latest, user=self._rhodecode_user,
367 367 translator=self.request.translate)
368 368 c.pr_merge_errors = _merge_check.error_details
369 369 c.pr_merge_possible = not _merge_check.failed
370 370 c.pr_merge_message = _merge_check.merge_msg
371 371
372 372 c.pr_merge_info = MergeCheck.get_merge_conditions(
373 373 pull_request_latest, translator=self.request.translate)
374 374
375 375 c.pull_request_review_status = _merge_check.review_status
376 376 if merge_checks:
377 377 self.request.override_renderer = \
378 378 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
379 379 return self._get_template_context(c)
380 380
381 381 comments_model = CommentsModel()
382 382
383 383 # reviewers and statuses
384 384 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
385 385 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
386 386
387 387 # GENERAL COMMENTS with versions #
388 388 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
389 389 q = q.order_by(ChangesetComment.comment_id.asc())
390 390 general_comments = q
391 391
392 392 # pick comments we want to render at current version
393 393 c.comment_versions = comments_model.aggregate_comments(
394 394 general_comments, versions, c.at_version_num)
395 395 c.comments = c.comment_versions[c.at_version_num]['until']
396 396
397 397 # INLINE COMMENTS with versions #
398 398 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
399 399 q = q.order_by(ChangesetComment.comment_id.asc())
400 400 inline_comments = q
401 401
402 402 c.inline_versions = comments_model.aggregate_comments(
403 403 inline_comments, versions, c.at_version_num, inline=True)
404 404
405 405 # inject latest version
406 406 latest_ver = PullRequest.get_pr_display_object(
407 407 pull_request_latest, pull_request_latest)
408 408
409 409 c.versions = versions + [latest_ver]
410 410
411 411 # if we use version, then do not show later comments
412 412 # than current version
413 413 display_inline_comments = collections.defaultdict(
414 414 lambda: collections.defaultdict(list))
415 415 for co in inline_comments:
416 416 if c.at_version_num:
417 417 # pick comments that are at least UPTO given version, so we
418 418 # don't render comments for higher version
419 419 should_render = co.pull_request_version_id and \
420 420 co.pull_request_version_id <= c.at_version_num
421 421 else:
422 422 # showing all, for 'latest'
423 423 should_render = True
424 424
425 425 if should_render:
426 426 display_inline_comments[co.f_path][co.line_no].append(co)
427 427
428 428 # load diff data into template context, if we use compare mode then
429 429 # diff is calculated based on changes between versions of PR
430 430
431 431 source_repo = pull_request_at_ver.source_repo
432 432 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
433 433
434 434 target_repo = pull_request_at_ver.target_repo
435 435 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
436 436
437 437 if compare:
438 438 # in compare switch the diff base to latest commit from prev version
439 439 target_ref_id = prev_pull_request_display_obj.revisions[0]
440 440
441 441 # despite opening commits for bookmarks/branches/tags, we always
442 442 # convert this to rev to prevent changes after bookmark or branch change
443 443 c.source_ref_type = 'rev'
444 444 c.source_ref = source_ref_id
445 445
446 446 c.target_ref_type = 'rev'
447 447 c.target_ref = target_ref_id
448 448
449 449 c.source_repo = source_repo
450 450 c.target_repo = target_repo
451 451
452 452 c.commit_ranges = []
453 453 source_commit = EmptyCommit()
454 454 target_commit = EmptyCommit()
455 455 c.missing_requirements = False
456 456
457 457 source_scm = source_repo.scm_instance()
458 458 target_scm = target_repo.scm_instance()
459 459
460 460 # try first shadow repo, fallback to regular repo
461 461 try:
462 462 commits_source_repo = pull_request_latest.get_shadow_repo()
463 463 except Exception:
464 464 log.debug('Failed to get shadow repo', exc_info=True)
465 465 commits_source_repo = source_scm
466 466
467 467 c.commits_source_repo = commits_source_repo
468 468 commit_cache = {}
469 469 try:
470 470 pre_load = ["author", "branch", "date", "message"]
471 471 show_revs = pull_request_at_ver.revisions
472 472 for rev in show_revs:
473 473 comm = commits_source_repo.get_commit(
474 474 commit_id=rev, pre_load=pre_load)
475 475 c.commit_ranges.append(comm)
476 476 commit_cache[comm.raw_id] = comm
477 477
478 478 # Order here matters, we first need to get target, and then
479 479 # the source
480 480 target_commit = commits_source_repo.get_commit(
481 481 commit_id=safe_str(target_ref_id))
482 482
483 483 source_commit = commits_source_repo.get_commit(
484 484 commit_id=safe_str(source_ref_id))
485 485
486 486 except CommitDoesNotExistError:
487 487 log.warning(
488 488 'Failed to get commit from `{}` repo'.format(
489 489 commits_source_repo), exc_info=True)
490 490 except RepositoryRequirementError:
491 491 log.warning(
492 492 'Failed to get all required data from repo', exc_info=True)
493 493 c.missing_requirements = True
494 494
495 495 c.ancestor = None # set it to None, to hide it from PR view
496 496
497 497 try:
498 498 ancestor_id = source_scm.get_common_ancestor(
499 499 source_commit.raw_id, target_commit.raw_id, target_scm)
500 500 c.ancestor_commit = source_scm.get_commit(ancestor_id)
501 501 except Exception:
502 502 c.ancestor_commit = None
503 503
504 504 c.statuses = source_repo.statuses(
505 505 [x.raw_id for x in c.commit_ranges])
506 506
507 507 # auto collapse if we have more than limit
508 508 collapse_limit = diffs.DiffProcessor._collapse_commits_over
509 509 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
510 510 c.compare_mode = compare
511 511
512 512 # diff_limit is the old behavior, will cut off the whole diff
513 513 # if the limit is applied otherwise will just hide the
514 514 # big files from the front-end
515 515 diff_limit = c.visual.cut_off_limit_diff
516 516 file_limit = c.visual.cut_off_limit_file
517 517
518 518 c.missing_commits = False
519 519 if (c.missing_requirements
520 520 or isinstance(source_commit, EmptyCommit)
521 521 or source_commit == target_commit):
522 522
523 523 c.missing_commits = True
524 524 else:
525 525
526 526 c.diffset = self._get_diffset(
527 527 c.source_repo.repo_name, commits_source_repo,
528 528 source_ref_id, target_ref_id,
529 529 target_commit, source_commit,
530 530 diff_limit, c.fulldiff, file_limit, display_inline_comments)
531 531
532 532 c.limited_diff = c.diffset.limited_diff
533 533
534 534 # calculate removed files that are bound to comments
535 535 comment_deleted_files = [
536 536 fname for fname in display_inline_comments
537 537 if fname not in c.diffset.file_stats]
538 538
539 539 c.deleted_files_comments = collections.defaultdict(dict)
540 540 for fname, per_line_comments in display_inline_comments.items():
541 541 if fname in comment_deleted_files:
542 542 c.deleted_files_comments[fname]['stats'] = 0
543 543 c.deleted_files_comments[fname]['comments'] = list()
544 544 for lno, comments in per_line_comments.items():
545 545 c.deleted_files_comments[fname]['comments'].extend(
546 546 comments)
547 547
548 548 # this is a hack to properly display links, when creating PR, the
549 549 # compare view and others uses different notation, and
550 550 # compare_commits.mako renders links based on the target_repo.
551 551 # We need to swap that here to generate it properly on the html side
552 552 c.target_repo = c.source_repo
553 553
554 554 c.commit_statuses = ChangesetStatus.STATUSES
555 555
556 556 c.show_version_changes = not pr_closed
557 557 if c.show_version_changes:
558 558 cur_obj = pull_request_at_ver
559 559 prev_obj = prev_pull_request_at_ver
560 560
561 561 old_commit_ids = prev_obj.revisions
562 562 new_commit_ids = cur_obj.revisions
563 563 commit_changes = PullRequestModel()._calculate_commit_id_changes(
564 564 old_commit_ids, new_commit_ids)
565 565 c.commit_changes_summary = commit_changes
566 566
567 567 # calculate the diff for commits between versions
568 568 c.commit_changes = []
569 569 mark = lambda cs, fw: list(
570 570 h.itertools.izip_longest([], cs, fillvalue=fw))
571 571 for c_type, raw_id in mark(commit_changes.added, 'a') \
572 572 + mark(commit_changes.removed, 'r') \
573 573 + mark(commit_changes.common, 'c'):
574 574
575 575 if raw_id in commit_cache:
576 576 commit = commit_cache[raw_id]
577 577 else:
578 578 try:
579 579 commit = commits_source_repo.get_commit(raw_id)
580 580 except CommitDoesNotExistError:
581 581 # in case we fail extracting still use "dummy" commit
582 582 # for display in commit diff
583 583 commit = h.AttributeDict(
584 584 {'raw_id': raw_id,
585 585 'message': 'EMPTY or MISSING COMMIT'})
586 586 c.commit_changes.append([c_type, commit])
587 587
588 588 # current user review statuses for each version
589 589 c.review_versions = {}
590 590 if self._rhodecode_user.user_id in allowed_reviewers:
591 591 for co in general_comments:
592 592 if co.author.user_id == self._rhodecode_user.user_id:
593 593 # each comment has a status change
594 594 status = co.status_change
595 595 if status:
596 596 _ver_pr = status[0].comment.pull_request_version_id
597 597 c.review_versions[_ver_pr] = status[0]
598 598
599 599 return self._get_template_context(c)
600 600
601 601 def assure_not_empty_repo(self):
602 602 _ = self.request.translate
603 603
604 604 try:
605 605 self.db_repo.scm_instance().get_commit()
606 606 except EmptyRepositoryError:
607 607 h.flash(h.literal(_('There are no commits yet')),
608 608 category='warning')
609 609 raise HTTPFound(
610 610 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
611 611
612 612 @LoginRequired()
613 613 @NotAnonymous()
614 614 @HasRepoPermissionAnyDecorator(
615 615 'repository.read', 'repository.write', 'repository.admin')
616 616 @view_config(
617 617 route_name='pullrequest_new', request_method='GET',
618 618 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
619 619 def pull_request_new(self):
620 620 _ = self.request.translate
621 621 c = self.load_default_context()
622 622
623 623 self.assure_not_empty_repo()
624 624 source_repo = self.db_repo
625 625
626 626 commit_id = self.request.GET.get('commit')
627 627 branch_ref = self.request.GET.get('branch')
628 628 bookmark_ref = self.request.GET.get('bookmark')
629 629
630 630 try:
631 631 source_repo_data = PullRequestModel().generate_repo_data(
632 632 source_repo, commit_id=commit_id,
633 633 branch=branch_ref, bookmark=bookmark_ref, translator=self.request.translate)
634 634 except CommitDoesNotExistError as e:
635 635 log.exception(e)
636 636 h.flash(_('Commit does not exist'), 'error')
637 637 raise HTTPFound(
638 638 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
639 639
640 640 default_target_repo = source_repo
641 641
642 642 if source_repo.parent:
643 643 parent_vcs_obj = source_repo.parent.scm_instance()
644 644 if parent_vcs_obj and not parent_vcs_obj.is_empty():
645 645 # change default if we have a parent repo
646 646 default_target_repo = source_repo.parent
647 647
648 648 target_repo_data = PullRequestModel().generate_repo_data(
649 649 default_target_repo, translator=self.request.translate)
650 650
651 651 selected_source_ref = source_repo_data['refs']['selected_ref']
652 652
653 653 title_source_ref = selected_source_ref.split(':', 2)[1]
654 654 c.default_title = PullRequestModel().generate_pullrequest_title(
655 655 source=source_repo.repo_name,
656 656 source_ref=title_source_ref,
657 657 target=default_target_repo.repo_name
658 658 )
659 659
660 660 c.default_repo_data = {
661 661 'source_repo_name': source_repo.repo_name,
662 662 'source_refs_json': json.dumps(source_repo_data),
663 663 'target_repo_name': default_target_repo.repo_name,
664 664 'target_refs_json': json.dumps(target_repo_data),
665 665 }
666 666 c.default_source_ref = selected_source_ref
667 667
668 668 return self._get_template_context(c)
669 669
670 670 @LoginRequired()
671 671 @NotAnonymous()
672 672 @HasRepoPermissionAnyDecorator(
673 673 'repository.read', 'repository.write', 'repository.admin')
674 674 @view_config(
675 675 route_name='pullrequest_repo_refs', request_method='GET',
676 676 renderer='json_ext', xhr=True)
677 677 def pull_request_repo_refs(self):
678 678 target_repo_name = self.request.matchdict['target_repo_name']
679 679 repo = Repository.get_by_repo_name(target_repo_name)
680 680 if not repo:
681 681 raise HTTPNotFound()
682 682 return PullRequestModel().generate_repo_data(
683 683 repo, translator=self.request.translate)
684 684
685 685 @LoginRequired()
686 686 @NotAnonymous()
687 687 @HasRepoPermissionAnyDecorator(
688 688 'repository.read', 'repository.write', 'repository.admin')
689 689 @view_config(
690 690 route_name='pullrequest_repo_destinations', request_method='GET',
691 691 renderer='json_ext', xhr=True)
692 692 def pull_request_repo_destinations(self):
693 693 _ = self.request.translate
694 694 filter_query = self.request.GET.get('query')
695 695
696 696 query = Repository.query() \
697 697 .order_by(func.length(Repository.repo_name)) \
698 698 .filter(
699 699 or_(Repository.repo_name == self.db_repo.repo_name,
700 700 Repository.fork_id == self.db_repo.repo_id))
701 701
702 702 if filter_query:
703 703 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
704 704 query = query.filter(
705 705 Repository.repo_name.ilike(ilike_expression))
706 706
707 707 add_parent = False
708 708 if self.db_repo.parent:
709 709 if filter_query in self.db_repo.parent.repo_name:
710 710 parent_vcs_obj = self.db_repo.parent.scm_instance()
711 711 if parent_vcs_obj and not parent_vcs_obj.is_empty():
712 712 add_parent = True
713 713
714 714 limit = 20 - 1 if add_parent else 20
715 715 all_repos = query.limit(limit).all()
716 716 if add_parent:
717 717 all_repos += [self.db_repo.parent]
718 718
719 719 repos = []
720 720 for obj in ScmModel().get_repos(all_repos):
721 721 repos.append({
722 722 'id': obj['name'],
723 723 'text': obj['name'],
724 724 'type': 'repo',
725 725 'obj': obj['dbrepo']
726 726 })
727 727
728 728 data = {
729 729 'more': False,
730 730 'results': [{
731 731 'text': _('Repositories'),
732 732 'children': repos
733 733 }] if repos else []
734 734 }
735 735 return data
736 736
737 737 @LoginRequired()
738 738 @NotAnonymous()
739 739 @HasRepoPermissionAnyDecorator(
740 740 'repository.read', 'repository.write', 'repository.admin')
741 741 @CSRFRequired()
742 742 @view_config(
743 743 route_name='pullrequest_create', request_method='POST',
744 744 renderer=None)
745 745 def pull_request_create(self):
746 746 _ = self.request.translate
747 747 self.assure_not_empty_repo()
748 748
749 749 controls = peppercorn.parse(self.request.POST.items())
750 750
751 751 try:
752 752 _form = PullRequestForm(self.db_repo.repo_id)().to_python(controls)
753 753 except formencode.Invalid as errors:
754 754 if errors.error_dict.get('revisions'):
755 755 msg = 'Revisions: %s' % errors.error_dict['revisions']
756 756 elif errors.error_dict.get('pullrequest_title'):
757 757 msg = _('Pull request requires a title with min. 3 chars')
758 758 else:
759 759 msg = _('Error creating pull request: {}').format(errors)
760 760 log.exception(msg)
761 761 h.flash(msg, 'error')
762 762
763 763 # would rather just go back to form ...
764 764 raise HTTPFound(
765 765 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
766 766
767 767 source_repo = _form['source_repo']
768 768 source_ref = _form['source_ref']
769 769 target_repo = _form['target_repo']
770 770 target_ref = _form['target_ref']
771 771 commit_ids = _form['revisions'][::-1]
772 772
773 773 # find the ancestor for this pr
774 774 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
775 775 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
776 776
777 777 # re-check permissions again here
778 778 # source_repo we must have read permissions
779 779
780 780 source_perm = HasRepoPermissionAny(
781 781 'repository.read',
782 782 'repository.write', 'repository.admin')(source_db_repo.repo_name)
783 783 if not source_perm:
784 784 msg = _('Not Enough permissions to source repo `{}`.'.format(
785 785 source_db_repo.repo_name))
786 786 h.flash(msg, category='error')
787 787 # copy the args back to redirect
788 788 org_query = self.request.GET.mixed()
789 789 raise HTTPFound(
790 790 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
791 791 _query=org_query))
792 792
793 # target repo we must have write permissions, and also later on
793 # target repo we must have read permissions, and also later on
794 794 # we want to check branch permissions here
795 795 target_perm = HasRepoPermissionAny(
796 'repository.read',
796 797 'repository.write', 'repository.admin')(target_db_repo.repo_name)
797 798 if not target_perm:
798 799 msg = _('Not Enough permissions to target repo `{}`.'.format(
799 800 target_db_repo.repo_name))
800 801 h.flash(msg, category='error')
801 802 # copy the args back to redirect
802 803 org_query = self.request.GET.mixed()
803 804 raise HTTPFound(
804 805 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
805 806 _query=org_query))
806 807
807 808 source_scm = source_db_repo.scm_instance()
808 809 target_scm = target_db_repo.scm_instance()
809 810
810 811 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
811 812 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
812 813
813 814 ancestor = source_scm.get_common_ancestor(
814 815 source_commit.raw_id, target_commit.raw_id, target_scm)
815 816
816 817 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
817 818 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
818 819
819 820 pullrequest_title = _form['pullrequest_title']
820 821 title_source_ref = source_ref.split(':', 2)[1]
821 822 if not pullrequest_title:
822 823 pullrequest_title = PullRequestModel().generate_pullrequest_title(
823 824 source=source_repo,
824 825 source_ref=title_source_ref,
825 826 target=target_repo
826 827 )
827 828
828 829 description = _form['pullrequest_desc']
829 830
830 831 get_default_reviewers_data, validate_default_reviewers = \
831 832 PullRequestModel().get_reviewer_functions()
832 833
833 834 # recalculate reviewers logic, to make sure we can validate this
834 835 reviewer_rules = get_default_reviewers_data(
835 836 self._rhodecode_db_user, source_db_repo,
836 837 source_commit, target_db_repo, target_commit)
837 838
838 839 given_reviewers = _form['review_members']
839 840 reviewers = validate_default_reviewers(given_reviewers, reviewer_rules)
840 841
841 842 try:
842 843 pull_request = PullRequestModel().create(
843 844 self._rhodecode_user.user_id, source_repo, source_ref,
844 845 target_repo, target_ref, commit_ids, reviewers,
845 846 pullrequest_title, description, reviewer_rules
846 847 )
847 848 Session().commit()
848 849
849 850 h.flash(_('Successfully opened new pull request'),
850 851 category='success')
851 852 except Exception:
852 853 msg = _('Error occurred during creation of this pull request.')
853 854 log.exception(msg)
854 855 h.flash(msg, category='error')
855 856
856 857 # copy the args back to redirect
857 858 org_query = self.request.GET.mixed()
858 859 raise HTTPFound(
859 860 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
860 861 _query=org_query))
861 862
862 863 raise HTTPFound(
863 864 h.route_path('pullrequest_show', repo_name=target_repo,
864 865 pull_request_id=pull_request.pull_request_id))
865 866
866 867 @LoginRequired()
867 868 @NotAnonymous()
868 869 @HasRepoPermissionAnyDecorator(
869 870 'repository.read', 'repository.write', 'repository.admin')
870 871 @CSRFRequired()
871 872 @view_config(
872 873 route_name='pullrequest_update', request_method='POST',
873 874 renderer='json_ext')
874 875 def pull_request_update(self):
875 876 pull_request = PullRequest.get_or_404(
876 877 self.request.matchdict['pull_request_id'])
877 878
878 879 # only owner or admin can update it
879 880 allowed_to_update = PullRequestModel().check_user_update(
880 881 pull_request, self._rhodecode_user)
881 882 if allowed_to_update:
882 883 controls = peppercorn.parse(self.request.POST.items())
883 884
884 885 if 'review_members' in controls:
885 886 self._update_reviewers(
886 887 pull_request, controls['review_members'],
887 888 pull_request.reviewer_data)
888 889 elif str2bool(self.request.POST.get('update_commits', 'false')):
889 890 self._update_commits(pull_request)
890 891 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
891 892 self._edit_pull_request(pull_request)
892 893 else:
893 894 raise HTTPBadRequest()
894 895 return True
895 896 raise HTTPForbidden()
896 897
897 898 def _edit_pull_request(self, pull_request):
898 899 _ = self.request.translate
899 900 try:
900 901 PullRequestModel().edit(
901 902 pull_request, self.request.POST.get('title'),
902 903 self.request.POST.get('description'), self._rhodecode_user)
903 904 except ValueError:
904 905 msg = _(u'Cannot update closed pull requests.')
905 906 h.flash(msg, category='error')
906 907 return
907 908 else:
908 909 Session().commit()
909 910
910 911 msg = _(u'Pull request title & description updated.')
911 912 h.flash(msg, category='success')
912 913 return
913 914
914 915 def _update_commits(self, pull_request):
915 916 _ = self.request.translate
916 917 resp = PullRequestModel().update_commits(pull_request)
917 918
918 919 if resp.executed:
919 920
920 921 if resp.target_changed and resp.source_changed:
921 922 changed = 'target and source repositories'
922 923 elif resp.target_changed and not resp.source_changed:
923 924 changed = 'target repository'
924 925 elif not resp.target_changed and resp.source_changed:
925 926 changed = 'source repository'
926 927 else:
927 928 changed = 'nothing'
928 929
929 930 msg = _(
930 931 u'Pull request updated to "{source_commit_id}" with '
931 932 u'{count_added} added, {count_removed} removed commits. '
932 933 u'Source of changes: {change_source}')
933 934 msg = msg.format(
934 935 source_commit_id=pull_request.source_ref_parts.commit_id,
935 936 count_added=len(resp.changes.added),
936 937 count_removed=len(resp.changes.removed),
937 938 change_source=changed)
938 939 h.flash(msg, category='success')
939 940
940 941 channel = '/repo${}$/pr/{}'.format(
941 942 pull_request.target_repo.repo_name,
942 943 pull_request.pull_request_id)
943 944 message = msg + (
944 945 ' - <a onclick="window.location.reload()">'
945 946 '<strong>{}</strong></a>'.format(_('Reload page')))
946 947 channelstream.post_message(
947 948 channel, message, self._rhodecode_user.username,
948 949 registry=self.request.registry)
949 950 else:
950 951 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
951 952 warning_reasons = [
952 953 UpdateFailureReason.NO_CHANGE,
953 954 UpdateFailureReason.WRONG_REF_TYPE,
954 955 ]
955 956 category = 'warning' if resp.reason in warning_reasons else 'error'
956 957 h.flash(msg, category=category)
957 958
958 959 @LoginRequired()
959 960 @NotAnonymous()
960 961 @HasRepoPermissionAnyDecorator(
961 962 'repository.read', 'repository.write', 'repository.admin')
962 963 @CSRFRequired()
963 964 @view_config(
964 965 route_name='pullrequest_merge', request_method='POST',
965 966 renderer='json_ext')
966 967 def pull_request_merge(self):
967 968 """
968 969 Merge will perform a server-side merge of the specified
969 970 pull request, if the pull request is approved and mergeable.
970 971 After successful merging, the pull request is automatically
971 972 closed, with a relevant comment.
972 973 """
973 974 pull_request = PullRequest.get_or_404(
974 975 self.request.matchdict['pull_request_id'])
975 976
976 977 check = MergeCheck.validate(pull_request, self._rhodecode_db_user,
977 978 translator=self.request.translate)
978 979 merge_possible = not check.failed
979 980
980 981 for err_type, error_msg in check.errors:
981 982 h.flash(error_msg, category=err_type)
982 983
983 984 if merge_possible:
984 985 log.debug("Pre-conditions checked, trying to merge.")
985 986 extras = vcs_operation_context(
986 987 self.request.environ, repo_name=pull_request.target_repo.repo_name,
987 988 username=self._rhodecode_db_user.username, action='push',
988 989 scm=pull_request.target_repo.repo_type)
989 990 self._merge_pull_request(
990 991 pull_request, self._rhodecode_db_user, extras)
991 992 else:
992 993 log.debug("Pre-conditions failed, NOT merging.")
993 994
994 995 raise HTTPFound(
995 996 h.route_path('pullrequest_show',
996 997 repo_name=pull_request.target_repo.repo_name,
997 998 pull_request_id=pull_request.pull_request_id))
998 999
999 1000 def _merge_pull_request(self, pull_request, user, extras):
1000 1001 _ = self.request.translate
1001 1002 merge_resp = PullRequestModel().merge(pull_request, user, extras=extras)
1002 1003
1003 1004 if merge_resp.executed:
1004 1005 log.debug("The merge was successful, closing the pull request.")
1005 1006 PullRequestModel().close_pull_request(
1006 1007 pull_request.pull_request_id, user)
1007 1008 Session().commit()
1008 1009 msg = _('Pull request was successfully merged and closed.')
1009 1010 h.flash(msg, category='success')
1010 1011 else:
1011 1012 log.debug(
1012 1013 "The merge was not successful. Merge response: %s",
1013 1014 merge_resp)
1014 1015 msg = PullRequestModel().merge_status_message(
1015 1016 merge_resp.failure_reason)
1016 1017 h.flash(msg, category='error')
1017 1018
1018 1019 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1019 1020 _ = self.request.translate
1020 1021 get_default_reviewers_data, validate_default_reviewers = \
1021 1022 PullRequestModel().get_reviewer_functions()
1022 1023
1023 1024 try:
1024 1025 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1025 1026 except ValueError as e:
1026 1027 log.error('Reviewers Validation: {}'.format(e))
1027 1028 h.flash(e, category='error')
1028 1029 return
1029 1030
1030 1031 PullRequestModel().update_reviewers(
1031 1032 pull_request, reviewers, self._rhodecode_user)
1032 1033 h.flash(_('Pull request reviewers updated.'), category='success')
1033 1034 Session().commit()
1034 1035
1035 1036 @LoginRequired()
1036 1037 @NotAnonymous()
1037 1038 @HasRepoPermissionAnyDecorator(
1038 1039 'repository.read', 'repository.write', 'repository.admin')
1039 1040 @CSRFRequired()
1040 1041 @view_config(
1041 1042 route_name='pullrequest_delete', request_method='POST',
1042 1043 renderer='json_ext')
1043 1044 def pull_request_delete(self):
1044 1045 _ = self.request.translate
1045 1046
1046 1047 pull_request = PullRequest.get_or_404(
1047 1048 self.request.matchdict['pull_request_id'])
1048 1049
1049 1050 pr_closed = pull_request.is_closed()
1050 1051 allowed_to_delete = PullRequestModel().check_user_delete(
1051 1052 pull_request, self._rhodecode_user) and not pr_closed
1052 1053
1053 1054 # only owner can delete it !
1054 1055 if allowed_to_delete:
1055 1056 PullRequestModel().delete(pull_request, self._rhodecode_user)
1056 1057 Session().commit()
1057 1058 h.flash(_('Successfully deleted pull request'),
1058 1059 category='success')
1059 1060 raise HTTPFound(h.route_path('pullrequest_show_all',
1060 1061 repo_name=self.db_repo_name))
1061 1062
1062 1063 log.warning('user %s tried to delete pull request without access',
1063 1064 self._rhodecode_user)
1064 1065 raise HTTPNotFound()
1065 1066
1066 1067 @LoginRequired()
1067 1068 @NotAnonymous()
1068 1069 @HasRepoPermissionAnyDecorator(
1069 1070 'repository.read', 'repository.write', 'repository.admin')
1070 1071 @CSRFRequired()
1071 1072 @view_config(
1072 1073 route_name='pullrequest_comment_create', request_method='POST',
1073 1074 renderer='json_ext')
1074 1075 def pull_request_comment_create(self):
1075 1076 _ = self.request.translate
1076 1077
1077 1078 pull_request = PullRequest.get_or_404(
1078 1079 self.request.matchdict['pull_request_id'])
1079 1080 pull_request_id = pull_request.pull_request_id
1080 1081
1081 1082 if pull_request.is_closed():
1082 1083 log.debug('comment: forbidden because pull request is closed')
1083 1084 raise HTTPForbidden()
1084 1085
1085 1086 allowed_to_comment = PullRequestModel().check_user_comment(
1086 1087 pull_request, self._rhodecode_user)
1087 1088 if not allowed_to_comment:
1088 1089 log.debug(
1089 1090 'comment: forbidden because pull request is from forbidden repo')
1090 1091 raise HTTPForbidden()
1091 1092
1092 1093 c = self.load_default_context()
1093 1094
1094 1095 status = self.request.POST.get('changeset_status', None)
1095 1096 text = self.request.POST.get('text')
1096 1097 comment_type = self.request.POST.get('comment_type')
1097 1098 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1098 1099 close_pull_request = self.request.POST.get('close_pull_request')
1099 1100
1100 1101 # the logic here should work like following, if we submit close
1101 1102 # pr comment, use `close_pull_request_with_comment` function
1102 1103 # else handle regular comment logic
1103 1104
1104 1105 if close_pull_request:
1105 1106 # only owner or admin or person with write permissions
1106 1107 allowed_to_close = PullRequestModel().check_user_update(
1107 1108 pull_request, self._rhodecode_user)
1108 1109 if not allowed_to_close:
1109 1110 log.debug('comment: forbidden because not allowed to close '
1110 1111 'pull request %s', pull_request_id)
1111 1112 raise HTTPForbidden()
1112 1113 comment, status = PullRequestModel().close_pull_request_with_comment(
1113 1114 pull_request, self._rhodecode_user, self.db_repo, message=text)
1114 1115 Session().flush()
1115 1116 events.trigger(
1116 1117 events.PullRequestCommentEvent(pull_request, comment))
1117 1118
1118 1119 else:
1119 1120 # regular comment case, could be inline, or one with status.
1120 1121 # for that one we check also permissions
1121 1122
1122 1123 allowed_to_change_status = PullRequestModel().check_user_change_status(
1123 1124 pull_request, self._rhodecode_user)
1124 1125
1125 1126 if status and allowed_to_change_status:
1126 1127 message = (_('Status change %(transition_icon)s %(status)s')
1127 1128 % {'transition_icon': '>',
1128 1129 'status': ChangesetStatus.get_status_lbl(status)})
1129 1130 text = text or message
1130 1131
1131 1132 comment = CommentsModel().create(
1132 1133 text=text,
1133 1134 repo=self.db_repo.repo_id,
1134 1135 user=self._rhodecode_user.user_id,
1135 1136 pull_request=pull_request,
1136 1137 f_path=self.request.POST.get('f_path'),
1137 1138 line_no=self.request.POST.get('line'),
1138 1139 status_change=(ChangesetStatus.get_status_lbl(status)
1139 1140 if status and allowed_to_change_status else None),
1140 1141 status_change_type=(status
1141 1142 if status and allowed_to_change_status else None),
1142 1143 comment_type=comment_type,
1143 1144 resolves_comment_id=resolves_comment_id
1144 1145 )
1145 1146
1146 1147 if allowed_to_change_status:
1147 1148 # calculate old status before we change it
1148 1149 old_calculated_status = pull_request.calculated_review_status()
1149 1150
1150 1151 # get status if set !
1151 1152 if status:
1152 1153 ChangesetStatusModel().set_status(
1153 1154 self.db_repo.repo_id,
1154 1155 status,
1155 1156 self._rhodecode_user.user_id,
1156 1157 comment,
1157 1158 pull_request=pull_request
1158 1159 )
1159 1160
1160 1161 Session().flush()
1161 1162 events.trigger(
1162 1163 events.PullRequestCommentEvent(pull_request, comment))
1163 1164
1164 1165 # we now calculate the status of pull request, and based on that
1165 1166 # calculation we set the commits status
1166 1167 calculated_status = pull_request.calculated_review_status()
1167 1168 if old_calculated_status != calculated_status:
1168 1169 PullRequestModel()._trigger_pull_request_hook(
1169 1170 pull_request, self._rhodecode_user, 'review_status_change')
1170 1171
1171 1172 Session().commit()
1172 1173
1173 1174 data = {
1174 1175 'target_id': h.safeid(h.safe_unicode(
1175 1176 self.request.POST.get('f_path'))),
1176 1177 }
1177 1178 if comment:
1178 1179 c.co = comment
1179 1180 rendered_comment = render(
1180 1181 'rhodecode:templates/changeset/changeset_comment_block.mako',
1181 1182 self._get_template_context(c), self.request)
1182 1183
1183 1184 data.update(comment.get_dict())
1184 1185 data.update({'rendered_text': rendered_comment})
1185 1186
1186 1187 return data
1187 1188
1188 1189 @LoginRequired()
1189 1190 @NotAnonymous()
1190 1191 @HasRepoPermissionAnyDecorator(
1191 1192 'repository.read', 'repository.write', 'repository.admin')
1192 1193 @CSRFRequired()
1193 1194 @view_config(
1194 1195 route_name='pullrequest_comment_delete', request_method='POST',
1195 1196 renderer='json_ext')
1196 1197 def pull_request_comment_delete(self):
1197 1198 pull_request = PullRequest.get_or_404(
1198 1199 self.request.matchdict['pull_request_id'])
1199 1200
1200 1201 comment = ChangesetComment.get_or_404(
1201 1202 self.request.matchdict['comment_id'])
1202 1203 comment_id = comment.comment_id
1203 1204
1204 1205 if pull_request.is_closed():
1205 1206 log.debug('comment: forbidden because pull request is closed')
1206 1207 raise HTTPForbidden()
1207 1208
1208 1209 if not comment:
1209 1210 log.debug('Comment with id:%s not found, skipping', comment_id)
1210 1211 # comment already deleted in another call probably
1211 1212 return True
1212 1213
1213 1214 if comment.pull_request.is_closed():
1214 1215 # don't allow deleting comments on closed pull request
1215 1216 raise HTTPForbidden()
1216 1217
1217 1218 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1218 1219 super_admin = h.HasPermissionAny('hg.admin')()
1219 1220 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1220 1221 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1221 1222 comment_repo_admin = is_repo_admin and is_repo_comment
1222 1223
1223 1224 if super_admin or comment_owner or comment_repo_admin:
1224 1225 old_calculated_status = comment.pull_request.calculated_review_status()
1225 1226 CommentsModel().delete(comment=comment, user=self._rhodecode_user)
1226 1227 Session().commit()
1227 1228 calculated_status = comment.pull_request.calculated_review_status()
1228 1229 if old_calculated_status != calculated_status:
1229 1230 PullRequestModel()._trigger_pull_request_hook(
1230 1231 comment.pull_request, self._rhodecode_user, 'review_status_change')
1231 1232 return True
1232 1233 else:
1233 1234 log.warning('No permissions for user %s to delete comment_id: %s',
1234 1235 self._rhodecode_db_user, comment_id)
1235 1236 raise HTTPNotFound()
@@ -1,733 +1,736 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Authentication modules
23 23 """
24 24
25 25 import colander
26 26 import copy
27 27 import logging
28 28 import time
29 29 import traceback
30 30 import warnings
31 31 import functools
32 32
33 33 from pyramid.threadlocal import get_current_registry
34 34 from zope.cachedescriptors.property import Lazy as LazyProperty
35 35
36 36 from rhodecode.authentication.interface import IAuthnPluginRegistry
37 37 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
38 38 from rhodecode.lib import caches
39 39 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
40 40 from rhodecode.lib.utils2 import safe_int
41 41 from rhodecode.lib.utils2 import safe_str
42 42 from rhodecode.model.db import User
43 43 from rhodecode.model.meta import Session
44 44 from rhodecode.model.settings import SettingsModel
45 45 from rhodecode.model.user import UserModel
46 46 from rhodecode.model.user_group import UserGroupModel
47 47
48 48
49 49 log = logging.getLogger(__name__)
50 50
51 51 # auth types that authenticate() function can receive
52 52 VCS_TYPE = 'vcs'
53 53 HTTP_TYPE = 'http'
54 54
55 55
56 56 class hybrid_property(object):
57 57 """
58 58 a property decorator that works both for instance and class
59 59 """
60 60 def __init__(self, fget, fset=None, fdel=None, expr=None):
61 61 self.fget = fget
62 62 self.fset = fset
63 63 self.fdel = fdel
64 64 self.expr = expr or fget
65 65 functools.update_wrapper(self, fget)
66 66
67 67 def __get__(self, instance, owner):
68 68 if instance is None:
69 69 return self.expr(owner)
70 70 else:
71 71 return self.fget(instance)
72 72
73 73 def __set__(self, instance, value):
74 74 self.fset(instance, value)
75 75
76 76 def __delete__(self, instance):
77 77 self.fdel(instance)
78 78
79 79
80 80
81 81 class LazyFormencode(object):
82 82 def __init__(self, formencode_obj, *args, **kwargs):
83 83 self.formencode_obj = formencode_obj
84 84 self.args = args
85 85 self.kwargs = kwargs
86 86
87 87 def __call__(self, *args, **kwargs):
88 88 from inspect import isfunction
89 89 formencode_obj = self.formencode_obj
90 90 if isfunction(formencode_obj):
91 91 # case we wrap validators into functions
92 92 formencode_obj = self.formencode_obj(*args, **kwargs)
93 93 return formencode_obj(*self.args, **self.kwargs)
94 94
95 95
96 96 class RhodeCodeAuthPluginBase(object):
97 97 # cache the authentication request for N amount of seconds. Some kind
98 98 # of authentication methods are very heavy and it's very efficient to cache
99 99 # the result of a call. If it's set to None (default) cache is off
100 100 AUTH_CACHE_TTL = None
101 101 AUTH_CACHE = {}
102 102
103 103 auth_func_attrs = {
104 104 "username": "unique username",
105 105 "firstname": "first name",
106 106 "lastname": "last name",
107 107 "email": "email address",
108 108 "groups": '["list", "of", "groups"]',
109 109 "extern_name": "name in external source of record",
110 110 "extern_type": "type of external source of record",
111 111 "admin": 'True|False defines if user should be RhodeCode super admin',
112 112 "active":
113 113 'True|False defines active state of user internally for RhodeCode',
114 114 "active_from_extern":
115 115 "True|False\None, active state from the external auth, "
116 116 "None means use definition from RhodeCode extern_type active value"
117 117 }
118 118 # set on authenticate() method and via set_auth_type func.
119 119 auth_type = None
120 120
121 121 # set on authenticate() method and via set_calling_scope_repo, this is a
122 122 # calling scope repository when doing authentication most likely on VCS
123 123 # operations
124 124 acl_repo_name = None
125 125
126 126 # List of setting names to store encrypted. Plugins may override this list
127 127 # to store settings encrypted.
128 128 _settings_encrypted = []
129 129
130 130 # Mapping of python to DB settings model types. Plugins may override or
131 131 # extend this mapping.
132 132 _settings_type_map = {
133 133 colander.String: 'unicode',
134 134 colander.Integer: 'int',
135 135 colander.Boolean: 'bool',
136 136 colander.List: 'list',
137 137 }
138 138
139 139 # list of keys in settings that are unsafe to be logged, should be passwords
140 140 # or other crucial credentials
141 141 _settings_unsafe_keys = []
142 142
143 143 def __init__(self, plugin_id):
144 144 self._plugin_id = plugin_id
145 145
146 146 def __str__(self):
147 147 return self.get_id()
148 148
149 149 def _get_setting_full_name(self, name):
150 150 """
151 151 Return the full setting name used for storing values in the database.
152 152 """
153 153 # TODO: johbo: Using the name here is problematic. It would be good to
154 154 # introduce either new models in the database to hold Plugin and
155 155 # PluginSetting or to use the plugin id here.
156 156 return 'auth_{}_{}'.format(self.name, name)
157 157
158 158 def _get_setting_type(self, name):
159 159 """
160 160 Return the type of a setting. This type is defined by the SettingsModel
161 161 and determines how the setting is stored in DB. Optionally the suffix
162 162 `.encrypted` is appended to instruct SettingsModel to store it
163 163 encrypted.
164 164 """
165 165 schema_node = self.get_settings_schema().get(name)
166 166 db_type = self._settings_type_map.get(
167 167 type(schema_node.typ), 'unicode')
168 168 if name in self._settings_encrypted:
169 169 db_type = '{}.encrypted'.format(db_type)
170 170 return db_type
171 171
172 172 @LazyProperty
173 173 def plugin_settings(self):
174 174 settings = SettingsModel().get_all_settings()
175 175 return settings
176 176
177 177 def is_enabled(self):
178 178 """
179 179 Returns true if this plugin is enabled. An enabled plugin can be
180 180 configured in the admin interface but it is not consulted during
181 181 authentication.
182 182 """
183 183 auth_plugins = SettingsModel().get_auth_plugins()
184 184 return self.get_id() in auth_plugins
185 185
186 186 def is_active(self):
187 187 """
188 188 Returns true if the plugin is activated. An activated plugin is
189 189 consulted during authentication, assumed it is also enabled.
190 190 """
191 191 return self.get_setting_by_name('enabled')
192 192
193 193 def get_id(self):
194 194 """
195 195 Returns the plugin id.
196 196 """
197 197 return self._plugin_id
198 198
199 199 def get_display_name(self):
200 200 """
201 201 Returns a translation string for displaying purposes.
202 202 """
203 203 raise NotImplementedError('Not implemented in base class')
204 204
205 205 def get_settings_schema(self):
206 206 """
207 207 Returns a colander schema, representing the plugin settings.
208 208 """
209 209 return AuthnPluginSettingsSchemaBase()
210 210
211 211 def get_setting_by_name(self, name, default=None, cache=True):
212 212 """
213 213 Returns a plugin setting by name.
214 214 """
215 215 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
216 216 if cache:
217 217 plugin_settings = self.plugin_settings
218 218 else:
219 219 plugin_settings = SettingsModel().get_all_settings()
220 220
221 return plugin_settings.get(full_name) or default
221 if full_name in plugin_settings:
222 return plugin_settings[full_name]
223 else:
224 return default
222 225
223 226 def create_or_update_setting(self, name, value):
224 227 """
225 228 Create or update a setting for this plugin in the persistent storage.
226 229 """
227 230 full_name = self._get_setting_full_name(name)
228 231 type_ = self._get_setting_type(name)
229 232 db_setting = SettingsModel().create_or_update_setting(
230 233 full_name, value, type_)
231 234 return db_setting.app_settings_value
232 235
233 236 def get_settings(self):
234 237 """
235 238 Returns the plugin settings as dictionary.
236 239 """
237 240 settings = {}
238 241 for node in self.get_settings_schema():
239 242 settings[node.name] = self.get_setting_by_name(node.name)
240 243 return settings
241 244
242 245 def log_safe_settings(self, settings):
243 246 """
244 247 returns a log safe representation of settings, without any secrets
245 248 """
246 249 settings_copy = copy.deepcopy(settings)
247 250 for k in self._settings_unsafe_keys:
248 251 if k in settings_copy:
249 252 del settings_copy[k]
250 253 return settings_copy
251 254
252 255 @property
253 256 def validators(self):
254 257 """
255 258 Exposes RhodeCode validators modules
256 259 """
257 260 # this is a hack to overcome issues with pylons threadlocals and
258 261 # translator object _() not being registered properly.
259 262 class LazyCaller(object):
260 263 def __init__(self, name):
261 264 self.validator_name = name
262 265
263 266 def __call__(self, *args, **kwargs):
264 267 from rhodecode.model import validators as v
265 268 obj = getattr(v, self.validator_name)
266 269 # log.debug('Initializing lazy formencode object: %s', obj)
267 270 return LazyFormencode(obj, *args, **kwargs)
268 271
269 272 class ProxyGet(object):
270 273 def __getattribute__(self, name):
271 274 return LazyCaller(name)
272 275
273 276 return ProxyGet()
274 277
275 278 @hybrid_property
276 279 def name(self):
277 280 """
278 281 Returns the name of this authentication plugin.
279 282
280 283 :returns: string
281 284 """
282 285 raise NotImplementedError("Not implemented in base class")
283 286
284 287 def get_url_slug(self):
285 288 """
286 289 Returns a slug which should be used when constructing URLs which refer
287 290 to this plugin. By default it returns the plugin name. If the name is
288 291 not suitable for using it in an URL the plugin should override this
289 292 method.
290 293 """
291 294 return self.name
292 295
293 296 @property
294 297 def is_headers_auth(self):
295 298 """
296 299 Returns True if this authentication plugin uses HTTP headers as
297 300 authentication method.
298 301 """
299 302 return False
300 303
301 304 @hybrid_property
302 305 def is_container_auth(self):
303 306 """
304 307 Deprecated method that indicates if this authentication plugin uses
305 308 HTTP headers as authentication method.
306 309 """
307 310 warnings.warn(
308 311 'Use is_headers_auth instead.', category=DeprecationWarning)
309 312 return self.is_headers_auth
310 313
311 314 @hybrid_property
312 315 def allows_creating_users(self):
313 316 """
314 317 Defines if Plugin allows users to be created on-the-fly when
315 318 authentication is called. Controls how external plugins should behave
316 319 in terms if they are allowed to create new users, or not. Base plugins
317 320 should not be allowed to, but External ones should be !
318 321
319 322 :return: bool
320 323 """
321 324 return False
322 325
323 326 def set_auth_type(self, auth_type):
324 327 self.auth_type = auth_type
325 328
326 329 def set_calling_scope_repo(self, acl_repo_name):
327 330 self.acl_repo_name = acl_repo_name
328 331
329 332 def allows_authentication_from(
330 333 self, user, allows_non_existing_user=True,
331 334 allowed_auth_plugins=None, allowed_auth_sources=None):
332 335 """
333 336 Checks if this authentication module should accept a request for
334 337 the current user.
335 338
336 339 :param user: user object fetched using plugin's get_user() method.
337 340 :param allows_non_existing_user: if True, don't allow the
338 341 user to be empty, meaning not existing in our database
339 342 :param allowed_auth_plugins: if provided, users extern_type will be
340 343 checked against a list of provided extern types, which are plugin
341 344 auth_names in the end
342 345 :param allowed_auth_sources: authentication type allowed,
343 346 `http` or `vcs` default is both.
344 347 defines if plugin will accept only http authentication vcs
345 348 authentication(git/hg) or both
346 349 :returns: boolean
347 350 """
348 351 if not user and not allows_non_existing_user:
349 352 log.debug('User is empty but plugin does not allow empty users,'
350 353 'not allowed to authenticate')
351 354 return False
352 355
353 356 expected_auth_plugins = allowed_auth_plugins or [self.name]
354 357 if user and (user.extern_type and
355 358 user.extern_type not in expected_auth_plugins):
356 359 log.debug(
357 360 'User `%s` is bound to `%s` auth type. Plugin allows only '
358 361 '%s, skipping', user, user.extern_type, expected_auth_plugins)
359 362
360 363 return False
361 364
362 365 # by default accept both
363 366 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
364 367 if self.auth_type not in expected_auth_from:
365 368 log.debug('Current auth source is %s but plugin only allows %s',
366 369 self.auth_type, expected_auth_from)
367 370 return False
368 371
369 372 return True
370 373
371 374 def get_user(self, username=None, **kwargs):
372 375 """
373 376 Helper method for user fetching in plugins, by default it's using
374 377 simple fetch by username, but this method can be custimized in plugins
375 378 eg. headers auth plugin to fetch user by environ params
376 379
377 380 :param username: username if given to fetch from database
378 381 :param kwargs: extra arguments needed for user fetching.
379 382 """
380 383 user = None
381 384 log.debug(
382 385 'Trying to fetch user `%s` from RhodeCode database', username)
383 386 if username:
384 387 user = User.get_by_username(username)
385 388 if not user:
386 389 log.debug('User not found, fallback to fetch user in '
387 390 'case insensitive mode')
388 391 user = User.get_by_username(username, case_insensitive=True)
389 392 else:
390 393 log.debug('provided username:`%s` is empty skipping...', username)
391 394 if not user:
392 395 log.debug('User `%s` not found in database', username)
393 396 else:
394 397 log.debug('Got DB user:%s', user)
395 398 return user
396 399
397 400 def user_activation_state(self):
398 401 """
399 402 Defines user activation state when creating new users
400 403
401 404 :returns: boolean
402 405 """
403 406 raise NotImplementedError("Not implemented in base class")
404 407
405 408 def auth(self, userobj, username, passwd, settings, **kwargs):
406 409 """
407 410 Given a user object (which may be null), username, a plaintext
408 411 password, and a settings object (containing all the keys needed as
409 412 listed in settings()), authenticate this user's login attempt.
410 413
411 414 Return None on failure. On success, return a dictionary of the form:
412 415
413 416 see: RhodeCodeAuthPluginBase.auth_func_attrs
414 417 This is later validated for correctness
415 418 """
416 419 raise NotImplementedError("not implemented in base class")
417 420
418 421 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
419 422 """
420 423 Wrapper to call self.auth() that validates call on it
421 424
422 425 :param userobj: userobj
423 426 :param username: username
424 427 :param passwd: plaintext password
425 428 :param settings: plugin settings
426 429 """
427 430 auth = self.auth(userobj, username, passwd, settings, **kwargs)
428 431 if auth:
429 432 auth['_plugin'] = self.name
430 433 auth['_ttl_cache'] = self.get_ttl_cache(settings)
431 434 # check if hash should be migrated ?
432 435 new_hash = auth.get('_hash_migrate')
433 436 if new_hash:
434 437 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
435 438 return self._validate_auth_return(auth)
436 439
437 440 return auth
438 441
439 442 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
440 443 new_hash_cypher = _RhodeCodeCryptoBCrypt()
441 444 # extra checks, so make sure new hash is correct.
442 445 password_encoded = safe_str(password)
443 446 if new_hash and new_hash_cypher.hash_check(
444 447 password_encoded, new_hash):
445 448 cur_user = User.get_by_username(username)
446 449 cur_user.password = new_hash
447 450 Session().add(cur_user)
448 451 Session().flush()
449 452 log.info('Migrated user %s hash to bcrypt', cur_user)
450 453
451 454 def _validate_auth_return(self, ret):
452 455 if not isinstance(ret, dict):
453 456 raise Exception('returned value from auth must be a dict')
454 457 for k in self.auth_func_attrs:
455 458 if k not in ret:
456 459 raise Exception('Missing %s attribute from returned data' % k)
457 460 return ret
458 461
459 462 def get_ttl_cache(self, settings=None):
460 463 plugin_settings = settings or self.get_settings()
461 464 cache_ttl = 0
462 465
463 466 if isinstance(self.AUTH_CACHE_TTL, (int, long)):
464 467 # plugin cache set inside is more important than the settings value
465 468 cache_ttl = self.AUTH_CACHE_TTL
466 469 elif plugin_settings.get('cache_ttl'):
467 470 cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
468 471
469 472 plugin_cache_active = bool(cache_ttl and cache_ttl > 0)
470 473 return plugin_cache_active, cache_ttl
471 474
472 475
473 476 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
474 477
475 478 @hybrid_property
476 479 def allows_creating_users(self):
477 480 return True
478 481
479 482 def use_fake_password(self):
480 483 """
481 484 Return a boolean that indicates whether or not we should set the user's
482 485 password to a random value when it is authenticated by this plugin.
483 486 If your plugin provides authentication, then you will generally
484 487 want this.
485 488
486 489 :returns: boolean
487 490 """
488 491 raise NotImplementedError("Not implemented in base class")
489 492
490 493 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
491 494 # at this point _authenticate calls plugin's `auth()` function
492 495 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
493 496 userobj, username, passwd, settings, **kwargs)
494 497
495 498 if auth:
496 499 # maybe plugin will clean the username ?
497 500 # we should use the return value
498 501 username = auth['username']
499 502
500 503 # if external source tells us that user is not active, we should
501 504 # skip rest of the process. This can prevent from creating users in
502 505 # RhodeCode when using external authentication, but if it's
503 506 # inactive user we shouldn't create that user anyway
504 507 if auth['active_from_extern'] is False:
505 508 log.warning(
506 509 "User %s authenticated against %s, but is inactive",
507 510 username, self.__module__)
508 511 return None
509 512
510 513 cur_user = User.get_by_username(username, case_insensitive=True)
511 514 is_user_existing = cur_user is not None
512 515
513 516 if is_user_existing:
514 517 log.debug('Syncing user `%s` from '
515 518 '`%s` plugin', username, self.name)
516 519 else:
517 520 log.debug('Creating non existing user `%s` from '
518 521 '`%s` plugin', username, self.name)
519 522
520 523 if self.allows_creating_users:
521 524 log.debug('Plugin `%s` allows to '
522 525 'create new users', self.name)
523 526 else:
524 527 log.debug('Plugin `%s` does not allow to '
525 528 'create new users', self.name)
526 529
527 530 user_parameters = {
528 531 'username': username,
529 532 'email': auth["email"],
530 533 'firstname': auth["firstname"],
531 534 'lastname': auth["lastname"],
532 535 'active': auth["active"],
533 536 'admin': auth["admin"],
534 537 'extern_name': auth["extern_name"],
535 538 'extern_type': self.name,
536 539 'plugin': self,
537 540 'allow_to_create_user': self.allows_creating_users,
538 541 }
539 542
540 543 if not is_user_existing:
541 544 if self.use_fake_password():
542 545 # Randomize the PW because we don't need it, but don't want
543 546 # them blank either
544 547 passwd = PasswordGenerator().gen_password(length=16)
545 548 user_parameters['password'] = passwd
546 549 else:
547 550 # Since the password is required by create_or_update method of
548 551 # UserModel, we need to set it explicitly.
549 552 # The create_or_update method is smart and recognises the
550 553 # password hashes as well.
551 554 user_parameters['password'] = cur_user.password
552 555
553 556 # we either create or update users, we also pass the flag
554 557 # that controls if this method can actually do that.
555 558 # raises NotAllowedToCreateUserError if it cannot, and we try to.
556 559 user = UserModel().create_or_update(**user_parameters)
557 560 Session().flush()
558 561 # enforce user is just in given groups, all of them has to be ones
559 562 # created from plugins. We store this info in _group_data JSON
560 563 # field
561 564 try:
562 565 groups = auth['groups'] or []
563 566 log.debug(
564 567 'Performing user_group sync based on set `%s` '
565 568 'returned by this plugin', groups)
566 569 UserGroupModel().enforce_groups(user, groups, self.name)
567 570 except Exception:
568 571 # for any reason group syncing fails, we should
569 572 # proceed with login
570 573 log.error(traceback.format_exc())
571 574 Session().commit()
572 575 return auth
573 576
574 577
575 578 def loadplugin(plugin_id):
576 579 """
577 580 Loads and returns an instantiated authentication plugin.
578 581 Returns the RhodeCodeAuthPluginBase subclass on success,
579 582 or None on failure.
580 583 """
581 584 # TODO: Disusing pyramids thread locals to retrieve the registry.
582 585 authn_registry = get_authn_registry()
583 586 plugin = authn_registry.get_plugin(plugin_id)
584 587 if plugin is None:
585 588 log.error('Authentication plugin not found: "%s"', plugin_id)
586 589 return plugin
587 590
588 591
589 592 def get_authn_registry(registry=None):
590 593 registry = registry or get_current_registry()
591 594 authn_registry = registry.getUtility(IAuthnPluginRegistry)
592 595 return authn_registry
593 596
594 597
595 598 def get_auth_cache_manager(custom_ttl=None):
596 599 return caches.get_cache_manager(
597 600 'auth_plugins', 'rhodecode.authentication', custom_ttl)
598 601
599 602
600 603 def get_perms_cache_manager(custom_ttl=None):
601 604 return caches.get_cache_manager(
602 605 'auth_plugins', 'rhodecode.permissions', custom_ttl)
603 606
604 607
605 608 def authenticate(username, password, environ=None, auth_type=None,
606 609 skip_missing=False, registry=None, acl_repo_name=None):
607 610 """
608 611 Authentication function used for access control,
609 612 It tries to authenticate based on enabled authentication modules.
610 613
611 614 :param username: username can be empty for headers auth
612 615 :param password: password can be empty for headers auth
613 616 :param environ: environ headers passed for headers auth
614 617 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
615 618 :param skip_missing: ignores plugins that are in db but not in environment
616 619 :returns: None if auth failed, plugin_user dict if auth is correct
617 620 """
618 621 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
619 622 raise ValueError('auth type must be on of http, vcs got "%s" instead'
620 623 % auth_type)
621 624 headers_only = environ and not (username and password)
622 625
623 626 authn_registry = get_authn_registry(registry)
624 627 plugins_to_check = authn_registry.get_plugins_for_authentication()
625 628 log.debug('Starting ordered authentication chain using %s plugins',
626 629 plugins_to_check)
627 630 for plugin in plugins_to_check:
628 631 plugin.set_auth_type(auth_type)
629 632 plugin.set_calling_scope_repo(acl_repo_name)
630 633
631 634 if headers_only and not plugin.is_headers_auth:
632 635 log.debug('Auth type is for headers only and plugin `%s` is not '
633 636 'headers plugin, skipping...', plugin.get_id())
634 637 continue
635 638
636 639 # load plugin settings from RhodeCode database
637 640 plugin_settings = plugin.get_settings()
638 641 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
639 642 log.debug('Plugin settings:%s', plugin_sanitized_settings)
640 643
641 644 log.debug('Trying authentication using ** %s **', plugin.get_id())
642 645 # use plugin's method of user extraction.
643 646 user = plugin.get_user(username, environ=environ,
644 647 settings=plugin_settings)
645 648 display_user = user.username if user else username
646 649 log.debug(
647 650 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
648 651
649 652 if not plugin.allows_authentication_from(user):
650 653 log.debug('Plugin %s does not accept user `%s` for authentication',
651 654 plugin.get_id(), display_user)
652 655 continue
653 656 else:
654 657 log.debug('Plugin %s accepted user `%s` for authentication',
655 658 plugin.get_id(), display_user)
656 659
657 660 log.info('Authenticating user `%s` using %s plugin',
658 661 display_user, plugin.get_id())
659 662
660 663 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)
661 664
662 665 # get instance of cache manager configured for a namespace
663 666 cache_manager = get_auth_cache_manager(custom_ttl=cache_ttl)
664 667
665 668 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
666 669 plugin.get_id(), plugin_cache_active, cache_ttl)
667 670
668 671 # for environ based password can be empty, but then the validation is
669 672 # on the server that fills in the env data needed for authentication
670 673
671 674 _password_hash = caches.compute_key_from_params(
672 675 plugin.name, username, (password or ''))
673 676
674 677 # _authenticate is a wrapper for .auth() method of plugin.
675 678 # it checks if .auth() sends proper data.
676 679 # For RhodeCodeExternalAuthPlugin it also maps users to
677 680 # Database and maps the attributes returned from .auth()
678 681 # to RhodeCode database. If this function returns data
679 682 # then auth is correct.
680 683 start = time.time()
681 684 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
682 685
683 686 def auth_func():
684 687 """
685 688 This function is used internally in Cache of Beaker to calculate
686 689 Results
687 690 """
688 691 log.debug('auth: calculating password access now...')
689 692 return plugin._authenticate(
690 693 user, username, password, plugin_settings,
691 694 environ=environ or {})
692 695
693 696 if plugin_cache_active:
694 697 log.debug('Trying to fetch cached auth by %s', _password_hash[:6])
695 698 plugin_user = cache_manager.get(
696 699 _password_hash, createfunc=auth_func)
697 700 else:
698 701 plugin_user = auth_func()
699 702
700 703 auth_time = time.time() - start
701 704 log.debug('Authentication for plugin `%s` completed in %.3fs, '
702 705 'expiration time of fetched cache %.1fs.',
703 706 plugin.get_id(), auth_time, cache_ttl)
704 707
705 708 log.debug('PLUGIN USER DATA: %s', plugin_user)
706 709
707 710 if plugin_user:
708 711 log.debug('Plugin returned proper authentication data')
709 712 return plugin_user
710 713 # we failed to Auth because .auth() method didn't return proper user
711 714 log.debug("User `%s` failed to authenticate against %s",
712 715 display_user, plugin.get_id())
713 716
714 717 # case when we failed to authenticate against all defined plugins
715 718 return None
716 719
717 720
718 721 def chop_at(s, sub, inclusive=False):
719 722 """Truncate string ``s`` at the first occurrence of ``sub``.
720 723
721 724 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
722 725
723 726 >>> chop_at("plutocratic brats", "rat")
724 727 'plutoc'
725 728 >>> chop_at("plutocratic brats", "rat", True)
726 729 'plutocrat'
727 730 """
728 731 pos = s.find(sub)
729 732 if pos == -1:
730 733 return s
731 734 if inclusive:
732 735 return s[:pos+len(sub)]
733 736 return s[:pos]
@@ -1,480 +1,480 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for LDAP
23 23 """
24 24
25 25
26 26 import colander
27 27 import logging
28 28 import traceback
29 29
30 30 from rhodecode.translation import _
31 31 from rhodecode.authentication.base import (
32 32 RhodeCodeExternalAuthPlugin, chop_at, hybrid_property)
33 33 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
34 34 from rhodecode.authentication.routes import AuthnPluginResourceBase
35 35 from rhodecode.lib.colander_utils import strip_whitespace
36 36 from rhodecode.lib.exceptions import (
37 37 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
38 38 )
39 39 from rhodecode.lib.utils2 import safe_unicode, safe_str
40 40 from rhodecode.model.db import User
41 41 from rhodecode.model.validators import Missing
42 42
43 43 log = logging.getLogger(__name__)
44 44
45 45 try:
46 46 import ldap
47 47 except ImportError:
48 48 # means that python-ldap is not installed, we use Missing object to mark
49 49 # ldap lib is Missing
50 50 ldap = Missing
51 51
52 52
53 53 def plugin_factory(plugin_id, *args, **kwds):
54 54 """
55 55 Factory function that is called during plugin discovery.
56 56 It returns the plugin instance.
57 57 """
58 58 plugin = RhodeCodeAuthPlugin(plugin_id)
59 59 return plugin
60 60
61 61
62 62 class LdapAuthnResource(AuthnPluginResourceBase):
63 63 pass
64 64
65 65
66 66 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
67 67 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
68 68 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
69 69 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
70 70
71 71 host = colander.SchemaNode(
72 72 colander.String(),
73 73 default='',
74 74 description=_('Host[s] of the LDAP Server \n'
75 75 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
76 76 'Multiple servers can be specified using commas'),
77 77 preparer=strip_whitespace,
78 78 title=_('LDAP Host'),
79 79 widget='string')
80 80 port = colander.SchemaNode(
81 81 colander.Int(),
82 82 default=389,
83 83 description=_('Custom port that the LDAP server is listening on. '
84 84 'Default value is: 389'),
85 85 preparer=strip_whitespace,
86 86 title=_('Port'),
87 87 validator=colander.Range(min=0, max=65536),
88 88 widget='int')
89 89 dn_user = colander.SchemaNode(
90 90 colander.String(),
91 91 default='',
92 92 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
93 93 'e.g., cn=admin,dc=mydomain,dc=com, or '
94 94 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
95 95 missing='',
96 96 preparer=strip_whitespace,
97 97 title=_('Account'),
98 98 widget='string')
99 99 dn_pass = colander.SchemaNode(
100 100 colander.String(),
101 101 default='',
102 102 description=_('Password to authenticate for given user DN.'),
103 103 missing='',
104 104 preparer=strip_whitespace,
105 105 title=_('Password'),
106 106 widget='password')
107 107 tls_kind = colander.SchemaNode(
108 108 colander.String(),
109 109 default=tls_kind_choices[0],
110 110 description=_('TLS Type'),
111 111 title=_('Connection Security'),
112 112 validator=colander.OneOf(tls_kind_choices),
113 113 widget='select')
114 114 tls_reqcert = colander.SchemaNode(
115 115 colander.String(),
116 116 default=tls_reqcert_choices[0],
117 117 description=_('Require Cert over TLS?. Self-signed and custom '
118 118 'certificates can be used when\n `RhodeCode Certificate` '
119 119 'found in admin > settings > system info page is extended.'),
120 120 title=_('Certificate Checks'),
121 121 validator=colander.OneOf(tls_reqcert_choices),
122 122 widget='select')
123 123 base_dn = colander.SchemaNode(
124 124 colander.String(),
125 125 default='',
126 126 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
127 127 'in it to be replaced with current user credentials \n'
128 128 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
129 129 missing='',
130 130 preparer=strip_whitespace,
131 131 title=_('Base DN'),
132 132 widget='string')
133 133 filter = colander.SchemaNode(
134 134 colander.String(),
135 135 default='',
136 136 description=_('Filter to narrow results \n'
137 137 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
138 138 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
139 139 missing='',
140 140 preparer=strip_whitespace,
141 141 title=_('LDAP Search Filter'),
142 142 widget='string')
143 143
144 144 search_scope = colander.SchemaNode(
145 145 colander.String(),
146 146 default=search_scope_choices[2],
147 147 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
148 148 title=_('LDAP Search Scope'),
149 149 validator=colander.OneOf(search_scope_choices),
150 150 widget='select')
151 151 attr_login = colander.SchemaNode(
152 152 colander.String(),
153 153 default='uid',
154 154 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
155 155 preparer=strip_whitespace,
156 156 title=_('Login Attribute'),
157 157 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
158 158 widget='string')
159 159 attr_firstname = colander.SchemaNode(
160 160 colander.String(),
161 161 default='',
162 162 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
163 163 missing='',
164 164 preparer=strip_whitespace,
165 165 title=_('First Name Attribute'),
166 166 widget='string')
167 167 attr_lastname = colander.SchemaNode(
168 168 colander.String(),
169 169 default='',
170 170 description=_('LDAP Attribute to map to last name (e.g., sn)'),
171 171 missing='',
172 172 preparer=strip_whitespace,
173 173 title=_('Last Name Attribute'),
174 174 widget='string')
175 175 attr_email = colander.SchemaNode(
176 176 colander.String(),
177 177 default='',
178 178 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
179 179 'Emails are a crucial part of RhodeCode. \n'
180 180 'If possible add a valid email attribute to ldap users.'),
181 181 missing='',
182 182 preparer=strip_whitespace,
183 183 title=_('Email Attribute'),
184 184 widget='string')
185 185
186 186
187 187 class AuthLdap(object):
188 188
189 189 def _build_servers(self):
190 190 return ', '.join(
191 191 ["{}://{}:{}".format(
192 192 self.ldap_server_type, host.strip(), self.LDAP_SERVER_PORT)
193 193 for host in self.SERVER_ADDRESSES])
194 194
195 195 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
196 196 tls_kind='PLAIN', tls_reqcert='DEMAND', ldap_version=3,
197 197 search_scope='SUBTREE', attr_login='uid',
198 ldap_filter=None):
198 ldap_filter=''):
199 199 if ldap == Missing:
200 200 raise LdapImportError("Missing or incompatible ldap library")
201 201
202 202 self.debug = False
203 203 self.ldap_version = ldap_version
204 204 self.ldap_server_type = 'ldap'
205 205
206 206 self.TLS_KIND = tls_kind
207 207
208 208 if self.TLS_KIND == 'LDAPS':
209 209 port = port or 689
210 210 self.ldap_server_type += 's'
211 211
212 212 OPT_X_TLS_DEMAND = 2
213 213 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert,
214 214 OPT_X_TLS_DEMAND)
215 215 # split server into list
216 216 self.SERVER_ADDRESSES = server.split(',')
217 217 self.LDAP_SERVER_PORT = port
218 218
219 219 # USE FOR READ ONLY BIND TO LDAP SERVER
220 220 self.attr_login = attr_login
221 221
222 222 self.LDAP_BIND_DN = safe_str(bind_dn)
223 223 self.LDAP_BIND_PASS = safe_str(bind_pass)
224 224 self.LDAP_SERVER = self._build_servers()
225 225 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
226 226 self.BASE_DN = safe_str(base_dn)
227 227 self.LDAP_FILTER = safe_str(ldap_filter)
228 228
229 229 def _get_ldap_server(self):
230 230 if self.debug:
231 231 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
232 232 if hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
233 233 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR,
234 234 '/etc/openldap/cacerts')
235 235 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
236 236 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
237 ldap.set_option(ldap.OPT_TIMEOUT, 20)
238 ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, 10)
239 ldap.set_option(ldap.OPT_TIMELIMIT, 15)
237 ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, 60 * 10)
238 ldap.set_option(ldap.OPT_TIMEOUT, 60 * 10)
239
240 240 if self.TLS_KIND != 'PLAIN':
241 241 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
242 242 server = ldap.initialize(self.LDAP_SERVER)
243 243 if self.ldap_version == 2:
244 244 server.protocol = ldap.VERSION2
245 245 else:
246 246 server.protocol = ldap.VERSION3
247 247
248 248 if self.TLS_KIND == 'START_TLS':
249 249 server.start_tls_s()
250 250
251 251 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
252 252 log.debug('Trying simple_bind with password and given login DN: %s',
253 253 self.LDAP_BIND_DN)
254 254 server.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
255 255
256 256 return server
257 257
258 258 def get_uid(self, username):
259 259 uid = username
260 260 for server_addr in self.SERVER_ADDRESSES:
261 261 uid = chop_at(username, "@%s" % server_addr)
262 262 return uid
263 263
264 264 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
265 265 try:
266 266 log.debug('Trying simple bind with %s', dn)
267 267 server.simple_bind_s(dn, safe_str(password))
268 268 user = server.search_ext_s(
269 269 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
270 270 _, attrs = user
271 271 return attrs
272 272
273 273 except ldap.INVALID_CREDENTIALS:
274 274 log.debug(
275 275 "LDAP rejected password for user '%s': %s, org_exc:",
276 276 username, dn, exc_info=True)
277 277
278 278 def authenticate_ldap(self, username, password):
279 279 """
280 280 Authenticate a user via LDAP and return his/her LDAP properties.
281 281
282 282 Raises AuthenticationError if the credentials are rejected, or
283 283 EnvironmentError if the LDAP server can't be reached.
284 284
285 285 :param username: username
286 286 :param password: password
287 287 """
288 288
289 289 uid = self.get_uid(username)
290 290
291 291 if not password:
292 292 msg = "Authenticating user %s with blank password not allowed"
293 293 log.warning(msg, username)
294 294 raise LdapPasswordError(msg)
295 295 if "," in username:
296 296 raise LdapUsernameError(
297 297 "invalid character `,` in username: `{}`".format(username))
298 298 try:
299 299 server = self._get_ldap_server()
300 300 filter_ = '(&%s(%s=%s))' % (
301 301 self.LDAP_FILTER, self.attr_login, username)
302 302 log.debug("Authenticating %r filter %s at %s", self.BASE_DN,
303 303 filter_, self.LDAP_SERVER)
304 304 lobjects = server.search_ext_s(
305 305 self.BASE_DN, self.SEARCH_SCOPE, filter_)
306 306
307 307 if not lobjects:
308 308 log.debug("No matching LDAP objects for authentication "
309 309 "of UID:'%s' username:(%s)", uid, username)
310 310 raise ldap.NO_SUCH_OBJECT()
311 311
312 312 log.debug('Found matching ldap object, trying to authenticate')
313 313 for (dn, _attrs) in lobjects:
314 314 if dn is None:
315 315 continue
316 316
317 317 user_attrs = self.fetch_attrs_from_simple_bind(
318 318 server, dn, username, password)
319 319 if user_attrs:
320 320 break
321 321
322 322 else:
323 323 raise LdapPasswordError(
324 324 'Failed to authenticate user `{}`'
325 325 'with given password'.format(username))
326 326
327 327 except ldap.NO_SUCH_OBJECT:
328 328 log.debug("LDAP says no such user '%s' (%s), org_exc:",
329 329 uid, username, exc_info=True)
330 330 raise LdapUsernameError('Unable to find user')
331 331 except ldap.SERVER_DOWN:
332 332 org_exc = traceback.format_exc()
333 333 raise LdapConnectionError(
334 334 "LDAP can't access authentication "
335 335 "server, org_exc:%s" % org_exc)
336 336
337 337 return dn, user_attrs
338 338
339 339
340 340 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
341 341 # used to define dynamic binding in the
342 342 DYNAMIC_BIND_VAR = '$login'
343 343 _settings_unsafe_keys = ['dn_pass']
344 344
345 345 def includeme(self, config):
346 346 config.add_authn_plugin(self)
347 347 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
348 348 config.add_view(
349 349 'rhodecode.authentication.views.AuthnPluginViewBase',
350 350 attr='settings_get',
351 351 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
352 352 request_method='GET',
353 353 route_name='auth_home',
354 354 context=LdapAuthnResource)
355 355 config.add_view(
356 356 'rhodecode.authentication.views.AuthnPluginViewBase',
357 357 attr='settings_post',
358 358 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
359 359 request_method='POST',
360 360 route_name='auth_home',
361 361 context=LdapAuthnResource)
362 362
363 363 def get_settings_schema(self):
364 364 return LdapSettingsSchema()
365 365
366 366 def get_display_name(self):
367 367 return _('LDAP')
368 368
369 369 @hybrid_property
370 370 def name(self):
371 371 return "ldap"
372 372
373 373 def use_fake_password(self):
374 374 return True
375 375
376 376 def user_activation_state(self):
377 377 def_user_perms = User.get_default_user().AuthUser().permissions['global']
378 378 return 'hg.extern_activate.auto' in def_user_perms
379 379
380 380 def try_dynamic_binding(self, username, password, current_args):
381 381 """
382 382 Detects marker inside our original bind, and uses dynamic auth if
383 383 present
384 384 """
385 385
386 386 org_bind = current_args['bind_dn']
387 387 passwd = current_args['bind_pass']
388 388
389 389 def has_bind_marker(username):
390 390 if self.DYNAMIC_BIND_VAR in username:
391 391 return True
392 392
393 393 # we only passed in user with "special" variable
394 394 if org_bind and has_bind_marker(org_bind) and not passwd:
395 395 log.debug('Using dynamic user/password binding for ldap '
396 396 'authentication. Replacing `%s` with username',
397 397 self.DYNAMIC_BIND_VAR)
398 398 current_args['bind_dn'] = org_bind.replace(
399 399 self.DYNAMIC_BIND_VAR, username)
400 400 current_args['bind_pass'] = password
401 401
402 402 return current_args
403 403
404 404 def auth(self, userobj, username, password, settings, **kwargs):
405 405 """
406 406 Given a user object (which may be null), username, a plaintext password,
407 407 and a settings object (containing all the keys needed as listed in
408 408 settings()), authenticate this user's login attempt.
409 409
410 410 Return None on failure. On success, return a dictionary of the form:
411 411
412 412 see: RhodeCodeAuthPluginBase.auth_func_attrs
413 413 This is later validated for correctness
414 414 """
415 415
416 416 if not username or not password:
417 417 log.debug('Empty username or password skipping...')
418 418 return None
419 419
420 420 ldap_args = {
421 421 'server': settings.get('host', ''),
422 422 'base_dn': settings.get('base_dn', ''),
423 423 'port': settings.get('port'),
424 424 'bind_dn': settings.get('dn_user'),
425 425 'bind_pass': settings.get('dn_pass'),
426 426 'tls_kind': settings.get('tls_kind'),
427 427 'tls_reqcert': settings.get('tls_reqcert'),
428 428 'search_scope': settings.get('search_scope'),
429 429 'attr_login': settings.get('attr_login'),
430 430 'ldap_version': 3,
431 431 'ldap_filter': settings.get('filter'),
432 432 }
433 433
434 434 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
435 435
436 436 log.debug('Checking for ldap authentication.')
437 437
438 438 try:
439 439 aldap = AuthLdap(**ldap_args)
440 440 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
441 441 log.debug('Got ldap DN response %s', user_dn)
442 442
443 443 def get_ldap_attr(k):
444 444 return ldap_attrs.get(settings.get(k), [''])[0]
445 445
446 446 # old attrs fetched from RhodeCode database
447 447 admin = getattr(userobj, 'admin', False)
448 448 active = getattr(userobj, 'active', True)
449 449 email = getattr(userobj, 'email', '')
450 450 username = getattr(userobj, 'username', username)
451 451 firstname = getattr(userobj, 'firstname', '')
452 452 lastname = getattr(userobj, 'lastname', '')
453 453 extern_type = getattr(userobj, 'extern_type', '')
454 454
455 455 groups = []
456 456 user_attrs = {
457 457 'username': username,
458 458 'firstname': safe_unicode(
459 459 get_ldap_attr('attr_firstname') or firstname),
460 460 'lastname': safe_unicode(
461 461 get_ldap_attr('attr_lastname') or lastname),
462 462 'groups': groups,
463 463 'email': get_ldap_attr('attr_email') or email,
464 464 'admin': admin,
465 465 'active': active,
466 466 'active_from_extern': None,
467 467 'extern_name': user_dn,
468 468 'extern_type': extern_type,
469 469 }
470 470 log.debug('ldap user: %s', user_attrs)
471 471 log.info('user %s authenticated correctly', user_attrs['username'])
472 472
473 473 return user_attrs
474 474
475 475 except (LdapUsernameError, LdapPasswordError, LdapImportError):
476 476 log.exception("LDAP related exception")
477 477 return None
478 478 except (Exception,):
479 479 log.exception("Other exception")
480 480 return None
General Comments 0
You need to be logged in to leave comments. Login now