Merge lp:~pfalcon/linaro-license-protection/crowd-auth into lp:~linaro-automation/linaro-license-protection/trunk

Proposed by Paul Sokolovsky
Status: Merged
Merged at revision: 195
Proposed branch: lp:~pfalcon/linaro-license-protection/crowd-auth
Merge into: lp:~linaro-automation/linaro-license-protection/trunk
Diff against target: 749 lines (+244/-136)
16 files modified
README (+23/-11)
license_protected_downloads/buildinfo.py (+13/-7)
license_protected_downloads/group_auth_common.py (+3/-0)
license_protected_downloads/group_auth_crowd.py (+48/-0)
license_protected_downloads/group_auth_openid.py (+19/-32)
license_protected_downloads/tests/BUILD-INFO.txt (+1/-1)
license_protected_downloads/tests/__init__.py (+2/-0)
license_protected_downloads/tests/test_buildinfo.py (+13/-4)
license_protected_downloads/tests/test_openid_auth.py (+11/-20)
license_protected_downloads/tests/test_splicebuildinfos.py (+3/-3)
license_protected_downloads/tests/test_views.py (+18/-0)
license_protected_downloads/tests/testserver_root/build-info/subdir2/BUILD-INFO.txt (+7/-0)
license_protected_downloads/tests/testserver_root/build-info/write-test/BUILD-INFO.txt (+0/-42)
license_protected_downloads/views.py (+54/-15)
settings.py (+19/-1)
templates/group_auth_failure.html (+10/-0)
To merge this branch: bzr merge lp:~pfalcon/linaro-license-protection/crowd-auth
Reviewer Review Type Date Requested Status
Milo Casagrande (community) Approve
Georgy Redkozubov Pending
Review via email: mp+167025@code.launchpad.net

This proposal supersedes a proposal from 2013-05-28.

Description of the change

These are changes to switch l-l-p to Crowd API for group access authorization. Expectably, I didn't tear off old "OpenID teams" code, but rather went for adding new Crowd authz code, and allow app to be configured for which of them to use, allowing seamless transition and reverting if needed.

Well, to support switching to another authz method at all, and such "pluggable" architecture in particular, it of course first took some refactoring on existing codebase.

Actually, I submit this MRs with just refactoring existing setup, which doesn't change current authz method. That's to get early comments, while I'm working on Crowd authz "plugin" specifically.

Suggested review mode is to look at the commit individually.

UPDATE 2013-06-03: Finalized patchset re-submitted.

To post a comment you must log in.
Revision history for this message
Milo Casagrande (milo) wrote : Posted in a previous version of this proposal

Paul,

thanks for kick-starting this work!
Overall the work looks great, and the refactoring too.

+class ViewHelpersTests(BaseServeViewTest):
+ def test_auth_group_error(self):
+ groups = ["linaro", "batman", "catwoman", "joker"]
+ request = Mock()
+ request.path = "mock_path"
+ response = views.group_auth_failed_response(request, groups)
+ self.assertIsNotNone(response)
+ self.assertTrue(isinstance(response, HttpResponse))
+ self.assertContains(response,
+ "You need to be the member of one of the linaro batman, catwoman "
+ "or joker teams in Launchpad.",
+ status_code=403)

Not something I'm really against, but I would be more interested in testing only
the status code I get, not the kind of "string" I will receive (to me that looks
already more than an unit test). I would test something like that:

self.failUnlessEqual(response.status_code, 403)

(not sure that that is working code though)

+def group_auth_failed_response(request, auth_groups):
+ """Construct a nice response detailing list of auth groups that
+ will allow access to the requested file."""
+ if len(auth_groups) > 1:
+ groups_string = "one of the " + auth_groups.pop(0) + " "
+ if len(auth_groups) > 1:
+ groups_string += ", ".join(auth_groups[0:-1])
+
+ groups_string += " or " + auth_groups[-1] + " teams"
+ else:
+ groups_string = "the " + auth_groups[0] + " team"

I personally try to avoid concatenating strings in that way. Why not something like:

if len(auth_group) > 1:
    groups_string = "one of the %s" % auth_groups.pop(0)
    if len(auth_groups) > 1:
        groups_string += (" %s" % ", ".join(auth_groups[0:-1])).rstrip()
    groups_string += " or %s teams" % auth_groups[-1]
else:
    groups_string = "the %s team" % auth_groups[0]

I prefer it since it is clearer what you are trying to achieve and you can easily spot spurious white spaces.

I'm approving since what I listed are not really blockers, and functionalities are OK, but if you feel to apply them, do while merging.

Another thing I noticed, but not related to this code review: we are missing a license for all the code in linaro-license-protection, and all license header too. We should probably apply one at one point.

review: Approve
Revision history for this message
Georgy Redkozubov (gesha) wrote : Posted in a previous version of this proposal

Looks good.
+1 to Milo's comment on tests, afaics, you've missed a ',' in comparison string on line 202. Should be
202 + "You need to be the member of one of the linaro, batman, catwoman "

Otherwise approved.

review: Approve
Revision history for this message
Paul Sokolovsky (pfalcon) wrote : Posted in a previous version of this proposal

> Not something I'm really against, but I would be more interested in testing only
the status code I get, not the kind of "string" I will receive (to me that looks
already more than an unit test). I would test something like that:

Well, if looking in more detail, what I did is factored out that string construction out of OpenIdAuth class. The was also test_openid_auth which exactly did that string test. Consequently, I moved that string test where its new routine belongs. I didn't add anything, I don't feel comfortable removing anything either - my idea was to preserve original semantics and tests which cover it.

> I personally try to avoid concatenating strings in that way.

Again, routine wasn't written by me ;-)

Revision history for this message
Paul Sokolovsky (pfalcon) wrote : Posted in a previous version of this proposal

> afaics, you've missed a ',' in comparison string on line

Ok, let me fire up bzr blame. Well, Englishman can't be wrong with English usage - it was done by James ;-).

And of course, I'm in principle all for fixing side issues, but: 1) They may need more consideration (in this case, both test "simplification" and grammar), and 2) They should be done separately, to not skew topic branch context.

Re: 2), if we used git, I could just apply quick fix to main branch, and rebase mine, but with bzr, it just better wait in queue (to not introduce twisted merges).

Revision history for this message
Milo Casagrande (milo) wrote :
Download full text (3.7 KiB)

Hello Paul!

Thanks for pulling this together!
Following are my findings!

