Merge lp:~sinzui/launchpad/override-blacklist-0 into lp:launchpad/db-devel

Proposed by Stuart Bishop
Status: Merged
Merged at revision: 10126
Proposed branch: lp:~sinzui/launchpad/override-blacklist-0
Merge into: lp:launchpad/db-devel
Diff against target: 650 lines (+273/-41)
15 files modified
database/schema/comments.sql (+1/-0)
database/schema/patch-2208-36-0.sql (+15/-0)
database/schema/security.cfg (+2/-2)
database/schema/trusted.sql (+56/-13)
lib/lp/registry/browser/nameblacklist.py (+3/-2)
lib/lp/registry/browser/tests/nameblacklist-views.txt (+13/-6)
lib/lp/registry/interfaces/nameblacklist.py (+13/-1)
lib/lp/registry/interfaces/person.py (+7/-2)
lib/lp/registry/model/nameblacklist.py (+6/-1)
lib/lp/registry/model/person.py (+8/-3)
lib/lp/registry/templates/nameblacklists-index.pt (+20/-0)
lib/lp/registry/tests/test_nameblacklist.py (+47/-4)
lib/lp/registry/tests/test_person.py (+14/-1)
lib/lp/services/fields/__init__.py (+4/-1)
lib/lp/services/fields/tests/test_fields.py (+64/-5)
To merge this branch: bzr merge lp:~sinzui/launchpad/override-blacklist-0
Reviewer Review Type Date Requested Status
Abel Deuring (community) code Approve
Stuart Bishop (community) db Approve
Robert Collins db Pending
Review via email: mp+45499@code.launchpad.net

This proposal supersedes a proposal from 2011-01-06.

Description of the change

Add a column to nameblacklist to specify the team that may override the
restriction.

    Launchpad bug: https://bugs.launchpad.net/bugs/276488
    Pre-implementation: jml
    Test command: ./bin/test -vv \
      -t nameblacklist -t test_fields -t test_person

Admins need to use SQL to change the name of pillar and persons because
the UI does not permit them to override the nameblacklist for approved
exceptions.

There are some names that really cannot be overridden. There are some that
admins do need to change. There are other that commericial or registy
admins could be trusted to use. We need a way to define who can override
blacklisted name.

jml and sinzui discussed the issue and decided that the blacklist admin
views should permit admins to specify the team that can override the
namebacklist. When the team is None, the nameblacklist cannot be overridden.

--------------------------------------------------------------------

RULES

    * Add a column to the nameblacklist to reference a team or is null.
      Update the functions to accept a user id to build a list of teams
      the user is in, then skip regexps when the user is in the admin team.
    * Update the NameBlacklistField to pass the current user when available.
    * Add the team field to the +nameblacklist list and the add/edit forms.

QA

    * Visit https://staging.launchpad.net/+nameblacklist
    * Verify it displays a column for admin
    * Choose to add a new expression and add ~launchpad as the admin
    * Verify you can register a project that matchs the expression
    * Update the ^launchpad expressions; add ~launchpad as the admin
    * Verify you can register a project that starts with launchpad.

LINT

    database/schema/comments.sql
    database/schema/patch-2208-97-0.sql
    database/schema/security.cfg
    database/schema/trusted.sql
    lib/lp/registry/browser/nameblacklist.py
    lib/lp/registry/browser/tests/nameblacklist-views.txt
    lib/lp/registry/interfaces/nameblacklist.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/nameblacklist.py
    lib/lp/registry/model/person.py
    lib/lp/registry/templates/nameblacklists-index.pt
    lib/lp/registry/tests/test_nameblacklist.py
    lib/lp/registry/tests/test_person.py
    lib/lp/services/fields/__init__.py
    lib/lp/services/fields/tests/test_fields.py

IMPLEMENTATION

Added the nameblacklist.admin column and updated the functions to use it.
Updated the interface and model too.
    database/schema/comments.sql
    database/schema/patch-2208-97-0.sql
    database/schema/security.cfg
    database/schema/trusted.sql
    lib/lp/registry/interfaces/nameblacklist.py
    lib/lp/registry/model/nameblacklist.py
    lib/lp/registry/tests/test_nameblacklist.py

Updated the code to pass the current user to when available when working
the the nameblacklist.
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_person.py
    lib/lp/services/fields/__init__.py
    lib/lp/services/fields/tests/test_fields.py

Added nameblacklist.admin to the UI
    lib/lp/registry/browser/nameblacklist.py
    lib/lp/registry/browser/tests/nameblacklist-views.txt
    lib/lp/registry/templates/nameblacklists-index.pt

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

Database patch needs an index:

CREATE INDEX nameblacklist__admin__idx ON NameBlackList(admin);

I don't really like that there is no warning when you create something with a blacklisted name that you are allowed to override. I'm not sure if a warning is desirable enough to complicate the UI.

Should the admins team in Launchpad be allowed ignore the blacklist entirely? Membership in this team needs to be checked for explicitly.

Here is a much more efficient version of the stored procedure that also performs the admin check:

