Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a confidential field to policies #4643

Merged
merged 5 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/api_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
('enterprise_impact', 'int'),
('shipping_year', 'int'),
('breaking_change', 'bool'),
('confidential', 'bool'),
('bug_url', 'link'),
('category', 'int'),
('cc_emails', 'emails'),
Expand Down
5 changes: 5 additions & 0 deletions api/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ def feature_entry_to_json_verbose(
'first_enterprise_notification_milestone': fe.first_enterprise_notification_milestone,
'enterprise_impact': fe.enterprise_impact,
'breaking_change': fe.breaking_change,
'confidential': fe.confidential,
'shipping_year': fe.shipping_year,
'flag_name': fe.flag_name,
'finch_name': fe.finch_name,
Expand Down Expand Up @@ -536,12 +537,16 @@ def feature_entry_to_json_basic(fe: FeatureEntry,
'enterprise_impact': fe.enterprise_impact,
'enterprise_product_category': fe.enterprise_product_category or ENTERPRISE_PRODUCT_CATEGORY_CHROME_BROWSER_UPDATE,
'breaking_change': fe.breaking_change,
'confidential': fe.confidential,
'first_enterprise_notification_milestone': fe.first_enterprise_notification_milestone,
'blink_components': fe.blink_components or [],
'resources': {
'samples': fe.sample_links or [],
'docs': fe.doc_links or [],
},
'creator': fe.creator_email,
'editors': fe.editor_emails or [],
'owners': fe.owner_emails or [],
'created': {'by': fe.creator_email, 'when': _date_to_str(fe.created)},
'updated': {'by': fe.updater_email, 'when': _date_to_str(fe.updated)},
'accurate_as_of': _date_to_str(fe.accurate_as_of),
Expand Down
9 changes: 9 additions & 0 deletions api/converters_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ def test_feature_entry_to_json_basic__normal(self):
'samples': ['https://example.com/samples'],
'docs': ['https://example.com/docs'],
},
'confidential': False,
'creator': 'creator@example.com',
'editors': ['feature_editor@example.com', 'owner_1@example.com'],
'owners': ['feature_owner@example.com'],
'created': {
'by': 'creator@example.com',
'when': expected_date
Expand Down Expand Up @@ -201,6 +205,10 @@ def test_feature_entry_to_json_basic__feature_release(self):
'samples': ['https://example.com/samples'],
'docs': ['https://example.com/docs'],
},
'confidential': False,
'creator': 'creator@example.com',
'editors': ['feature_editor@example.com', 'owner_1@example.com'],
'owners': ['feature_owner@example.com'],
'created': {
'by': 'creator@example.com',
'when': expected_date
Expand Down Expand Up @@ -398,6 +406,7 @@ def test_feature_entry_to_json_verbose__normal(self):
'editors': ['feature_editor@example.com', 'owner_1@example.com'],
'creator': 'creator@example.com',
'comments': 'notes',
'confidential': False,
'browsers': {
'chrome': {
'bug': 'https://example.com/bug',
Expand Down
3 changes: 3 additions & 0 deletions api/features_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def get_one_feature(self, feature_id: int) -> VerboseFeatureDict:
user = users.get_current_user()
if feature.deleted and not permissions.can_edit_feature(user, feature_id):
self.abort(404, msg='Feature %r not found' % feature_id)
if not permissions.can_view_feature(user, feature):
self.abort(404, msg='Feature %r not found' % feature_id)

return converters.feature_entry_to_json_verbose(feature)

def do_search(self):
Expand Down
32 changes: 23 additions & 9 deletions api/features_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ def setUp(self):
stage_type=360, milestones=MilestoneSet(desktop_first=2))
self.ship_stage_3.put()

self.feature_confidential = FeatureEntry(
name='feature confidential', summary='sum A', feature_type=2,
owner_emails=['confidential_owner@example.com'], category=1,
editor_emails=['confidential_editor@example.com'],
creator_email='confidential_creator@examplecom',
intent_stage=core_enums.INTENT_IMPLEMENT,
unlisted=True, confidential=True)
self.feature_confidential.put()
self.feature_confidential_id = self.feature_3.key.integer_id()
self.ship_stage_3 = Stage(feature_id=self.feature_3_id,
stage_type=360, milestones=MilestoneSet(desktop_first=1))
self.ship_stage_3.put()

self.request_path = '/api/v0/features'
self.handler = features_api.FeaturesAPI()

Expand Down Expand Up @@ -180,9 +193,9 @@ def test_get__all_listed_feature_names(self):
# as it only returns feature names.
self.assertEqual(2, len(actual['features']))
self.assertEqual(2, actual['total_count'])
self.assertEqual(2, len(actual['features'][0]))
self.assertEqual(3, len(actual['features'][0]))
self.assertEqual('feature two', actual['features'][0]['name'])
self.assertEqual(2, len(actual['features'][1]))
self.assertEqual(3, len(actual['features'][1]))
self.assertEqual('feature one', actual['features'][1]['name'])

def test_get__all_listed__pagination(self):
Expand Down Expand Up @@ -320,11 +333,12 @@ def test_get__all_unlisted_can_edit(self):
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(self.request_path):
actual = self.handler.do_get()
self.assertEqual(3, len(actual['features']))
self.assertEqual(3, actual['total_count'])
self.assertEqual('feature three', actual['features'][0]['name'])
self.assertEqual('feature two', actual['features'][1]['name'])
self.assertEqual('feature one', actual['features'][2]['name'])
self.assertEqual(4, len(actual['features']))
self.assertEqual(4, actual['total_count'])
self.assertEqual('feature confidential', actual['features'][0]['name'])
self.assertEqual('feature three', actual['features'][1]['name'])
self.assertEqual('feature two', actual['features'][2]['name'])
self.assertEqual('feature one', actual['features'][3]['name'])

def test_get__user_query_no_sort__signed_out(self):
"""Get all features with a specified owner, unlisted not shown."""
Expand Down Expand Up @@ -361,8 +375,8 @@ def test_get__user_query_with_sort__with_perms(self):
url = self.request_path + '?sort=-summary'
with test_app.test_request_context(url):
actual = self.handler.do_get()
self.assertEqual(3, len(actual['features']))
self.assertEqual(3, actual['total_count'])
self.assertEqual(4, len(actual['features']))
self.assertEqual(4, actual['total_count'])
self.assertEqual('sum Z', actual['features'][0]['summary'])
self.assertEqual('sum K', actual['features'][1]['summary'])
self.assertEqual('sum A', actual['features'][2]['summary'])
Expand Down
1 change: 1 addition & 0 deletions api/legacy_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def feature_to_legacy_json(f: Feature) -> dict[str, Any]:
d['category'] = FEATURE_CATEGORIES[f.category]
d['enterprise_feature_categories'] = f.enterprise_feature_categories
d['enterprise_product_category'] = f.enterprise_product_category
d['confidential'] = f.confidential
d['category_int'] = f.category
if f.feature_type is not None:
d['feature_type'] = FEATURE_TYPES[f.feature_type]
Expand Down
2 changes: 2 additions & 0 deletions client-src/elements/form-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const ENTERPRISE_NEW_FEATURE_FORM_FIELDS = [
'enterprise_product_category',
'first_enterprise_notification_milestone',
'enterprise_impact',
'confidential',
];

// The fields that are available to every feature.
Expand Down Expand Up @@ -237,6 +238,7 @@ export const FLAT_ENTERPRISE_METADATA_FIELDS: MetadataFields = {
'enterprise_feature_categories',
'enterprise_product_category',
'enterprise_impact',
'confidential',
'first_enterprise_notification_milestone',
'screenshot_links',
],
Expand Down
9 changes: 9 additions & 0 deletions client-src/elements/form-field-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2135,6 +2135,15 @@ export const ALL_FIELDS: Record<string, Field> = {
take action to continue using some existing functionaity.`,
},

confidential: {
type: 'checkbox',
label: 'Confidential',
initial: false,
help_text: html`This is a confidential feature that should only be visible
to admins, chromium contributors and the feature's owners, contributors and
creator.`,
},

intent_cc_emails: {
type: 'input',
attrs: MULTI_EMAIL_FIELD_ATTRS,
Expand Down
55 changes: 52 additions & 3 deletions framework/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,61 @@ def is_google_or_chromium_account(user: User) -> bool:
return user.email().endswith(('@chromium.org', '@google.com'))
return False

def can_view_feature(unused_user, unused_feature) -> bool:
def can_view_feature_formatted(user: User, feature: dict) -> bool:
"""Return True if the user is allowed to view the given feature."""
# Note, for now there are no private features, only unlisted ones.
return True
if not feature:
return False

return not feature['confidential'] or _can_view_confidential_feature_formatted(user, feature)

def _can_view_confidential_feature_formatted(user: User, feature: dict):
''' Check if the user is an owner, editor, spec mentor, or creator
for this feature or has a google.com or chromium.org account.
If yes, they feature can be viewed, otherwise they cannot view
confidential features.'''
if not user:
return False

if is_google_or_chromium_account(user) or can_admin_site(user):
return True

email = user.email()
if (
email in feature['owners'] or
email in feature['editors'] or
email == feature['creator']):
return True

return False


def can_view_feature(user: User, feature: FeatureEntry) -> bool:
"""Return True if the user is allowed to view the given feature."""
if not feature:
return False

return not feature.confidential or _can_view_confidential_feature(user, feature)

def _can_view_confidential_feature(user: User, feature: FeatureEntry):
''' Check if the user is an owner, editor, spec mentor, or creator
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: all our other docstrings use three double-quotes.

for this feature or has a google.com or chromium.org account.
If yes, they feature can be viewed, otherwise they cannot view
confidential features.'''
if not user or not feature:
return False

if is_google_or_chromium_account(user) or can_admin_site(user):
return True

email = user.email()
if (
email in feature.owner_emails or
email in feature.editor_emails or
email == feature.creator_email):
return True

return False

def can_create_feature(user: User) -> bool:
"""Return True if the user is allowed to create features."""
if not user:
Expand Down
29 changes: 24 additions & 5 deletions framework/permissions_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,22 @@ def setUp(self):
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()

# Feature for checking permissions against
self.feature_confidential = core_models.FeatureEntry(
name='feature one', summary='sum',
creator_email="feature_creator@example.com",
owner_emails=['feature_owner@example.com'],
editor_emails=['feature_editor@example.com'],
spec_mentor_emails=['mentor@example.com'], category=1,
confidential=True)
self.feature_confidential.put()
self.feature_confidential_id = self.feature_confidential.key.integer_id()

def tearDown(self):
for user in self.users:
user.delete()
self.feature_1.key.delete()
self.feature_confidential.key.delete()

def check_function_results(
self, func, additional_args,
Expand Down Expand Up @@ -194,16 +206,23 @@ def test_can_admin_site(self):
def test_can_view_feature(self):
self.check_function_results(
permissions.can_view_feature, (None,),
unregistered=True, registered=True,
special=True, site_editor=True, admin=True, anon=True)
unregistered=False, registered=False,
special=False, site_editor=False, admin=False, anon=False)

self.check_function_results_with_feature(
permissions.can_view_feature, (self.feature_id,),
permissions.can_view_feature, (self.feature_1,),
unregistered=True, registered=True,
feature_owner=True, feature_editor=True,
creator=True, site_editor=True, admin=True, spec_mentor=True,
)

self.check_function_results_with_feature(
permissions.can_view_feature, (self.feature_confidential,),
unregistered=False, registered=False,
feature_owner=True, feature_editor=True,
creator=True, site_editor=False, admin=True, spec_mentor=False,
)

def test_can_create_feature(self):
self.check_function_results(
permissions.can_create_feature, tuple(),
Expand Down Expand Up @@ -233,13 +252,13 @@ def test_can_edit_feature(self):
def test_can_review_gate(self):
approvers = []
self.check_function_results(
permissions.can_review_gate, (None, None, approvers),
permissions.can_review_gate, (self.feature_1, None, approvers),
unregistered=False, registered=False,
special=False, site_editor=False, admin=True, anon=False)

approvers = ['registered@example.com']
self.check_function_results(
permissions.can_review_gate, (None, None, approvers),
permissions.can_review_gate, (self.feature_1, None, approvers),
unregistered=False, registered=True,
special=False, site_editor=False, admin=True, anon=False)

Expand Down
1 change: 1 addition & 0 deletions internals/core_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class FeatureEntry(ndb.Model): # Copy from Feature
first_enterprise_notification_milestone = ndb.IntegerProperty()
enterprise_impact = ndb.IntegerProperty(default=ENTERPRISE_IMPACT_NONE)
breaking_change = ndb.BooleanProperty(default=False)
confidential = ndb.BooleanProperty(default=False)
shipping_year = ndb.IntegerProperty()

# Implementation in Chrome
Expand Down
1 change: 1 addition & 0 deletions internals/data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ class VerboseFeatureDict(TypedDict):
screenshot_links: list[str]
first_enterprise_notification_milestone: int | None
breaking_change: bool
confidential: bool
enterprise_impact: int
shipping_year: int | None

Expand Down
Loading
Loading