On Mon, Jun 3, 2013 at 1:57 PM, Paul Sokolovsky
<email address hidden> wrote:
>
> === modified file 'README'
> --- README 2013-02-15 09:45:08 +0000
> +++ README 2013-06-03 11:56:29 +0000
> @@ -16,7 +16,9 @@
> - * openid restrictions (by Launchpad teams)
> + * group-based access authorization restrictions (using group information
> + from external services, currently OpenID with team extensions is
> + (as used by Launchpad.net) and Atlassian Crowd are supported).

I guess the "is" on the second changed line should be removed.

> + * Auth-Groups: (optional)
> + Names of groups, members of which are allowed to access protected files.

"Name of groups" should be fine, but better have a
real-English-speaking person review for this one :-)

> -Format-Version: 0.1
> +Format-Version: 0.5

> def _set(self, key, value):
> + "key: file pattern, value: list of dicts of field/val pairs"

Can you please split this on two lines?

> === added file 'license_protected_downloads/group_auth_crowd.py'
> --- license_protected_downloads/group_auth_crowd.py 1970-01-01 00:00:00 +0000
> +++ license_protected_downloads/group_auth_crowd.py 2013-06-03 11:56:29 +0000
> @@ -0,0 +1,52 @@
> +import logging
> +
> +from django.conf import settings
> +from django.shortcuts import redirect
> +
> +import requests
> +
> +
> +log = logging.getLogger(__file__)
> +
> +
> +def upgrade_requests():
> + """Ubuntu 12.04 comes with pretty old requests version. Add convenience
> + methods of newer versions straight to it, to avoid client-side
> + workarounds."""
> + if "json" not in dir(requests.models.Response):
> + def patchy_json(self):
> + import json
> + return json.loads(self.content)
> + requests.models.Response.json = patchy_json
> +
> +# We monkey-patch requests module on first load
> +upgrade_requests()
> +
> +
> +class GroupAuthError(Exception):
> + pass
> +
> +
> +def process_group_auth(request, required_groups):
> + if not required_groups:
> + return True
> + if not request.user.is_authenticated():
> + # Force OpenID login
> + return redirect(settings.LOGIN_URL + "?next=" + request.path)
> +
> + log.warn("Authenticating using Crowd API: %s",
> + request.user.username)
> +
> + auth = (settings.ATLASSIAN_CROWD_API_USERNAME,
> + settings.ATLASSIAN_CROWD_API_PASSWORD)
> + params = {"username": request.user.username}
> + r = requests.get(settings.ATLASSIAN_CROWD_API_URL
> + + "/user/group/nested.json", params=params, auth=auth)
> + if r.status_code != 200:
> + raise GroupAuthError(r.status_code)
> + data = r.json()
> + user_groups = set([x["name"] for x in data["groups"]])
> + for g in required_groups:
> + if g in user_groups:
> + return True
> + return False

I know there is nothing wrong with the for loop, but just a suggestion
to avoid it:

return user_groups.isdisjoint(set(required_groups))

Since you already have a set there in place.

> === added directory 'license_protected_downloads/tests/...

Read more...

Revision history for this message
Georgy Redkozubov (gesha) wrote :

Paul, thanks for working on this and a good job.

Here are my 5cents:

One thought about versions, I think it makes sense to add warning that user is using "old" version of BUILD-INFO.txt with OpenID-Launchpad-Teams which will be deprecated sooner or later.

Another is about tests. May be it is worth renaming test 'test_apply_to_dir(self)' to something like 'test_apply_to_dir_openid_launchpad_teams_field(self)' to better reflect what it does after you've added backwards compatibility with renaming of fields.

And the last one is in license_protected_downloads/buildinfo.py
There is a function that does write BUILD-INFO.txt from an array, it's used in splicing, it writes out Format-Version: 0.1, I guess we need to update it to write latest version (currently = 0.5) since it will be updated during read.

    def write_from_array(cls, build_info_array, file_path):
        with open(file_path, "w") as outfile:
            outfile.write("Format-Version: 0.1\n\n")

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

> I guess the "is" on the second changed line should be removed.

Fixed.

> "Name of groups" should be fine, but better have a real-English-speaking person review for this one :-)

Yep, I paused at that too when wrote that, but somehow did it like that (to emphasize that members are of group, not of names?). Let it be just "List of groups".

> Can you please split this on two lines?

Done.

> I know there is nothing wrong with the for loop, but just a suggestion to avoid it:
> return user_groups.isdisjoint(set(required_groups))

Gotta remember (those who will later look at the code too) that Python now has pretty complete set implementation! Another problem is readability and understandability. In particular, in this case, it should be "return not". All in all, it now requires a comment where previously it didn't. Well, ok, let's go for it.

> Hmmm... shouldn't this be bumped to 0.5?

Makes sense.

206. By Paul Sokolovsky

Typos/grammar.

207. By Paul Sokolovsky

Improve docstring for _set().

208. By Paul Sokolovsky

We use Auth-Groups directive in this file, so it's format 0.5 .

209. By Paul Sokolovsky

Applied isdisjoint() "optimization", with credit to Milo.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

> I think it makes sense to add warning that user is using "old" version of BUILD-INFO.txt with OpenID-Launchpad-Teams which will be deprecated sooner or later.

Yes, makes sense, I added warning this may happen in the future to README. But when it should happen? It should happen during publishing stage, not in webapp. Or do you hint about updating scripts/publish_to_snapshots.py? Well, that's not directly related with the aim of this patch, which is "adding Crowd API support to l-l-p webapp", and would be just scope creep even further. So, let's leave this for separate patch.

> Another is about tests. May be it is worth renaming test 'test_apply_to_dir(self)' to something like 'test_apply_to_dir_openid_launchpad_teams_field(self)' to better reflect what it does after you've added backwards compatibility with renaming of fields.

Well, that test actually tests how BUILD-INFO.txt applies to dirs, nothing else. It's just that was the only test in testsuite which deals with group field, so I selected it as a template for Auth-Groups field test. It's not ideal, but the whole testsuite is not ideal (well, I'm not sure it's the aim - it just should provide test coverage). Improving that should be a separate effort again.

> There is a function that does write BUILD-INFO.txt from an array, it's used in splicing, it writes out Format-Version: 0.1

Fixed, thanks.

210. By Paul Sokolovsky

write_from_array(): Write 0.5 format files.

Revision history for this message
Milo Casagrande (milo) wrote :