CREATE OR REPLACE FUNCTION name_blacklist_match(text, integer) RETURNS int4
LANGUAGE plpythonu STABLE RETURNS NULL ON NULL INPUT
EXTERNAL SECURITY DEFINER SET search_path TO public AS
$$
    import re
    name = args[0].decode("UTF-8")
    user_id = args[1]

    # Initialize shared storage, shared between invocations.
    if not SD.has_key("regexp_select_plan"):

        # All the blacklist regexps except the ones we are an admin
        # for. These we do not check since they are not blacklisted to us.
        SD["regexp_select_plan"] = plpy.prepare("""
            SELECT id, regexp FROM NameBlacklist
            WHERE admin IS NULL OR admin NOT IN (
                SELECT team FROM TeamParticipation
                WHERE person = $1)
            ORDER BY id
            """, ["integer"])

        # Storage for compiled regexps
        SD["compiled"] = {}

        # admins is a celebrity and its id is immutable.
        admins_id = plpy.execute(
            "SELECT id FROM Person WHERE name='admins'")[0]["id"]

        SD["admin_select_plan"] = plpy.prepare("""
            SELECT TRUE FROM TeamParticipation
            WHERE
                TeamParticipation.team = %d
                AND TeamParticipation.person = $1
            LIMIT 1
            """ % admins_id, ["integer"])

    # Names are never blacklisted for admins.
    if user_id is not None and plpy.execute(
        SD["admin_select_plan"], [user_id]).nrows() > 0:
        return None

    compiled = SD["compiled"]

    for row in plpy.execute(SD["regexp_select_plan"], [user_id]):
        regexp_id = row["id"]
        regexp_txt = row["regexp"]
        if (compiled.get(regexp_id) is None
            or compiled[regexp_id][0] != regexp_txt):
            regexp = re.compile(
                regexp_txt, re.IGNORECASE | re.UNICODE | re.VERBOSE
                )
            compiled[regexp_id] = (regexp_txt, regexp)
        else:
            regexp = compiled[regexp_id][1]
        if regexp.search(name) is not None:
            return regexp_id
    return None
$$;

review: Approve (db)
Revision history for this message
Stuart Bishop (stub) wrote :

patch-2208-36-0.sql

Revision history for this message
Abel Deuring (adeuring) wrote :

Hi Sinzui,

the code changes looks good to me.

But I share Stuart's concern that persons registered as a nameblacklist.admin do not get the slightest hint that a new project name is blackisted. My concern i not that much about the LOSAs If they are asked to set a project name to a blacklisted value, we can be sure that the request explains the reason why the blacklist should be overridden. And the LOSAs did prove that they work very carefully, so I am confident that they check if the request makes sense. (But they might appreciate display of the affected blackist record's regex value and comment so they can easily see why a given name is blacklisted.)

But if we use the option to appoint arbitrary persons as nameblacklist.admin, things might become messy. Let's assume that somebody is the admin for two nameblacklist records, where the two records blacklist names related to two different project groups. If this person now creates a new product for group A, but the name is blacklisted to prevent "stealing" names reserved for group B, they do not get the slightest hint that they are making an error. (I would probably be the first one to walk into this trap ;)

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Abel, Stuart.

Thank you for the review. I am going to update the UI of the forms to clarify when a team can be set as the admin of an regexp. I think I can update the name change forms to add a notice when the name was changed and it matches a blacklisted expression (call the isBlacklistedName() method without the user).

There are two kinds of blacklisted names: Those for because the application reserves the name, and those that are reserved by organisations. No one can ever be permitted to override an application restriction. The LOSA want this feature because they fear they will do that using SQL. They want a way to override organisational names...

...Stuart, I cannot accept your rewrite of name_blacklist_match because it breaks tests. It allows admins to override application blacklisted names. The SQL is hardcoded to give ~admins the power, but the intent is to assign a team to do the act. A rule without an admin team can never be overridden. Very few rules will permit someone to override them, I know of four rules:
  private: ~commercial-admins
  canonical: ~admin
  launchpad: ~admin
  ubuntuone: ~admin

We may ~registry instead of ~admin, or assign the names to their respective teams (~launchpad and ~canonical). This approach allows organisations to buy namespaces and create names without complaining to Launchpad staff.

I think the admin_select_plan wrongly uses ~admin instead of the team in the nameblacklisttable. I will attempt to revise the SQL to do the right thing.

...
        # admins is a celebrity and its id is immutable.
        admins_id = plpy.execute(
            "SELECT id FROM Person WHERE name='admins'")[0]["id"]

        SD["admin_select_plan"] = plpy.prepare("""
            SELECT TRUE FROM TeamParticipation
            WHERE
                TeamParticipation.team = %d
                AND TeamParticipation.person = $1
            LIMIT 1
            """ % admins_id, ["integer"])
...

    # Names are never blacklisted for admins.
    if user_id is not None and plpy.execute(
        SD["admin_select_plan"], [user_id]).nrows() > 0:
        return None

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Stuart.

I propose small modification to your script. I remove the admin_select_plan since it gave unchecked power to admins. I added a guard for user_id to set the value to zero if it is NULL so that your revised regexp_select_plan always works.

