launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #23462
[Merge] lp:~cjwatson/launchpad/stricter-bug-edit-permissions into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/stricter-bug-edit-permissions into lp:launchpad.
Commit message:
Add configurable heuristic karma and account age thresholds for users who are allowed to edit bugs.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/stricter-bug-edit-permissions/+merge/365131
I expect this to need further refinement, but it's a start.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/stricter-bug-edit-permissions into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2019-03-11 14:51:16 +0000
+++ database/schema/security.cfg 2019-03-26 20:55:23 +0000
@@ -1815,6 +1815,7 @@
public.job = SELECT, INSERT, UPDATE
public.karma = SELECT, INSERT
public.karmaaction = SELECT
+public.karmatotalcache = SELECT
public.language = SELECT
public.libraryfilealias = SELECT, INSERT, UPDATE
public.libraryfilecontent = SELECT, INSERT
=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py 2019-01-10 11:41:30 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py 2019-03-26 20:55:23 +0000
@@ -126,7 +126,7 @@
0, 10, login_method=lambda: login(ADMIN_EMAIL))
# This may seem large: it is; there is easily another 25% fat in
# there.
- self.assertThat(recorder1, HasQueryCount(LessThan(84)))
+ self.assertThat(recorder1, HasQueryCount(LessThan(85)))
self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
def test_rendered_query_counts_constant_with_attachments(self):
@@ -137,7 +137,7 @@
lambda: self.getUserBrowser(url, person),
lambda: self.factory.makeBugAttachment(bug=task.bug),
1, 9, login_method=lambda: login(ADMIN_EMAIL))
- self.assertThat(recorder1, HasQueryCount(LessThan(85)))
+ self.assertThat(recorder1, HasQueryCount(LessThan(86)))
self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
def makeLinkedBranchMergeProposal(self, sourcepackage, bug, owner):
@@ -172,7 +172,7 @@
recorder1, recorder2 = record_two_runs(
lambda: self.getUserBrowser(url, owner),
make_merge_proposals, 0, 1)
- self.assertThat(recorder1, HasQueryCount(LessThan(92)))
+ self.assertThat(recorder1, HasQueryCount(LessThan(93)))
# Ideally this should be much fewer, but this tries to keep a win of
# removing more than half of these.
self.assertThat(
@@ -218,7 +218,7 @@
lambda: self.getUserBrowser(url, person),
lambda: add_activity("description", self.factory.makePerson()),
1, 20, login_method=lambda: login(ADMIN_EMAIL))
- self.assertThat(recorder1, HasQueryCount(LessThan(85)))
+ self.assertThat(recorder1, HasQueryCount(LessThan(86)))
self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
def test_rendered_query_counts_constant_with_milestones(self):
@@ -228,7 +228,7 @@
with celebrity_logged_in('admin'):
browses_under_limit = BrowsesWithQueryLimit(
- 85, self.factory.makePerson())
+ 86, self.factory.makePerson())
self.assertThat(bug, browses_under_limit)
=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml 2016-07-29 16:03:33 +0000
+++ lib/lp/bugs/configure.zcml 2019-03-26 20:55:23 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2016 Canonical Ltd. This software is licensed under the
+<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
GNU Affero General Public License version 3 (see the file LICENSE).
-->
@@ -725,11 +725,13 @@
linked_merge_proposals
getVisibleLinkedMergeProposals"/>
<require
- permission="launchpad.Edit"
- interface="lp.bugs.interfaces.bug.IBugEdit"
+ permission="launchpad.Append"
+ interface="lp.bugs.interfaces.bug.IBugAppend"
attributes="
linkBranch
- unlinkBranch"
+ unlinkBranch"/>
+ <require
+ permission="launchpad.Edit"
set_attributes="
name
tags
=== modified file 'lib/lp/bugs/doc/bug.txt'
--- lib/lp/bugs/doc/bug.txt 2018-12-21 21:47:08 +0000
+++ lib/lp/bugs/doc/bug.txt 2019-03-26 20:55:23 +0000
@@ -181,7 +181,7 @@
>>> firefox_crashes.setPrivate(True, current_user())
Traceback (most recent call last):
...
- Unauthorized: (..., 'setPrivate', 'launchpad.Edit')
+ Unauthorized: (..., 'setPrivate', 'launchpad.Append')
We have to be logged in, so let's do that:
=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py 2016-11-12 18:24:37 +0000
+++ lib/lp/bugs/interfaces/bug.py 2019-03-26 20:55:23 +0000
@@ -10,9 +10,9 @@
'CreatedBugWithNoBugTasksError',
'IBug',
'IBugAddForm',
+ 'IBugAppend',
'IBugBecameQuestionEvent',
'IBugDelta',
- 'IBugEdit',
'IBugMute',
'IBugPublic',
'IBugSet',
@@ -684,8 +684,8 @@
"""
-class IBugEdit(Interface):
- """IBug attributes that require launchpad.Edit permission."""
+class IBugAppend(Interface):
+ """IBug attributes that require launchpad.Append permission."""
@call_with(owner=REQUEST_USER, from_api=True)
@operation_parameters(
@@ -1009,7 +1009,7 @@
"""
-class IBug(IBugPublic, IBugView, IBugEdit, IHasLinkedBranches):
+class IBug(IBugPublic, IBugView, IBugAppend, IHasLinkedBranches):
"""The core bug entry."""
export_as_webservice_entry()
=== modified file 'lib/lp/bugs/security.py'
--- lib/lp/bugs/security.py 2016-04-12 10:50:30 +0000
+++ lib/lp/bugs/security.py 2019-03-26 20:55:23 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2012 Canonical Ltd. This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Security adapters for the bugs module."""
@@ -23,7 +23,10 @@
from lp.bugs.interfaces.bugwatch import IBugWatch
from lp.bugs.interfaces.hasbug import IHasBug
from lp.bugs.interfaces.structuralsubscription import IStructuralSubscription
-from lp.registry.interfaces.role import IHasOwner
+from lp.registry.interfaces.role import (
+ IHasAppointedDriver,
+ IHasOwner,
+ )
from lp.services.messages.interfaces.message import IMessage
@@ -99,19 +102,82 @@
return not self.obj.bug.private
-class EditPublicByLoggedInUserAndPrivateByExplicitSubscribers(
- AuthorizationBase):
- permission = 'launchpad.Edit'
+class AppendBug(AuthorizationBase):
+ """Security adapter for appending to bugs.
+
+ This is used for operations that anyone who can see the bug can perform.
+ """
+ permission = 'launchpad.Append'
usedfor = IBug
def checkAuthenticated(self, user):
- """Allow any logged in user to edit a public bug, and only
- explicit subscribers to edit private bugs. Any bug that can be
- seen can be edited.
+ """Allow any logged in user to append to a public bug, and only
+ explicit subscribers to append to private bugs. Any bug that can be
+ seen can be appended to.
"""
return self.obj.userCanView(user)
def checkUnauthenticated(self):
+ """Never allow unauthenticated users to append to a bug."""
+ return False
+
+
+class EditBug(AuthorizationBase):
+ """Security adapter for editing bugs.
+
+ This is used for operations that are potentially destructive in some
+ way. They aren't heavily locked down, but only users who appear to be
+ legitimate can perform them.
+ """
+ permission = 'launchpad.Edit'
+ usedfor = IBug
+
+ def _hasAnyRole(self, user, targets):
+ """Return True if the user has any role on any of these bug targets."""
+ # XXX cjwatson 2019-03-26: This is inefficient for bugs with many
+ # targets. However, we only get here if we can't easily establish
+ # that the user seems legitimate, so it shouldn't be a big problem
+ # in practice. We can optimise this further if it turns out to
+ # matter.
+ for target in targets:
+ roles = []
+ if IHasOwner.providedBy(target):
+ roles.append('owner')
+ if IHasAppointedDriver.providedBy(target):
+ roles.append('driver')
+ if IHasBugSupervisor.providedBy(target):
+ roles.append('bug_supervisor')
+ if user.isOneOf(target, roles):
+ return True
+ return False
+
+ def checkAuthenticated(self, user):
+ """Allow sufficiently-trusted users to edit bugs.
+
+ Only users who can append to the bug can edit it; in addition, only
+ users who seem to be generally legitimate or who have a relevant
+ role on one of the targets of the bug can edit the bug.
+ """
+ if not self.forwardCheckAuthenticated(
+ user, permission='launchpad.Append'):
+ # The user cannot even see the bug.
+ return False
+ return (
+ # If the bug is private, then we don't need more elaborate
+ # checks as they must have been explicitly subscribed.
+ self.obj.private or
+ # If the user seems generally legitimate, let them through.
+ self.forwardCheckAuthenticated(
+ user, permission='launchpad.AnyLegitimatePerson') or
+ # The bug reporter can always edit their own bug.
+ user.inTeam(self.obj.owner) or
+ # Users with relevant roles can edit the bug.
+ user.in_admin or user.in_commercial_admin or
+ user.in_registry_experts or
+ self._hasAnyRole(
+ user, [task.target for task in self.obj.bugtasks]))
+
+ def checkUnauthenticated(self):
"""Never allow unauthenticated users to edit a bug."""
return False
=== modified file 'lib/lp/bugs/tests/test_bug.py'
--- lib/lp/bugs/tests/test_bug.py 2015-09-25 10:23:48 +0000
+++ lib/lp/bugs/tests/test_bug.py 2019-03-26 20:55:23 +0000
@@ -1,13 +1,16 @@
-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Tests for lp.bugs.model.Bug."""
__metaclass__ = type
+from datetime import timedelta
+
from lazr.lifecycle.snapshot import Snapshot
from zope.component import getUtility
from zope.interface import providedBy
+from zope.security.management import checkPermission
from zope.security.proxy import removeSecurityProxy
from lp.bugs.enums import BugNotificationLevel
@@ -23,8 +26,10 @@
UserCannotEditBugTaskImportance,
UserCannotEditBugTaskMilestone,
)
+from lp.registry.tests.test_person import KarmaTestMixin
from lp.testing import (
admin_logged_in,
+ celebrity_logged_in,
person_logged_in,
StormStatementRecorder,
TestCaseWithFactory,
@@ -327,3 +332,83 @@
target = self.factory.makeProduct()
bug = self.createBug(owner=person, target=target)
self.assertContentEqual([person], bug.getDirectSubscribers())
+
+
+class TestBugPermissions(TestCaseWithFactory, KarmaTestMixin):
+ """Test bug permissions."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestBugPermissions, self).setUp()
+ self.pushConfig(
+ 'launchpad', min_legitimate_karma=5, min_legitimate_account_age=5)
+ self.bug = self.factory.makeBug()
+
+ def test_unauthenticated_user_cannot_edit(self):
+ self.assertFalse(checkPermission('launchpad.Edit', self.bug))
+
+ def test_new_user_cannot_edit(self):
+ with person_logged_in(self.factory.makePerson()):
+ self.assertFalse(checkPermission('launchpad.Edit', self.bug))
+
+ def test_private_bug_subscriber_can_edit(self):
+ person = self.factory.makePerson()
+ with admin_logged_in() as admin:
+ self.bug.setPrivate(True, admin)
+ self.bug.subscribe(person, admin)
+ with person_logged_in(person):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_private_bug_non_subscriber_cannot_edit(self):
+ with admin_logged_in() as admin:
+ self.bug.setPrivate(True, admin)
+ with person_logged_in(self.factory.makePerson()):
+ self.assertFalse(checkPermission('launchpad.Edit', self.bug))
+
+ def test_user_with_karma_can_edit(self):
+ person = self.factory.makePerson()
+ self._makeKarmaTotalCache(person, 10)
+ with person_logged_in(person):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_user_with_account_age_can_edit(self):
+ person = self.factory.makePerson()
+ naked_account = removeSecurityProxy(person.account)
+ naked_account.date_created = (
+ naked_account.date_created - timedelta(days=10))
+ with person_logged_in(person):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_bug_reporter_can_edit(self):
+ with person_logged_in(self.bug.owner):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_admin_can_edit(self):
+ with admin_logged_in():
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_commercial_admin_can_edit(self):
+ with celebrity_logged_in('commercial_admin'):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_registry_expert_can_edit(self):
+ with celebrity_logged_in('registry_experts'):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_target_owner_can_edit(self):
+ with person_logged_in(self.bug.default_bugtask.target.owner):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_target_driver_can_edit(self):
+ person = self.factory.makePerson()
+ removeSecurityProxy(self.bug.default_bugtask.target).driver = person
+ with person_logged_in(person):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
+
+ def test_target_bug_supervisor_can_edit(self):
+ person = self.factory.makePerson()
+ removeSecurityProxy(self.bug.default_bugtask.target).bug_supervisor = (
+ person)
+ with person_logged_in(person):
+ self.assertTrue(checkPermission('launchpad.Edit', self.bug))
=== modified file 'lib/lp/bugs/tests/test_doc.py'
--- lib/lp/bugs/tests/test_doc.py 2018-06-30 16:09:44 +0000
+++ lib/lp/bugs/tests/test_doc.py 2019-03-26 20:55:23 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""
@@ -479,6 +479,7 @@
stdout_logging=False),
'bugs-emailinterface.txt-processmail': LayeredDocFileSuite(
'../tests/bugs-emailinterface.txt',
+ id_extensions=['bugs-emailinterface.txt-processmail'],
setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
layer=ProcessMailLayer,
stdout_logging=False),
=== modified file 'lib/lp/permissions.zcml'
--- lib/lp/permissions.zcml 2013-05-09 06:04:22 +0000
+++ lib/lp/permissions.zcml 2019-03-26 20:55:23 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2013 Canonical Ltd. This software is licensed under the
+<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
GNU Affero General Public License version 3 (see the file LICENSE).
-->
@@ -27,6 +27,11 @@
access_level="write" />
<permission
+ id="launchpad.AnyLegitimatePerson"
+ title="Any person who is Allowed and who also seems generally legitimate."
+ access_level="write" />
+
+ <permission
id="launchpad.Edit" title="Editing something" access_level="write" />
<!-- Request large downloads, or other heavyweight jobs that are not
=== modified file 'lib/lp/security.py'
--- lib/lp/security.py 2019-03-08 16:53:33 +0000
+++ lib/lp/security.py 2019-03-26 20:55:23 +0000
@@ -9,8 +9,13 @@
'ModerateByRegistryExpertsOrAdmins',
]
+from datetime import (
+ datetime,
+ timedelta,
+ )
from operator import methodcaller
+import pytz
from storm.expr import (
And,
Select,
@@ -177,6 +182,7 @@
)
from lp.registry.interfaces.wikiname import IWikiName
from lp.registry.model.person import Person
+from lp.services.config import config
from lp.services.database.interfaces import IStore
from lp.services.identity.interfaces.account import IAccount
from lp.services.identity.interfaces.emailaddress import IEmailAddress
@@ -301,6 +307,33 @@
return self.forwardCheckAuthenticated(user, self.obj, 'launchpad.View')
+class AnyLegitimatePerson(AuthorizationBase):
+ """The default ruleset for the launchpad.AnyLegitimatePerson permission.
+
+ Some operations are open to Launchpad users in general, but we still don't
+ want drive-by vandalism.
+ """
+ permission = 'launchpad.AnyLegitimatePerson'
+ usedfor = Interface
+
+ def checkUnauthenticated(self):
+ return False
+
+ def _hasEnoughKarma(self, user):
+ return user.person.karma >= config.launchpad.min_legitimate_karma
+
+ def _isOldEnough(self, user):
+ return (
+ datetime.now(pytz.UTC) - user.person.account.date_created >=
+ timedelta(days=config.launchpad.min_legitimate_account_age))
+
+ def checkAuthenticated(self, user):
+ if not self._hasEnoughKarma(user) and not self._isOldEnough(user):
+ return False
+ return self.forwardCheckAuthenticated(
+ user, self.obj, 'launchpad.AnyAllowedPerson')
+
+
class LimitedViewDeferredToView(AuthorizationBase):
"""The default ruleset for the launchpad.LimitedView permission.
=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf 2019-03-18 12:22:55 +0000
+++ lib/lp/services/config/schema-lazr.conf 2019-03-26 20:55:23 +0000
@@ -1108,6 +1108,12 @@
# specific in many contexts, but this provides a fallback.
urlfetch_timeout: 30
+# Minimum karma value that indicates a legitimate user.
+min_legitimate_karma: 0
+
+# Minimum account age in days that indicates a legitimate user.
+min_legitimate_account_age: 0
+
[launchpad_session]
# The database connection string.
# datatype: pgconnection
Follow ups