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