CREATE OR REPLACE FUNCTION name_blacklist_match(text, integer) RETURNS int4
LANGUAGE plpythonu STABLE RETURNS NULL ON NULL INPUT
EXTERNAL SECURITY DEFINER SET search_path TO public AS
$$
    import re
    name = args[0].decode("UTF-8")
    user_id = args[1]
    if user_id is None:
        # Ids cannot be NULL. Zero will force every repexp rule to apply.
        user_id = 0

    # Initialize shared storage, shared between invocations.
    if not SD.has_key("regexp_select_plan"):

        # All the blacklist regexps except the ones we are an admin
        # for. These we do not check since they are not blacklisted to us.
        SD["regexp_select_plan"] = plpy.prepare("""
            SELECT id, regexp FROM NameBlacklist
            WHERE admin IS NULL OR admin NOT IN (
                SELECT team FROM TeamParticipation
                WHERE person = $1)
            ORDER BY id
            """, ["integer"])

        # Storage for compiled regexps
        SD["compiled"] = {}

    compiled = SD["compiled"]

    for row in plpy.execute(SD["regexp_select_plan"], [user_id]):
        regexp_id = row["id"]
        regexp_txt = row["regexp"]
        if (compiled.get(regexp_id) is None
            or compiled[regexp_id][0] != regexp_txt):
            regexp = re.compile(
                regexp_txt, re.IGNORECASE | re.UNICODE | re.VERBOSE
                )
            compiled[regexp_id] = (regexp_txt, regexp)
        else:
            regexp = compiled[regexp_id][1]
        if regexp.search(name) is not None:
            return regexp_id
    return None
$$;

Revision history for this message
Stuart Bishop (stub) wrote :

The guard for user_id shouldn't be needed; "WHERE person = 0" will give the same results as "WHERE person = NULL" (the former evaluates to FALSE for every row, the latter to NULL for every row, but both filter all rows in this context). It makes no practical difference so whatever you feel is clearer.

By removing privileges from a group of admins (registry or admin or both), will we end up with cases where something private cannot be created because there is no overlap between the set of people who can can create the object with a blacklisted name and the set of people who can create private objects?

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Stuart.

I see you point. I removed the guard. I updated the function to permit Lp admins
an exemption for all regexps that have the admin column assigned. This change
restores most of your performance suggestion.

=== modified file 'database/schema/trusted.sql'
--- database/schema/trusted.sql 2011-01-10 18:27:20 +0000
+++ database/schema/trusted.sql 2011-01-10 19:24:26 +0000
@@ -765,9 +765,37 @@
         # Storage for compiled regexps
         SD["compiled"] = {}

+ # admins is a celebrity and its id is immutable.
+ admins_id = plpy.execute(
+ "SELECT id FROM Person WHERE name='admins'")[0]["id"]
+
+ SD["admin_select_plan"] = plpy.prepare("""
+ SELECT TRUE FROM TeamParticipation
+ WHERE
+ TeamParticipation.team = %d
+ AND TeamParticipation.person = $1
+ LIMIT 1
+ """ % admins_id, ["integer"])
+
+ # All the blacklist regexps except those that have an admin because
+ # members of ~admin can use any name that any other admin can use.
+ SD["admin_regexp_select_plan"] = plpy.prepare("""
+ SELECT id, regexp FROM NameBlacklist
+ WHERE admin IS NULL
+ ORDER BY id
+ """, ["integer"])
+
+
     compiled = SD["compiled"]

- for row in plpy.execute(SD["regexp_select_plan"], [user_id]):
+ # Names are never blacklisted for Lauchpad admins.
+ if user_id is not None and plpy.execute(
+ SD["admin_select_plan"], [user_id]).nrows() > 0:
+ blacklist_plan = "admin_regexp_select_plan"
+ else:
+ blacklist_plan = "regexp_select_plan"
+
+ for row in plpy.execute(SD[blacklist_plan], [user_id]):

Revision history for this message
Stuart Bishop (stub) wrote :