Thanks Paul! Looks good to me.

review: Approve
211. By Paul Sokolovsky

Handle errors during group auth properly.

212. By Paul Sokolovsky

First step toward normal logging setup.

213. By Paul Sokolovsky

Fix exception logging.

214. By Paul Sokolovsky

More test fixes for 0.5 format version.

215. By Paul Sokolovsky

Pyflakes fixes.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'README'
--- README 2013-02-15 09:45:08 +0000
+++ README 2013-06-04 18:22:27 +0000
@@ -16,7 +16,9 @@
16 * makes use of the regular directory structure on disk16 * makes use of the regular directory structure on disk
17 * click through licensing with each file potentially having a different17 * click through licensing with each file potentially having a different
18 license/theme18 license/theme
19 * openid restrictions (by Launchpad teams)19 * group-based access authorization restrictions (using group information
20 from external services, currently OpenID with team extensions
21 (as used by Launchpad.net) and Atlassian Crowd are supported).
20 * post-processing of all uploads (a script that manages uploads)22 * post-processing of all uploads (a script that manages uploads)
21 * per-IP pass-through (for automatic services like a test framework)23 * per-IP pass-through (for automatic services like a test framework)
2224
@@ -46,12 +48,12 @@
46syntax).48syntax).
4749
48BUILD-INFO.txt file will allow one to specify the access restrictions50BUILD-INFO.txt file will allow one to specify the access restrictions
49(such as click-through licensing and OpenID-restrictions) and influence51(such as click-through licensing and required groups) and influence
50the display (such as license-theme) of a particular build.52the display (such as license-theme) of a particular build.
5153
52WARNING: if you want a build type to be protected by OpenID, you need to54WARNING: if you want a build to be protected by OpenID-based groups, you
53ensure that the appropriate team is added to the list of django groups55need to ensure that the appropriate team is added to the list of django
54in the database. Only django admins can do that.56groups in the database. Only django admins can do that.
5557
56Next step is to ensure that58Next step is to ensure that
5759
@@ -108,15 +110,15 @@
108 a placeholder and will be ignored. To be added later by build services?110 a placeholder and will be ignored. To be added later by build services?
109 * License-Type: (required)111 * License-Type: (required)
110 open - Open builds. No license page is displayed.112 open - Open builds. No license page is displayed.
111 protected - EULA protected builds. If 'OpenID-Launchpad-Teams' is defined113 protected - EULA protected builds. If 'Auth-Groups' is defined
112 then OpenID protection is used, otherwise simple Accept/Decline license114 then group authorization is used, otherwise simple Accept/Decline license
113 page is displayed before accessing protected files.115 page is displayed before accessing protected files.
114 * Theme: (required only if License-Type is 'protected')116 * Theme: (required only if License-Type is 'protected')
115 Acceptable values are: stericsson, samsung.117 Acceptable values are: stericsson, samsung.
116 Theme name for selecting proper theming on download and license pages.118 Theme name for selecting proper theming on download and license pages.
117 * OpenID-Launchpad-Teams: (optional)119 * Auth-Groups: (optional)
118 LP team names, members of which are allowed to access protected files. No120 List of groups, members of which are allowed to access protected files.
119 OpenID protection if absent.121 No group-based protection if absent.
120 * Collect-User-Data: (optional)122 * Collect-User-Data: (optional)
121 Acceptable values are: yes, no.123 Acceptable values are: yes, no.
122 Defaults to 'no' if not present. If the field is set to 'yes' then124 Defaults to 'no' if not present. If the field is set to 'yes' then
@@ -131,10 +133,20 @@
131- Field names are case insensitive.133- Field names are case insensitive.
132- Fields order doesn't matter, except 'Files-Pattern'134- Fields order doesn't matter, except 'Files-Pattern'
133135
136History of BUILD-INFO.txt format changes
137........................................
138
139Changes from format version 0.1 to 0.5:
140
1411. Field "OpenID-Launchpad-Teams" is deprecated and renamed to
142"Auth-Groups". Old name is supported for archived builds, however
143it may lead to warnings, and at later time to errors, during
144publishing new builds, so client usage should be upgraded.
145
134BUILD-INFO.txt example:146BUILD-INFO.txt example:
135.......................147.......................
136148
137Format-Version: 0.1149Format-Version: 0.5
138150
139Files-Pattern: *.img, *.tar.bz2151Files-Pattern: *.img, *.tar.bz2
140Build-Name: landing-snowball152Build-Name: landing-snowball
141153
=== modified file 'license_protected_downloads/buildinfo.py'
--- license_protected_downloads/buildinfo.py 2013-05-29 13:41:12 +0000
+++ license_protected_downloads/buildinfo.py 2013-06-04 18:22:27 +0000
@@ -14,8 +14,11 @@
14 self.file_info_array = [{}]14 self.file_info_array = [{}]
15 self.fields_defined = [15 self.fields_defined = [
16 "format-version", "files-pattern", "build-name", "theme",16 "format-version", "files-pattern", "build-name", "theme",
17 "license-type", "openid-launchpad-teams", "collect-user-data",17 "license-type", "auth-groups", "collect-user-data",
18 "license-text"]18 "license-text",
19 # Deprecated
20 "openid-launchpad-teams",
21 ]
19 self.full_file_name = fn22 self.full_file_name = fn
20 self.search_path = self.get_search_path(fn)23 self.search_path = self.get_search_path(fn)
21 self.fname = os.path.basename(fn)24 self.fname = os.path.basename(fn)
@@ -40,6 +43,9 @@
40 os.path.join(cls.get_search_path(path), "BUILD-INFO.txt"))43 os.path.join(cls.get_search_path(path), "BUILD-INFO.txt"))
4144
42 def _set(self, key, value):45 def _set(self, key, value):
46 """Record set of directives applying to a file pattern
47 key: file pattern
48 value: list of dicts of field/val pairs"""
43 if key in self.build_info_array[self.index]:49 if key in self.build_info_array[self.index]:
44 # A repeated key indicates we have found another chunk of50 # A repeated key indicates we have found another chunk of
45 # build-info51 # build-info
@@ -91,10 +97,7 @@
91 if index > self.max_index:97 if index > self.max_index:
92 return False98 return False
93 block = self.file_info_array[index]99 block = self.file_info_array[index]
94 for key in block:100 return block.get(field, False)
95 if field == key:
96 return block[field]
97 return False
98101
99 def parseLine(self, line):102 def parseLine(self, line):
100 values = line.split(":", 1)103 values = line.split(":", 1)
@@ -108,6 +111,9 @@
108 raise IncorrectDataFormatException(111 raise IncorrectDataFormatException(
109 "Field '%s' not allowed." % field)112 "Field '%s' not allowed." % field)
110 else:113 else:
114 # Rename any deprecated field names to new names
115 field_renames = {"openid-launchpad-teams": "auth-groups"}
116 field = field_renames.get(field, field)
111 return {field: value}117 return {field: value}
112118
113 def isValidField(self, field_name):119 def isValidField(self, field_name):
@@ -189,7 +195,7 @@
189 @classmethod195 @classmethod
190 def write_from_array(cls, build_info_array, file_path):196 def write_from_array(cls, build_info_array, file_path):
191 with open(file_path, "w") as outfile:197 with open(file_path, "w") as outfile:
192 outfile.write("Format-Version: 0.1\n\n")198 outfile.write("Format-Version: 0.5\n\n")
193 for key in build_info_array[0]:199 for key in build_info_array[0]:
194 if key != "format-version":200 if key != "format-version":
195 outfile.write("Files-Pattern: %s\n" % key)201 outfile.write("Files-Pattern: %s\n" % key)
196202
=== added file 'license_protected_downloads/group_auth_common.py'
--- license_protected_downloads/group_auth_common.py 1970-01-01 00:00:00 +0000
+++ license_protected_downloads/group_auth_common.py 2013-06-04 18:22:27 +0000
@@ -0,0 +1,3 @@
1class GroupAuthError(Exception):
2 "Unexpected (infastructure) error during group authorization check."
3 pass
04
=== added file 'license_protected_downloads/group_auth_crowd.py'
--- license_protected_downloads/group_auth_crowd.py 1970-01-01 00:00:00 +0000
+++ license_protected_downloads/group_auth_crowd.py 2013-06-04 18:22:27 +0000
@@ -0,0 +1,48 @@
1import logging
2
3from django.conf import settings
4from django.shortcuts import redirect
5import requests
6
7from group_auth_common import GroupAuthError
8
9
10log = logging.getLogger(__file__)
11
12
13def upgrade_requests():
14 """Ubuntu 12.04 comes with pretty old requests version. Add convenience
15 methods of newer versions straight to it, to avoid client-side
16 workarounds."""
17 if "json" not in dir(requests.models.Response):
18 def patchy_json(self):
19 import json
20 return json.loads(self.content)
21 requests.models.Response.json = patchy_json
22
23# We monkey-patch requests module on first load
24upgrade_requests()
25
26
27def process_group_auth(request, required_groups):
28 if not required_groups:
29 return True
30 if not request.user.is_authenticated():
31 # Force OpenID login
32 return redirect(settings.LOGIN_URL + "?next=" + request.path)
33
34 log.warn("Authenticating using Crowd API: %s",
35 request.user.username)
36
37 auth = (settings.ATLASSIAN_CROWD_API_USERNAME,
38 settings.ATLASSIAN_CROWD_API_PASSWORD)
39 params = {"username": request.user.username}
40 r = requests.get(settings.ATLASSIAN_CROWD_API_URL
41 + "/user/group/nested.json", params=params, auth=auth)
42 if r.status_code != 200:
43 raise GroupAuthError(r.status_code)
44 data = r.json()
45 user_groups = set([x["name"] for x in data["groups"]])
46
47 # If groups don't intersect, access denied
48 return not user_groups.isdisjoint(required_groups)
049
=== renamed file 'license_protected_downloads/openid_auth.py' => 'license_protected_downloads/group_auth_openid.py'
--- license_protected_downloads/openid_auth.py 2012-08-23 13:37:29 +0000
+++ license_protected_downloads/group_auth_openid.py 2013-06-04 18:22:27 +0000
@@ -1,16 +1,21 @@
1import logging
2
1from django.conf import settings3from django.conf import settings
2from django.shortcuts import redirect, render_to_response4from django.shortcuts import redirect
3from django.contrib.auth.models import Group5from django.contrib.auth.models import Group
4import bzr_version6
57
68log = logging.getLogger(__file__)
7class OpenIDAuth:9
810
9 @classmethod11def process_group_auth(request, openid_teams):
10 def process_openid_auth(cls, request, openid_teams):12 """Returns True if access granted, False if denied and Response
13 object if not enough authentication information available and
14 user should authenticate first (by following that Response).
15 """
1116
12 if not openid_teams:17 if not openid_teams:
13 return None18 return True
1419
15 for openid_team in openid_teams:20 for openid_team in openid_teams:
16 Group.objects.get_or_create(name=openid_team)21 Group.objects.get_or_create(name=openid_team)
@@ -19,28 +24,10 @@
19 # Force OpenID login24 # Force OpenID login
20 return redirect(settings.LOGIN_URL + "?next=" + request.path)25 return redirect(settings.LOGIN_URL + "?next=" + request.path)
2126
27 log.warn("Authenticating using Launchpad OpenID Teams: %s",
28 request.user.username)
22 for group in request.user.groups.all():29 for group in request.user.groups.all():
23 if group.name in openid_teams:30 if group.name in openid_teams:
24 return None31 return True
2532
26 # Construct a nice string of openid teams that will allow access to33 return False
27 # the requested file
28 if len(openid_teams) > 1:
29 teams_string = "one of the " + openid_teams.pop(0) + " "
30 if len(openid_teams) > 1:
31 teams_string += ", ".join(openid_teams[0:-1])
32
33 teams_string += " or " + openid_teams[-1] + " teams"
34 else:
35 teams_string = "the " + openid_teams[0] + " team"
36
37 response = render_to_response(
38 'openid_forbidden_template.html',
39 {'login': settings.LOGIN_URL + "?next=" + request.path,
40 'authenticated': request.user.is_authenticated(),
41 'openid_teams': teams_string,
42 'revno': bzr_version.get_my_bzr_revno(),
43 })
44
45 response.status_code = 403
46 return response
4734
=== modified file 'license_protected_downloads/tests/BUILD-INFO.txt'
--- license_protected_downloads/tests/BUILD-INFO.txt 2012-06-26 18:50:23 +0000
+++ license_protected_downloads/tests/BUILD-INFO.txt 2013-06-04 18:22:27 +0000
@@ -1,4 +1,4 @@
1Format-Version: 0.11Format-Version: 0.5
22
3Files-Pattern: *.txt3Files-Pattern: *.txt
4Build-Name: landing-protected4Build-Name: landing-protected
55
=== modified file 'license_protected_downloads/tests/__init__.py'
--- license_protected_downloads/tests/__init__.py 2013-04-19 12:13:03 +0000
+++ license_protected_downloads/tests/__init__.py 2013-06-04 18:22:27 +0000
@@ -11,6 +11,7 @@
11 FileViewTests,11 FileViewTests,
12 HowtoViewTests,12 HowtoViewTests,
13 ViewTests,13 ViewTests,
14 ViewHelpersTests,
14 )15 )
15from license_protected_downloads.tests.test_openid_auth import TestOpenIDAuth16from license_protected_downloads.tests.test_openid_auth import TestOpenIDAuth
16from license_protected_downloads.tests.test_custom_commands \17from license_protected_downloads.tests.test_custom_commands \
@@ -32,4 +33,5 @@
32 'TestPep8': TestPep8,33 'TestPep8': TestPep8,
33 'TestPyflakes': TestPyflakes,34 'TestPyflakes': TestPyflakes,
34 'ViewTests': ViewTests,35 'ViewTests': ViewTests,
36 'ViewHelpersTests': ViewHelpersTests,
35}37}
3638
=== modified file 'license_protected_downloads/tests/test_buildinfo.py'
--- license_protected_downloads/tests/test_buildinfo.py 2013-04-19 12:13:03 +0000
+++ license_protected_downloads/tests/test_buildinfo.py 2013-06-04 18:22:27 +0000
@@ -27,7 +27,16 @@
27 self.assertEquals(build_info.getInfoForFile(),27 self.assertEquals(build_info.getInfoForFile(),
28 [{'build-name': 'landing-protected',28 [{'build-name': 'landing-protected',
29 'license-type': 'protected',29 'license-type': 'protected',
30 'openid-launchpad-teams': 'linaro'}])30 'auth-groups': 'linaro'}])
31
32 def test_apply_to_dir_auth_groups_field(self):
33 dir_path = THIS_DIRECTORY + \
34 '/testserver_root/build-info/subdir2'
35 build_info = BuildInfo(dir_path)
36 self.assertEquals(build_info.getInfoForFile(),
37 [{'build-name': 'landing-protected',
38 'license-type': 'protected',
39 'auth-groups': 'linaro'}])
3140
32 def test_apply_to_nonexistent_file(self):41 def test_apply_to_nonexistent_file(self):
33 with self.assertRaises(IOError):42 with self.assertRaises(IOError):
@@ -41,14 +50,14 @@
41 def test_getFormatVersion(self):50 def test_getFormatVersion(self):
42 build_info = BuildInfo(self.buildinfo_file_path)51 build_info = BuildInfo(self.buildinfo_file_path)
4352
44 self.assertEqual("0.1", build_info.getFormatVersion())53 self.assertEqual("0.5", build_info.getFormatVersion())
4554
46 def test_get_emptyField(self):55 def test_get_emptyField(self):
47 value = "notempty"56 value = "notempty"
48 build_info = BuildInfo(self.buildinfo_file_path)57 build_info = BuildInfo(self.buildinfo_file_path)
49 for pair in build_info.file_info_array:58 for pair in build_info.file_info_array:
50 if "openid-launchpad-teams" in pair:59 if "auth-groups" in pair:
51 value = pair["openid-launchpad-teams"]60 value = pair["auth-groups"]
5261
53 self.assertFalse(value)62 self.assertFalse(value)
5463
5564
=== modified file 'license_protected_downloads/tests/test_openid_auth.py'
--- license_protected_downloads/tests/test_openid_auth.py 2012-08-23 13:37:29 +0000
+++ license_protected_downloads/tests/test_openid_auth.py 2013-06-04 18:22:27 +0000
@@ -2,7 +2,7 @@
2from django.http import HttpResponse2from django.http import HttpResponse
3from mock import Mock, patch3from mock import Mock, patch
44
5from license_protected_downloads.openid_auth import OpenIDAuth5from license_protected_downloads import group_auth_openid as openid_auth
66
77
8class TestOpenIDAuth(TestCase):8class TestOpenIDAuth(TestCase):
@@ -27,14 +27,14 @@
27 def test_check_team_membership_no_teams(self):27 def test_check_team_membership_no_teams(self):
28 mock_request = self.make_mock_request()28 mock_request = self.make_mock_request()
29 openid_teams = []29 openid_teams = []
30 self.assertIsNone(30 self.assertTrue(
31 OpenIDAuth.process_openid_auth(mock_request, openid_teams))31 openid_auth.process_group_auth(mock_request, openid_teams))
3232
33 def test_check_team_membership_no_authentication(self):33 def test_check_team_membership_no_authentication(self):
34 mock_request = self.make_mock_request()34 mock_request = self.make_mock_request()
35 mock_request.user.is_authenticated.return_value = False35 mock_request.user.is_authenticated.return_value = False
36 openid_teams = ["linaro"]36 openid_teams = ["linaro"]
37 response = OpenIDAuth.process_openid_auth(mock_request, openid_teams)37 response = openid_auth.process_group_auth(mock_request, openid_teams)
38 self.assertIsNotNone(response)38 self.assertIsNotNone(response)
39 self.assertTrue(isinstance(response, HttpResponse))39 self.assertTrue(isinstance(response, HttpResponse))
40 self.assertEquals(302, response.status_code)40 self.assertEquals(302, response.status_code)
@@ -45,8 +45,8 @@
45 mock_request.user.groups.all.return_value = [45 mock_request.user.groups.all.return_value = [
46 self.make_mock_group("linaro")]46 self.make_mock_group("linaro")]
47 openid_teams = ["linaro"]47 openid_teams = ["linaro"]
48 response = OpenIDAuth.process_openid_auth(mock_request, openid_teams)48 response = openid_auth.process_group_auth(mock_request, openid_teams)
49 self.assertIsNone(response)49 self.assertTrue(response)
5050
51 def test_check_no_team_membership_authed(self):51 def test_check_no_team_membership_authed(self):
52 mock_request = self.make_mock_request()52 mock_request = self.make_mock_request()
@@ -54,12 +54,8 @@
54 mock_request.user.groups.all.return_value = [54 mock_request.user.groups.all.return_value = [
55 self.make_mock_group("another-group")]55 self.make_mock_group("another-group")]
56 openid_teams = ["linaro"]56 openid_teams = ["linaro"]
57 response = OpenIDAuth.process_openid_auth(mock_request, openid_teams)57 response = openid_auth.process_group_auth(mock_request, openid_teams)
58 self.assertIsNotNone(response)58 self.assertFalse(response)
59 self.assertTrue(isinstance(response, HttpResponse))
60 self.assertContains(response,
61 "You need to be the member of the linaro team in Launchpad.",
62 status_code=403)
6359
64 def test_check_no_team_membership_authed_multi_teams(self):60 def test_check_no_team_membership_authed_multi_teams(self):
65 mock_request = self.make_mock_request()61 mock_request = self.make_mock_request()
@@ -67,13 +63,8 @@
67 mock_request.user.groups.all.return_value = [63 mock_request.user.groups.all.return_value = [
68 self.make_mock_group("another-group")]64 self.make_mock_group("another-group")]
69 openid_teams = ["linaro", "batman", "catwoman", "joker"]65 openid_teams = ["linaro", "batman", "catwoman", "joker"]
70 response = OpenIDAuth.process_openid_auth(mock_request, openid_teams)66 response = openid_auth.process_group_auth(mock_request, openid_teams)
71 self.assertIsNotNone(response)67 self.assertFalse(response)
72 self.assertTrue(isinstance(response, HttpResponse))
73 self.assertContains(response,
74 "You need to be the member of one of the linaro batman, catwoman "
75 "or joker teams in Launchpad.",
76 status_code=403)
7768
78 @patch("django.contrib.auth.models.Group.objects.get_or_create")69 @patch("django.contrib.auth.models.Group.objects.get_or_create")
79 def test_auto_adding_groups(self, get_or_create_mock):70 def test_auto_adding_groups(self, get_or_create_mock):
@@ -83,7 +74,7 @@
83 self.make_mock_group("another-group")]74 self.make_mock_group("another-group")]
8475
85 openid_teams = ["linaro", "linaro-infrastructure"]76 openid_teams = ["linaro", "linaro-infrastructure"]
86 OpenIDAuth.process_openid_auth(mock_request, openid_teams)77 openid_auth.process_group_auth(mock_request, openid_teams)
8778
88 expected = [79 expected = [
89 ((), {'name': 'linaro'}), ((), {'name': 'linaro-infrastructure'})]80 ((), {'name': 'linaro'}), ((), {'name': 'linaro-infrastructure'})]
9081
=== modified file 'license_protected_downloads/tests/test_splicebuildinfos.py'
--- license_protected_downloads/tests/test_splicebuildinfos.py 2013-04-19 12:13:03 +0000
+++ license_protected_downloads/tests/test_splicebuildinfos.py 2013-06-04 18:22:27 +0000
@@ -20,16 +20,16 @@
20 'test-protected.txt':20 'test-protected.txt':
21 [{'license-type': 'protected',21 [{'license-type': 'protected',
22 'build-name': 'landing-protected',22 'build-name': 'landing-protected',
23 'openid-launchpad-teams': 'linaro'}],23 'auth-groups': 'linaro'}],
24 'test-protected-2.txt':24 'test-protected-2.txt':
25 [{'license-type': 'protected',25 [{'license-type': 'protected',
26 'build-name': 'landing-protected',26 'build-name': 'landing-protected',
27 'openid-launchpad-teams': 'linaro'}]}27 'auth-groups': 'linaro'}]}
2828
29 result = {'test-protected.txt, test-protected-2.txt':29 result = {'test-protected.txt, test-protected-2.txt':
30 [{'license-type': 'protected',30 [{'license-type': 'protected',
31 'build-name': 'landing-protected',31 'build-name': 'landing-protected',
32 'openid-launchpad-teams': 'linaro'}]}32 'auth-groups': 'linaro'}]}
3333
34 build_info_res = SpliceBuildInfos.merge_duplicates(build_info_dict)34 build_info_res = SpliceBuildInfos.merge_duplicates(build_info_dict)
35 self.assertEquals(build_info_res, result)35 self.assertEquals(build_info_res, result)
3636
=== modified file 'license_protected_downloads/tests/test_views.py'
--- license_protected_downloads/tests/test_views.py 2013-04-02 09:58:42 +0000
+++ license_protected_downloads/tests/test_views.py 2013-06-04 18:22:27 +0000
@@ -2,6 +2,7 @@
22
3from django.conf import settings3from django.conf import settings
4from django.test import Client, TestCase4from django.test import Client, TestCase
5from django.http import HttpResponse
5import hashlib6import hashlib
6import os7import os
7import tempfile8import tempfile
@@ -10,6 +11,8 @@
10import urlparse11import urlparse
11import json12import json
1213
14from mock import Mock
15
13from license_protected_downloads import bzr_version16from license_protected_downloads import bzr_version
14from license_protected_downloads.buildinfo import BuildInfo17from license_protected_downloads.buildinfo import BuildInfo
15from license_protected_downloads.config import INTERNAL_HOSTS18from license_protected_downloads.config import INTERNAL_HOSTS
@@ -19,6 +22,7 @@
19from license_protected_downloads.views import _process_include_tags22from license_protected_downloads.views import _process_include_tags
20from license_protected_downloads.views import _sizeof_fmt23from license_protected_downloads.views import _sizeof_fmt
21from license_protected_downloads.views import is_same_parent_dir24from license_protected_downloads.views import is_same_parent_dir
25from license_protected_downloads import views
2226
23THIS_DIRECTORY = os.path.dirname(os.path.abspath(__file__))27THIS_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
24TESTSERVER_ROOT = os.path.join(THIS_DIRECTORY, "testserver_root")28TESTSERVER_ROOT = os.path.join(THIS_DIRECTORY, "testserver_root")
@@ -903,5 +907,19 @@
903 self.assertEqual(response.status_code, 200)907 self.assertEqual(response.status_code, 200)
904908
905909
910class ViewHelpersTests(BaseServeViewTest):
911 def test_auth_group_error(self):
912 groups = ["linaro", "batman", "catwoman", "joker"]
913 request = Mock()
914 request.path = "mock_path"
915 response = views.group_auth_failed_response(request, groups)
916 self.assertIsNotNone(response)
917 self.assertTrue(isinstance(response, HttpResponse))
918 self.assertContains(response,
919 "You need to be the member of one of the linaro batman, catwoman "
920 "or joker teams in Launchpad.",
921 status_code=403)
922
923
906if __name__ == '__main__':924if __name__ == '__main__':
907 unittest.main()925 unittest.main()
908926
=== added directory 'license_protected_downloads/tests/testserver_root/build-info/subdir2'
=== added file 'license_protected_downloads/tests/testserver_root/build-info/subdir2/BUILD-INFO.txt'
--- license_protected_downloads/tests/testserver_root/build-info/subdir2/BUILD-INFO.txt 1970-01-01 00:00:00 +0000
+++ license_protected_downloads/tests/testserver_root/build-info/subdir2/BUILD-INFO.txt 2013-06-04 18:22:27 +0000
@@ -0,0 +1,7 @@
1Format-Version: 0.5
2
3
4Files-Pattern: *
5Build-Name: landing-protected
6License-Type: protected
7Auth-Groups: linaro
08
=== added file 'license_protected_downloads/tests/testserver_root/build-info/subdir2/testfile.txt'
=== removed file 'license_protected_downloads/tests/testserver_root/build-info/write-test/BUILD-INFO.txt'
--- license_protected_downloads/tests/testserver_root/build-info/write-test/BUILD-INFO.txt 2013-04-19 12:13:03 +0000
+++ license_protected_downloads/tests/testserver_root/build-info/write-test/BUILD-INFO.txt 1970-01-01 00:00:00 +0000
@@ -1,42 +0,0 @@
1Format-Version: 0.1
2
3Files-Pattern: *openid*
4license-type: protected
5build-name: landing-protected
6openid-launchpad-teams: linaro
7
8Files-Pattern: *panda*
9license-type: open
10build-name: landing-panda
11
12Files-Pattern: *.txt
13license-type: protected
14build-name: landing-protected
15openid-launchpad-teams:
16theme: stericsson
17collect-user-data: yes
18license-text: <p>IMPORTANT — PLEASE READ THE FOLLOWING AGREEMENT CAREFULLY.</p>
19 <p>
20 THIS IS A LEGALLY BINDING AGREEMENT
21 </p>
22
23Files-Pattern: *origen*
24theme: samsung
25license-type: protected
26build-name: landing-origen
27license-text: <p>IMPORTANT — PLEASE READ THE FOLLOWING AGREEMENT CAREFULLY.</p>
28 <p>
29 THIS IS A LEGALLY BINDING AGREEMENT BETWEEN YOU, an individual or a
30 legal entity, (“LICENSEE”) AND HAL 1000.
31 </p>
32
33Files-Pattern: *snowball*
34theme: stericsson
35license-type: protected
36build-name: landing-snowball
37license-text: <p>IMPORTANT — PLEASE READ THE FOLLOWING AGREEMENT CAREFULLY.</p>
38 <p>
39 THIS IS A LEGALLY BINDING AGREEMENT BETWEEN YOU, an individual or a
40 legal entity, (“LICENSEE”) AND ME, THE TEST SERVER.
41 </p>
42
430
=== modified file 'license_protected_downloads/views.py'
--- license_protected_downloads/views.py 2013-05-09 11:15:33 +0000
+++ license_protected_downloads/views.py 2013-06-04 18:22:27 +0000
@@ -1,3 +1,4 @@
1import logging
1import glob2import glob
2import hashlib3import hashlib
3import json4import json
@@ -23,9 +24,12 @@
23from buildinfo import BuildInfo, IncorrectDataFormatException24from buildinfo import BuildInfo, IncorrectDataFormatException
24from render_text_files import RenderTextFiles25from render_text_files import RenderTextFiles
25from models import License26from models import License
26from openid_auth import OpenIDAuth27# Load group auth "plugin" dynamically
28import importlib
29group_auth = importlib.import_module(settings.GROUP_AUTH_MODULE)
27from BeautifulSoup import BeautifulSoup30from BeautifulSoup import BeautifulSoup
28import config31import config
32from group_auth_common import GroupAuthError
2933
3034
31LINARO_INCLUDE_FILE_RE = re.compile(35LINARO_INCLUDE_FILE_RE = re.compile(
@@ -33,6 +37,8 @@
33LINARO_INCLUDE_FILE_RE1 = re.compile(37LINARO_INCLUDE_FILE_RE1 = re.compile(
34 r'<linaro:include file="(?P<file_name>.*)">(.*)</linaro:include>')38 r'<linaro:include file="(?P<file_name>.*)">(.*)</linaro:include>')
3539
40log = logging.getLogger(__file__)
41
3642
37def _hidden_file(file_name):43def _hidden_file(file_name):
38 hidden_files = ["BUILD-INFO.txt", "EULA.txt", r"^\.", "HEADER.html"]44 hidden_files = ["BUILD-INFO.txt", "EULA.txt", r"^\.", "HEADER.html"]
@@ -242,7 +248,7 @@
242 license_type = build_info.get("license-type")248 license_type = build_info.get("license-type")
243 license_text = build_info.get("license-text")249 license_text = build_info.get("license-text")
244 theme = build_info.get("theme")250 theme = build_info.get("theme")
245 openid_teams = build_info.get("openid-launchpad-teams")251 auth_groups = build_info.get("auth-groups")
246 max_index = build_info.max_index252 max_index = build_info.max_index
247 elif os.path.isfile(open_eula_path):253 elif os.path.isfile(open_eula_path):
248 return "OPEN"254 return "OPEN"
@@ -256,7 +262,7 @@
256 license_type = "protected"262 license_type = "protected"
257 license_file = os.path.join(settings.PROJECT_ROOT,263 license_file = os.path.join(settings.PROJECT_ROOT,
258 'templates/licenses/' + theme + '.txt')264 'templates/licenses/' + theme + '.txt')
259 openid_teams = False265 auth_groups = False
260 with open(license_file, "r") as infile:266 with open(license_file, "r") as infile:
261 license_text = infile.read()267 license_text = infile.read()
262 elif _check_special_eula(path):268 elif _check_special_eula(path):
@@ -264,7 +270,7 @@
264 license_type = "protected"270 license_type = "protected"
265 license_file = os.path.join(settings.PROJECT_ROOT,271 license_file = os.path.join(settings.PROJECT_ROOT,
266 'templates/licenses/' + theme + '.txt')272 'templates/licenses/' + theme + '.txt')
267 openid_teams = False273 auth_groups = False
268 with open(license_file, "r") as infile:274 with open(license_file, "r") as infile:
269 license_text = infile.read()275 license_text = infile.read()
270 elif _check_special_eula(base_path + "/*"):276 elif _check_special_eula(base_path + "/*"):
@@ -279,7 +285,7 @@
279 return "OPEN"285 return "OPEN"
280286
281 # File matches a license, isn't open.287 # File matches a license, isn't open.
282 if openid_teams:288 if auth_groups:
283 return "OPEN"289 return "OPEN"
284 elif license_text:290 elif license_text:
285 for i in range(max_index):291 for i in range(max_index):
@@ -392,6 +398,30 @@
392 return response398 return response
393399
394400
401def group_auth_failed_response(request, auth_groups):
402 """Construct a nice response detailing list of auth groups that
403 will allow access to the requested file."""
404 if len(auth_groups) > 1:
405 groups_string = "one of the " + auth_groups.pop(0) + " "
406 if len(auth_groups) > 1:
407 groups_string += ", ".join(auth_groups[0:-1])
408
409 groups_string += " or " + auth_groups[-1] + " teams"
410 else:
411 groups_string = "the " + auth_groups[0] + " team"
412
413 response = render_to_response(
414 'openid_forbidden_template.html',
415 {'login': settings.LOGIN_URL + "?next=" + request.path,
416 'authenticated': request.user.is_authenticated(),
417 'openid_teams': groups_string,
418 'revno': bzr_version.get_my_bzr_revno(),
419 })
420
421 response.status_code = 403
422 return response
423
424
395def file_server(request, path):425def file_server(request, path):
396 """Serve up a file / directory listing or license page as required"""426 """Serve up a file / directory listing or license page as required"""
397 path = iri_to_uri(path)427 path = iri_to_uri(path)
@@ -415,16 +445,25 @@
415 return HttpResponseForbidden(445 return HttpResponseForbidden(
416 "Error parsing BUILD-INFO.txt")446 "Error parsing BUILD-INFO.txt")
417447
418 launchpad_teams = build_info.get("openid-launchpad-teams")448 auth_groups = build_info.get("auth-groups")
419 if launchpad_teams:449 if auth_groups:
420 launchpad_teams = launchpad_teams.split(",")450 auth_groups = auth_groups.split(",")
421 launchpad_teams = [team.strip() for team in launchpad_teams]451 auth_groups = [g.strip() for g in auth_groups]
422 # TODO: use logging!452 log.info("Checking membership in auth groups: %s", auth_groups)
423 print "Checking membership in OpenID groups:", launchpad_teams453 try:
424 openid_response = OpenIDAuth.process_openid_auth(454 response = group_auth.process_group_auth(request, auth_groups)
425 request, launchpad_teams)455 except GroupAuthError:
426 if openid_response:456 log.exception("GroupAuthError")
427 return openid_response457 response = render_to_response('group_auth_failure.html')
458 response.status_code = 500
459 return response
460
461 if response == False:
462 return group_auth_failed_response(request, auth_groups)
463 elif response == True:
464 pass
465 else:
466 return response
428467
429 if target_type == "dir":468 if target_type == "dir":
430 # Generate a link to the parent directory (if one exists)469 # Generate a link to the parent directory (if one exists)
431470
=== modified file 'settings.py'
--- settings.py 2013-02-26 19:38:30 +0000
+++ settings.py 2013-06-04 18:22:27 +0000
@@ -124,6 +124,10 @@
124LOGIN_URL = '/linaro-openid/login/'124LOGIN_URL = '/linaro-openid/login/'
125LOGIN_REDIRECT_URL = '/'125LOGIN_REDIRECT_URL = '/'
126126
127# Name of "plugin" module to use for group authentication
128GROUP_AUTH_MODULE = 'license_protected_downloads.group_auth_openid'
129
130# Config for django_openid_auth.auth.OpenIDBackend
127OPENID_CREATE_USERS = True131OPENID_CREATE_USERS = True
128OPENID_SSO_SERVER_URL = 'https://login.launchpad.net/'132OPENID_SSO_SERVER_URL = 'https://login.launchpad.net/'
129OPENID_UPDATE_DETAILS_FROM_SREG = True133OPENID_UPDATE_DETAILS_FROM_SREG = True
@@ -132,6 +136,11 @@
132OPENID_USE_AS_ADMIN_LOGIN = True136OPENID_USE_AS_ADMIN_LOGIN = True
133OPENID_USE_EMAIL_FOR_USERNAME = True137OPENID_USE_EMAIL_FOR_USERNAME = True
134138
139ATLASSIAN_CROWD_API_URL = \
140 "https://login.linaro.org:8443/crowd/rest/usermanagement/1"
141ATLASSIAN_CROWD_API_USERNAME = None
142ATLASSIAN_CROWD_API_PASSWORD = None
143
135# A sample logging configuration. The only tangible logging144# A sample logging configuration. The only tangible logging
136# performed by this configuration is to send an email to145# performed by this configuration is to send an email to
137# the site admins on every HTTP 500 error.146# the site admins on every HTTP 500 error.
@@ -144,7 +153,11 @@
144 'mail_admins': {153 'mail_admins': {
145 'level': 'ERROR',154 'level': 'ERROR',
146 'class': 'django.utils.log.AdminEmailHandler'155 'class': 'django.utils.log.AdminEmailHandler'
147 }156 },
157 'console': {
158 'level': 'DEBUG',
159 'class': 'logging.StreamHandler',
160 },
148 },161 },
149 'loggers': {162 'loggers': {
150 'django.request': {163 'django.request': {
@@ -152,6 +165,11 @@
152 'level': 'ERROR',165 'level': 'ERROR',
153 'propagate': True,166 'propagate': True,
154 },167 },
168 # Root logger
169 '': {
170 'level': 'DEBUG',
171 'handlers': ['console'],
172 },
155 }173 }
156}174}
157175
158176
=== added file 'templates/group_auth_failure.html'
--- templates/group_auth_failure.html 1970-01-01 00:00:00 +0000
+++ templates/group_auth_failure.html 2013-06-04 18:22:27 +0000
@@ -0,0 +1,10 @@
1{% extends "header.html" %}
2
3{% block content %}
4 <h1>Group Authentication Error</h1>
5 <p>
6 Unexpected group authentication error happened. Error details were logged.
7 Please contact administrator for resolution.
8 </p>
9</table>
10{% endblock %}

Subscribers

People subscribed via source and target branches