All looking good.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/comments.sql'
2--- database/schema/comments.sql 2010-12-21 00:32:34 +0000
3+++ database/schema/comments.sql 2011-01-10 19:41:16 +0000
4@@ -2182,6 +2182,7 @@
5 COMMENT ON TABLE NameBlacklist IS 'A list of regular expressions used to blacklist names.';
6 COMMENT ON COLUMN NameBlacklist.regexp IS 'A Python regular expression. It will be compiled with the IGNORECASE, UNICODE and VERBOSE flags. The Python search method will be used rather than match, so ^ markers should be used to indicate the start of a string.';
7 COMMENT ON COLUMN NameBlacklist.comment IS 'An optional comment on why this regexp was entered. It should not be displayed to non-admins and its only purpose is documentation.';
8+COMMENT ON COLUMN NameBlacklist.admin IS 'The person who can override the blacklisted name.';
9
10 -- ScriptActivity
11 COMMENT ON TABLE ScriptActivity IS 'Records of successful runs of scripts ';
12
13=== added file 'database/schema/patch-2208-36-0.sql'
14--- database/schema/patch-2208-36-0.sql 1970-01-01 00:00:00 +0000
15+++ database/schema/patch-2208-36-0.sql 2011-01-10 19:41:16 +0000
16@@ -0,0 +1,15 @@
17+-- Copyright 2011 Canonical Ltd. This software is licensed under the
18+-- GNU Affero General Public License version 3 (see the file LICENSE).
19+SET client_min_messages=ERROR;
20+
21+-- Add a column that identifies the person or team that is exempt from
22+-- the regexp check.
23+ALTER TABLE NameBlacklist
24+ ADD COLUMN admin
25+ INTEGER,
26+ ADD CONSTRAINT nameblacklist_admin_fk FOREIGN KEY ("admin")
27+ REFERENCES person (id) MATCH SIMPLE
28+ ON UPDATE NO ACTION ON DELETE NO ACTION;
29+
30+
31+INSERT INTO LaunchpadDatabaseRevision VALUES (2208, 36, 0);
32
33=== modified file 'database/schema/security.cfg'
34--- database/schema/security.cfg 2010-12-02 16:15:46 +0000
35+++ database/schema/security.cfg 2011-01-10 19:41:16 +0000
36@@ -31,13 +31,13 @@
37 public.valid_regexp(text) = EXECUTE
38 public.sane_version(text) = EXECUTE
39 public.sha1(text) = EXECUTE
40-public.is_blacklisted_name(text) = EXECUTE
41+public.is_blacklisted_name(text, integer) = EXECUTE
42 public.is_person(text) = EXECUTE
43 public.is_team(integer) = EXECUTE
44 public.is_team(text) = EXECUTE
45 public.is_printable_ascii(text) = EXECUTE
46 public.launchpaddatabaserevision = SELECT
47-public.name_blacklist_match(text) = EXECUTE
48+public.name_blacklist_match(text, integer) = EXECUTE
49 public.pillarname = SELECT
50 public.ulower(text) = EXECUTE
51 public.generate_openid_identifier() = EXECUTE
52
53=== modified file 'database/schema/trusted.sql'
54--- database/schema/trusted.sql 2010-11-17 10:59:19 +0000
55+++ database/schema/trusted.sql 2011-01-10 19:41:16 +0000
56@@ -741,19 +741,61 @@
57 COMMENT ON FUNCTION debversion_sort_key(text) IS 'Return a string suitable for sorting debian version strings on';
58
59
60-CREATE OR REPLACE FUNCTION name_blacklist_match(text) RETURNS int4
61+CREATE OR REPLACE FUNCTION name_blacklist_match(text, integer) RETURNS int4
62 LANGUAGE plpythonu STABLE RETURNS NULL ON NULL INPUT
63-EXTERNAL SECURITY DEFINER AS
64+EXTERNAL SECURITY DEFINER SET search_path TO public AS
65 $$
66 import re
67 name = args[0].decode("UTF-8")
68- if not SD.has_key("select_plan"):
69- SD["select_plan"] = plpy.prepare("""
70- SELECT id, regexp FROM NameBlacklist ORDER BY id
71- """)
72+ user_id = args[1]
73+
74+ # Initialize shared storage, shared between invocations.
75+ if not SD.has_key("regexp_select_plan"):
76+
77+ # All the blacklist regexps except the ones we are an admin
78+ # for. These we do not check since they are not blacklisted to us.
79+ SD["regexp_select_plan"] = plpy.prepare("""
80+ SELECT id, regexp FROM NameBlacklist
81+ WHERE admin IS NULL OR admin NOT IN (
82+ SELECT team FROM TeamParticipation
83+ WHERE person = $1)
84+ ORDER BY id
85+ """, ["integer"])
86+
87+ # Storage for compiled regexps
88 SD["compiled"] = {}
89+
90+ # admins is a celebrity and its id is immutable.
91+ admins_id = plpy.execute(
92+ "SELECT id FROM Person WHERE name='admins'")[0]["id"]
93+
94+ SD["admin_select_plan"] = plpy.prepare("""
95+ SELECT TRUE FROM TeamParticipation
96+ WHERE
97+ TeamParticipation.team = %d
98+ AND TeamParticipation.person = $1
99+ LIMIT 1
100+ """ % admins_id, ["integer"])
101+
102+ # All the blacklist regexps except those that have an admin because
103+ # members of ~admin can use any name that any other admin can use.
104+ SD["admin_regexp_select_plan"] = plpy.prepare("""
105+ SELECT id, regexp FROM NameBlacklist
106+ WHERE admin IS NULL
107+ ORDER BY id
108+ """, ["integer"])
109+
110+
111 compiled = SD["compiled"]
112- for row in plpy.execute(SD["select_plan"]):
113+
114+ # Names are never blacklisted for Lauchpad admins.
115+ if user_id is not None and plpy.execute(
116+ SD["admin_select_plan"], [user_id]).nrows() > 0:
117+ blacklist_plan = "admin_regexp_select_plan"
118+ else:
119+ blacklist_plan = "regexp_select_plan"
120+
121+ for row in plpy.execute(SD[blacklist_plan], [user_id]):
122 regexp_id = row["id"]
123 regexp_txt = row["regexp"]
124 if (compiled.get(regexp_id) is None
125@@ -769,16 +811,17 @@
126 return None
127 $$;
128
129-COMMENT ON FUNCTION name_blacklist_match(text) IS 'Return the id of the row in the NameBlacklist table that matches the given name, or NULL if no regexps in the NameBlacklist table match.';
130-
131-
132-CREATE OR REPLACE FUNCTION is_blacklisted_name(text) RETURNS boolean
133+COMMENT ON FUNCTION name_blacklist_match(text, integer) IS 'Return the id of the row in the NameBlacklist table that matches the given name, or NULL if no regexps in the NameBlacklist table match.';
134+
135+
136+CREATE OR REPLACE FUNCTION is_blacklisted_name(text, integer)
137+RETURNS boolean
138 LANGUAGE SQL STABLE RETURNS NULL ON NULL INPUT EXTERNAL SECURITY DEFINER AS
139 $$
140- SELECT COALESCE(name_blacklist_match($1)::boolean, FALSE);
141+ SELECT COALESCE(name_blacklist_match($1, $2)::boolean, FALSE);
142 $$;
143
144-COMMENT ON FUNCTION is_blacklisted_name(text) IS 'Return TRUE if any regular expressions stored in the NameBlacklist table match the givenname, otherwise return FALSE.';
145+COMMENT ON FUNCTION is_blacklisted_name(text, integer) IS 'Return TRUE if any regular expressions stored in the NameBlacklist table match the givenname, otherwise return FALSE.';
146
147
148 CREATE OR REPLACE FUNCTION set_shipit_normalized_address() RETURNS trigger
149
150=== modified file 'lib/lp/registry/browser/nameblacklist.py'
151--- lib/lp/registry/browser/nameblacklist.py 2010-11-30 20:34:31 +0000
152+++ lib/lp/registry/browser/nameblacklist.py 2011-01-10 19:41:16 +0000
153@@ -73,7 +73,7 @@
154 """View for editing a blacklist expression."""
155
156 schema = INameBlacklist
157- field_names = ['regexp', 'comment']
158+ field_names = ['regexp', 'admin', 'comment']
159 label = "Edit a blacklist expression"
160 page_title = label
161
162@@ -88,7 +88,7 @@
163 """View for adding a blacklist expression."""
164
165 schema = INameBlacklist
166- field_names = ['regexp', 'comment']
167+ field_names = ['regexp', 'admin', 'comment']
168 label = "Add a new blacklist expression"
169 page_title = label
170
171@@ -107,6 +107,7 @@
172 name_blacklist_set.create(
173 regexp=data['regexp'],
174 comment=data['comment'],
175+ admin=data['admin'],
176 )
177 self.request.response.addInfoNotification(
178 'Regular expression "%s" has been added to the name blacklist.'
179
180=== modified file 'lib/lp/registry/browser/tests/nameblacklist-views.txt'
181--- lib/lp/registry/browser/tests/nameblacklist-views.txt 2010-11-09 05:15:03 +0000
182+++ lib/lp/registry/browser/tests/nameblacklist-views.txt 2011-01-10 19:41:16 +0000
183@@ -25,9 +25,9 @@
184 >>> view = create_initialized_view(name_blacklist_set, '+index',
185 ... principal=registry_expert)
186 >>> print extract_text(find_tag_by_id(view.render(), 'blacklist'))
187- Regular Expression Comment
188- ^admin Edit blacklist expression
189- blacklist Edit blacklist expression For testing purposes
190+ Regular Expression Admin Comment
191+ ^admin Edit blacklist expression —
192+ blacklist Edit blacklist expression — For testing purposes
193
194
195 Add expression to blacklist
196@@ -37,6 +37,7 @@
197
198 >>> form = {
199 ... 'field.regexp': u'(',
200+ ... 'field.admin': registry_experts.name,
201 ... 'field.comment': u'old-comment',
202 ... 'field.actions.add': 'Add to blacklist',
203 ... }
204@@ -61,17 +62,23 @@
205 ... print notification.message
206 Regular expression "foo" has been added to the name blacklist.
207
208+ >>> import transaction
209+ >>> transaction.commit()
210+ >>> foo_exp = name_blacklist_set.getByRegExp(u'foo')
211+ >>> print foo_exp.regexp
212+ foo
213+ >>> print foo_exp.admin.name
214+ registry
215+
216
217 Edit expression in blacklist
218 ----------------------------
219
220 When a regular expression is edited, it still must be valid.
221
222- >>> import transaction
223- >>> transaction.commit()
224- >>> foo_exp = name_blacklist_set.getByRegExp(u'foo')
225 >>> form = {
226 ... 'field.regexp': u'(',
227+ ... 'field.admin': registry_experts.name,
228 ... 'field.comment': u'new-comment',
229 ... 'field.actions.change': 'Change',
230 ... }
231
232=== modified file 'lib/lp/registry/interfaces/nameblacklist.py'
233--- lib/lp/registry/interfaces/nameblacklist.py 2010-11-08 21:05:19 +0000
234+++ lib/lp/registry/interfaces/nameblacklist.py 2011-01-10 19:41:16 +0000
235@@ -13,6 +13,7 @@
236 from zope.interface import Interface
237 from zope.schema import (
238 Int,
239+ Choice,
240 Text,
241 TextLine,
242 )
243@@ -25,7 +26,18 @@
244
245 id = Int(title=_('ID'), required=True, readonly=True)
246 regexp = TextLine(title=_('Regular expression'), required=True)
247- comment = Text(title=_('Comment'), required=False)
248+ comment = Text(
249+ title=_('Comment'),
250+ description=_(
251+ "Why is the name blacklisted? Does the namespace belong to an "
252+ "organization or is the namespace reserved by the application?"),
253+ required=False)
254+ admin = Choice(
255+ title=_('Admin'),
256+ description=_(
257+ "The team that is exempt from this restriction because it "
258+ "administers this namespace for an organisation."),
259+ vocabulary='ValidPersonOrTeam', required=False)
260
261
262 class INameBlacklistSet(Interface):
263
264=== modified file 'lib/lp/registry/interfaces/person.py'
265--- lib/lp/registry/interfaces/person.py 2011-01-03 21:14:17 +0000
266+++ lib/lp/registry/interfaces/person.py 2011-01-10 19:41:16 +0000
267@@ -1865,8 +1865,13 @@
268 def getTopContributors(limit=50):
269 """Return the top contributors in Launchpad, up to the given limit."""
270
271- def isNameBlacklisted(name):
272- """Is the given name blacklisted by Launchpad Administrators?"""
273+ def isNameBlacklisted(name, user=None):
274+ """Is the given name blacklisted by Launchpad Administrators?
275+
276+ :param name: The name to be checked.
277+ :param user: The `IPerson` that wants to use the name. If the user
278+ is an admin for the nameblacklist expression, he can use the name.
279+ """
280
281 def createPersonAndEmail(
282 email, rationale, comment=None, name=None, displayname=None,
283
284=== modified file 'lib/lp/registry/model/nameblacklist.py'
285--- lib/lp/registry/model/nameblacklist.py 2010-11-11 16:06:24 +0000
286+++ lib/lp/registry/model/nameblacklist.py 2011-01-10 19:41:16 +0000
287@@ -13,6 +13,7 @@
288 from storm.base import Storm
289 from storm.locals import (
290 Int,
291+ Reference,
292 Unicode,
293 )
294 from zope.interface import implements
295@@ -22,6 +23,7 @@
296 INameBlacklist,
297 INameBlacklistSet,
298 )
299+from lp.registry.model.person import Person
300
301
302 class NameBlacklist(Storm):
303@@ -34,6 +36,8 @@
304 id = Int(primary=True)
305 regexp = Unicode(name='regexp', allow_none=False)
306 comment = Unicode(name='comment', allow_none=True)
307+ admin_id = Int(name='admin', allow_none=True)
308+ admin = Reference(admin_id, Person.id)
309
310
311 class NameBlacklistSet:
312@@ -46,11 +50,12 @@
313 store = IStore(NameBlacklist)
314 return store.find(NameBlacklist).order_by(NameBlacklist.regexp)
315
316- def create(self, regexp, comment=None):
317+ def create(self, regexp, comment=None, admin=None):
318 """See `INameBlacklistSet`."""
319 nameblacklist = NameBlacklist()
320 nameblacklist.regexp = regexp
321 nameblacklist.comment = comment
322+ nameblacklist.admin = admin
323 store = IStore(NameBlacklist)
324 store.add(nameblacklist)
325 return nameblacklist
326
327=== modified file 'lib/lp/registry/model/person.py'
328--- lib/lp/registry/model/person.py 2011-01-04 23:32:39 +0000
329+++ lib/lp/registry/model/person.py 2011-01-10 19:41:16 +0000
330@@ -2764,11 +2764,16 @@
331 def __init__(self):
332 self.title = 'People registered with Launchpad'
333
334- def isNameBlacklisted(self, name):
335+ def isNameBlacklisted(self, name, user=None):
336 """See `IPersonSet`."""
337+ if user is None:
338+ user_id = 0
339+ else:
340+ user_id = user.id
341 cur = cursor()
342- cur.execute("SELECT is_blacklisted_name(%(name)s)" % sqlvalues(
343- name=name.encode('UTF-8')))
344+ cur.execute(
345+ "SELECT is_blacklisted_name(%(name)s, %(user_id)s)" % sqlvalues(
346+ name=name.encode('UTF-8'), user_id=user_id))
347 return bool(cur.fetchone()[0])
348
349 def getTopContributors(self, limit=50):
350
351=== modified file 'lib/lp/registry/templates/nameblacklists-index.pt'
352--- lib/lp/registry/templates/nameblacklists-index.pt 2010-11-11 16:18:29 +0000
353+++ lib/lp/registry/templates/nameblacklists-index.pt 2011-01-10 19:41:16 +0000
354@@ -16,9 +16,23 @@
355 </tal:side>
356
357 <div metal:fill-slot="main" class="main-portlet">
358+ <p>
359+ There are two kinds of blacklisted names:
360+ </p>
361+ <ul class="bulleted">
362+ <li>
363+ Organisational blackisted names have an administering team
364+ that is permitted to use the name for projects and teams.
365+ </li>
366+ <li>
367+ Application blackisted names cannot have an administering team.
368+ No one can to override them.
369+ </li>
370+ </ul>
371 <table id="blacklist" class="listing sortable">
372 <thead>
373 <th>Regular Expression</th>
374+ <th>Admin</th>
375 <th>Comment</th>
376 </thead>
377 <tbody>
378@@ -29,6 +43,12 @@
379 structure
380 item/menu:overview/edit_blacklist_expression/fmt:icon"/>
381 </td>
382+ <td>
383+ <a
384+ tal:condition="item/admin"
385+ tal:replace="structure item/admin/fmt:link" />
386+ <tal:none condition="not: item/admin">&mdash;</tal:none>
387+ </td>
388 <td tal:content="item/comment"/>
389 </tr>
390 </tbody>
391
392=== modified file 'lib/lp/registry/tests/test_nameblacklist.py'
393--- lib/lp/registry/tests/test_nameblacklist.py 2010-11-13 15:49:06 +0000
394+++ lib/lp/registry/tests/test_nameblacklist.py 2011-01-10 19:41:16 +0000
395@@ -9,6 +9,7 @@
396 from zope.component import getUtility
397 from zope.interface.verify import verifyObject
398
399+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
400 from canonical.launchpad.interfaces.lpstorm import IStore
401 from canonical.launchpad.webapp.authorization import check_permission
402 from canonical.testing.layers import (
403@@ -36,20 +37,24 @@
404 self.caret_foo_exp = self.name_blacklist_set.create(u'^foo')
405 self.foo_exp = self.name_blacklist_set.create(u'foo')
406 self.verbose_exp = self.name_blacklist_set.create(u'v e r b o s e')
407+ team = self.factory.makeTeam()
408+ self.admin_exp = self.name_blacklist_set.create(u'fnord', admin=team)
409 self.store = IStore(self.foo_exp)
410 self.store.flush()
411
412- def name_blacklist_match(self, name):
413+ def name_blacklist_match(self, name, user_id=None):
414 '''Return the result of the name_blacklist_match stored procedure.'''
415+ user_id = user_id or 0
416 result = self.store.execute(
417- "SELECT name_blacklist_match(%s)", (name,))
418+ "SELECT name_blacklist_match(%s, %s)", (name, user_id))
419 return result.get_one()[0]
420
421- def is_blacklisted_name(self, name):
422+ def is_blacklisted_name(self, name, user_id=None):
423 '''Call the is_blacklisted_name stored procedure and return the result
424 '''
425+ user_id = user_id or 0
426 result = self.store.execute(
427- "SELECT is_blacklisted_name(%s)", (name,))
428+ "SELECT is_blacklisted_name(%s, %s)", (name, user_id))
429 blacklisted = result.get_one()[0]
430 self.failIf(blacklisted is None, 'is_blacklisted_name returned NULL')
431 return bool(blacklisted)
432@@ -69,6 +74,30 @@
433 self.name_blacklist_match(u"barfoo"),
434 self.foo_exp.id)
435
436+ def test_name_blacklist_match_admin_does_not_match(self):
437+ # A user in the expresssion's admin team is exempt from the
438+ # backlisted name restriction.
439+ user = self.admin_exp.admin.teamowner
440+ self.assertEqual(
441+ None, self.name_blacklist_match(u"fnord", user.id))
442+
443+ def test_name_blacklist_match_launchpad_admin_can_change(self):
444+ # A Launchpad admin is exempt from any backlisted name restriction
445+ # that has an admin.
446+ user = self.factory.makePerson()
447+ admins = getUtility(ILaunchpadCelebrities).admin
448+ admins.addMember(user, user)
449+ self.assertEqual(
450+ None, self.name_blacklist_match(u"fnord", user.id))
451+
452+ def test_name_blacklist_match_launchpad_admin_cannot_change(self):
453+ # A Launchpad admin cannot override backlisted names without admins.
454+ user = self.factory.makePerson()
455+ admins = getUtility(ILaunchpadCelebrities).admin
456+ admins.addMember(user, user)
457+ self.assertEqual(
458+ self.foo_exp.id, self.name_blacklist_match(u"barfoo", user.id))
459+
460 def test_name_blacklist_match_cache(self):
461 # If the blacklist is changed in the DB, these changes are noticed.
462 # This test is needed because the stored procedure keeps a cache
463@@ -92,6 +121,11 @@
464 self.foo_exp.regexp = u'bar2'
465 self.failUnless(self.is_blacklisted_name(u"foo") is False)
466
467+ def test_is_blacklisted_name_admin_false(self):
468+ # Users in the expression's admin team are will return False.
469+ user = self.admin_exp.admin.teamowner
470+ self.assertFalse(self.is_blacklisted_name(u"fnord", user.id))
471+
472 def test_case_insensitive(self):
473 self.failUnless(self.is_blacklisted_name(u"Foo") is True)
474
475@@ -123,6 +157,15 @@
476 self.assertEqual(u'foo', name_blacklist.regexp)
477 self.assertEqual(u'bar', name_blacklist.comment)
478
479+ def test_create_with_three_args(self):
480+ # Test NameBlacklistSet.create(regexp, comment, admin).
481+ team = self.factory.makeTeam()
482+ name_blacklist = self.name_blacklist_set.create(u'foo', u'bar', team)
483+ self.assertTrue(verifyObject(INameBlacklist, name_blacklist))
484+ self.assertEqual(u'foo', name_blacklist.regexp)
485+ self.assertEqual(u'bar', name_blacklist.comment)
486+ self.assertEqual(team, name_blacklist.admin)
487+
488 def test_get_int(self):
489 # Test NameBlacklistSet.get() with int id.
490 name_blacklist = self.name_blacklist_set.create(u'foo', u'bar')
491
492=== modified file 'lib/lp/registry/tests/test_person.py'
493--- lib/lp/registry/tests/test_person.py 2010-12-10 15:06:12 +0000
494+++ lib/lp/registry/tests/test_person.py 2011-01-10 19:41:16 +0000
495@@ -31,7 +31,10 @@
496 InvalidEmailAddress,
497 )
498 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
499-from canonical.launchpad.interfaces.lpstorm import IMasterStore
500+from canonical.launchpad.interfaces.lpstorm import (
501+ IMasterStore,
502+ IStore,
503+ )
504 from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller
505 from canonical.testing.layers import (
506 DatabaseFunctionalLayer,
507@@ -47,6 +50,7 @@
508 PrivatePersonLinkageError,
509 )
510 from lp.registry.interfaces.karma import IKarmaCacheManager
511+from lp.registry.interfaces.nameblacklist import INameBlacklistSet
512 from lp.registry.interfaces.person import (
513 ImmutableVisibilityError,
514 InvalidName,
515@@ -445,6 +449,15 @@
516 self.failUnless(self.person_set.isNameBlacklisted('foo'))
517 self.failIf(self.person_set.isNameBlacklisted('bar'))
518
519+ def test_isNameBlacklisted_user_is_admin(self):
520+ team = self.factory.makeTeam()
521+ name_blacklist_set = getUtility(INameBlacklistSet)
522+ self.admin_exp = name_blacklist_set.create(u'fnord', admin=team)
523+ self.store = IStore(self.admin_exp)
524+ self.store.flush()
525+ user = team.teamowner
526+ self.assertFalse(self.person_set.isNameBlacklisted('fnord', user))
527+
528 def test_getByEmail_ignores_case_and_whitespace(self):
529 person1_email = 'foo.bar@canonical.com'
530 person1 = self.person_set.getByEmail(person1_email)
531
532=== modified file 'lib/lp/services/fields/__init__.py'
533--- lib/lp/services/fields/__init__.py 2010-12-20 17:42:47 +0000
534+++ lib/lp/services/fields/__init__.py 2011-01-10 19:41:16 +0000
535@@ -104,8 +104,10 @@
536 name_validator,
537 valid_name,
538 )
539+from canonical.launchpad.webapp.interfaces import ILaunchBag
540 from lp.registry.interfaces.pillar import IPillarNameSet
541
542+
543 # Marker object to tell BaseImageUpload to keep the existing image.
544 KEEP_SAME_IMAGE = object()
545
546@@ -502,7 +504,8 @@
547
548 # Need a local import because of circular dependencies.
549 from lp.registry.interfaces.person import IPersonSet
550- if getUtility(IPersonSet).isNameBlacklisted(input):
551+ user = getUtility(ILaunchBag).user
552+ if getUtility(IPersonSet).isNameBlacklisted(input, user):
553 raise LaunchpadValidationError(
554 "The name '%s' has been blocked by the Launchpad "
555 "administrators." % input)
556
557=== modified file 'lib/lp/services/fields/tests/test_fields.py'
558--- lib/lp/services/fields/tests/test_fields.py 2010-08-20 20:31:18 +0000
559+++ lib/lp/services/fields/tests/test_fields.py 2011-01-10 19:41:16 +0000
560@@ -7,20 +7,32 @@
561
562 import datetime
563 import time
564-import unittest
565-
566+
567+from zope.interface import Interface
568+from zope.component import getUtility
569+
570+from canonical.launchpad.interfaces.lpstorm import IStore
571 from canonical.launchpad.validators import LaunchpadValidationError
572+from canonical.testing.layers import DatabaseFunctionalLayer
573 from lp.services.fields import (
574+ BlacklistableContentNameField,
575 FormattableDate,
576 StrippableText,
577 )
578-from lp.testing import TestCase
579+from lp.registry.interfaces.nameblacklist import INameBlacklistSet
580+from lp.testing import (
581+ login_person,
582+ TestCase,
583+ TestCaseWithFactory,
584+ )
585
586
587 def make_target():
588 """Make a trivial object to be a target of the field setting."""
589+
590 class Simple:
591 """A simple class to test setting fields on."""
592+
593 return Simple()
594
595
596@@ -67,5 +79,52 @@
597 self.assertIs(None, target.test)
598
599
600-def test_suite():
601- return unittest.TestLoader().loadTestsFromName(__name__)
602+class TestBlacklistableContentNameField(TestCaseWithFactory):
603+
604+ layer = DatabaseFunctionalLayer
605+
606+ def setUp(self):
607+ super(TestBlacklistableContentNameField, self).setUp()
608+ name_blacklist_set = getUtility(INameBlacklistSet)
609+ self.team = self.factory.makeTeam()
610+ admin_exp = name_blacklist_set.create(u'fnord', admin=self.team)
611+ IStore(admin_exp).flush()
612+
613+ def makeTestField(self):
614+ """Return testible subclass."""
615+
616+ class ITestInterface(Interface):
617+ pass
618+
619+ class TestField(BlacklistableContentNameField):
620+ _content_iface = ITestInterface
621+
622+ def _getByName(self, name):
623+ return None
624+
625+ return TestField(__name__='test')
626+
627+ def test_validate_fails_with_blacklisted_name_anonymous(self):
628+ # Anonymous users, processes, cannot create a name that matches
629+ # a blacklisted name.
630+ field = self.makeTestField()
631+ date_value = u'fnord'
632+ self.assertRaises(
633+ LaunchpadValidationError, field.validate, date_value)
634+
635+ def test_validate_fails_with_blacklisted_name_not_admin(self):
636+ # Users who do not adminster a blacklisted name cannot create
637+ # a matching name.
638+ field = self.makeTestField()
639+ date_value = u'fnord'
640+ login_person(self.factory.makePerson())
641+ self.assertRaises(
642+ LaunchpadValidationError, field.validate, date_value)
643+
644+ def test_validate_passes_for_admin(self):
645+ # Users in the team that adminsters a blacklisted name may create
646+ # matching names.
647+ field = self.makeTestField()
648+ date_value = u'fnord'
649+ login_person(self.team.teamowner)
650+ self.assertEqual(None, field.validate(date_value))

Subscribers

People subscribed via source and target branches

to status/vote changes: