Skip to content

Commit f00b539

Browse files
Add module for using cloud identity api to manage google groups (#5149)
This is some groundwork in order to use google groups for CCs in OSS-Fuzz issues. Context: b/477964128, b/388390041 and google/oss-fuzz#12945 Since we used google groups for the auth/access in appengine, I updated it to centralize in the same module under google cloud utils. Tests: * Used a local script (gpaste/4919004936404992) to execute all methods using a test group. Output: gpaste/6567249169219584
1 parent 69923f0 commit f00b539

File tree

4 files changed

+188
-63
lines changed

4 files changed

+188
-63
lines changed

src/appengine/libs/access.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from clusterfuzz._internal.config import db_config
2020
from clusterfuzz._internal.config import local_config
2121
from clusterfuzz._internal.datastore import data_handler
22+
from clusterfuzz._internal.google_cloud_utils import google_groups
2223
from clusterfuzz._internal.issue_management import issue_tracker_utils
2324
from libs import auth
2425
from libs import helpers
@@ -40,19 +41,17 @@ def _is_privileged_user(email):
4041
# Check privileged access from google groups.
4142
privileged_groups = (db_config.get_value('privileged_groups') or
4243
'').splitlines()
43-
identity_service = auth.get_identity_api()
4444
for privileged_group in privileged_groups:
4545
# Filter for non-group patterns.
4646
if ('@' not in privileged_group or
4747
utils.is_service_account(privileged_group)):
4848
continue
4949

50-
group_id = auth.get_google_group_id(privileged_group, identity_service)
50+
group_id = google_groups.get_group_id(privileged_group)
5151
if not group_id:
5252
continue
5353

54-
if auth.check_transitive_group_membership(group_id, email,
55-
identity_service):
54+
if google_groups.check_transitive_group_membership(group_id, email):
5655
return True
5756

5857
return False

src/appengine/libs/auth.py

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,19 @@
1414
"""Authentication helpers."""
1515

1616
import collections
17-
from urllib import parse
1817

1918
from firebase_admin import auth
2019
from google.auth.transport import requests as google_requests
2120
from google.cloud import ndb
2221
from google.oauth2 import id_token
2322
from googleapiclient import discovery
24-
from googleapiclient.errors import HttpError
2523
import jwt
2624
import requests
2725

2826
from clusterfuzz._internal.base import memoize
2927
from clusterfuzz._internal.base import utils
3028
from clusterfuzz._internal.config import local_config
3129
from clusterfuzz._internal.datastore import data_types
32-
from clusterfuzz._internal.google_cloud_utils import credentials
3330
from clusterfuzz._internal.metrics import logs
3431
from clusterfuzz._internal.system import environment
3532
from libs import helpers
@@ -252,48 +249,3 @@ def decode_claims(session_cookie):
252249
return auth.verify_session_cookie(session_cookie, check_revoked=True)
253250
except (ValueError, auth.AuthError):
254251
raise AuthError('Invalid session cookie.')
255-
256-
257-
def get_identity_api() -> discovery.Resource:
258-
"""Return cloud identity api client."""
259-
creds, _ = credentials.get_default()
260-
return discovery.build('cloudidentity', 'v1', credentials=creds)
261-
262-
263-
def get_google_group_id(group_email: str,
264-
identity_service: discovery.Resource | None = None
265-
) -> str | None:
266-
"""Retrive a google group ID."""
267-
if not identity_service:
268-
identity_service = get_identity_api()
269-
270-
try:
271-
request = identity_service.groups().lookup(groupKey_id=group_email)
272-
response = request.execute()
273-
return response.get('name')
274-
except HttpError:
275-
logs.warning(f"Unable to look up group {group_email}.")
276-
return None
277-
278-
279-
def check_transitive_group_membership(
280-
group_id: str,
281-
member: str,
282-
identity_service: discovery.Resource | None = None) -> bool:
283-
"""Check if an user is a member of a google group."""
284-
if not identity_service:
285-
identity_service = get_identity_api()
286-
287-
try:
288-
query_params = parse.urlencode({
289-
"query": "member_key_id == '{}'".format(member)
290-
})
291-
request = identity_service.groups().memberships().checkTransitiveMembership(
292-
parent=group_id)
293-
request.uri += "&" + query_params
294-
response = request.execute()
295-
return response.get('hasMembership', False)
296-
except HttpError:
297-
logs.warning(
298-
f'Unable to check group membership from {member} to {group_id}.')
299-
return False
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Helper for google groups management."""
15+
16+
import threading
17+
from urllib import parse
18+
19+
from googleapiclient import discovery
20+
from googleapiclient import errors
21+
22+
from clusterfuzz._internal.config import local_config
23+
from clusterfuzz._internal.google_cloud_utils import credentials
24+
from clusterfuzz._internal.metrics import logs
25+
26+
# pylint: disable=no-member
27+
28+
_FAIL_RETRIES = 3
29+
30+
_local = threading.local()
31+
32+
33+
def get_identity_api() -> discovery.Resource | None:
34+
"""Return cloud identity api client."""
35+
if not hasattr(_local, 'identity_service'):
36+
creds, _ = credentials.get_default()
37+
_local.identity_service = discovery.build(
38+
'cloudidentity', 'v1', credentials=creds, cache_discovery=False)
39+
40+
return _local.identity_service
41+
42+
43+
def get_group_id(group_name: str, exists_check: bool = False) -> str | None:
44+
"""Retrive a google group ID."""
45+
identity_service = get_identity_api()
46+
try:
47+
request = identity_service.groups().lookup(groupKey_id=group_name)
48+
response = request.execute()
49+
return response.get('name')
50+
except errors.HttpError:
51+
if not exists_check:
52+
logs.warning(f"Unable to look up group {group_name}.")
53+
return None
54+
55+
56+
def check_transitive_group_membership(group_id: str, member: str) -> bool:
57+
"""Check if an user is a member of a google group."""
58+
identity_service = get_identity_api()
59+
try:
60+
query_params = parse.urlencode({
61+
"query": "member_key_id == '{}'".format(member)
62+
})
63+
request = identity_service.groups().memberships().checkTransitiveMembership(
64+
parent=group_id)
65+
request.uri += "&" + query_params
66+
response = request.execute(num_retries=_FAIL_RETRIES)
67+
return response.get('hasMembership', False)
68+
except errors.HttpError:
69+
logs.warning(
70+
f'Unable to check group membership from {member} to {group_id}.')
71+
return False
72+
73+
74+
def create_google_group(group_name: str,
75+
group_display_name: str | None = None,
76+
group_description: str | None = None,
77+
customer_id: str | None = None) -> str | None:
78+
"""Create a google group."""
79+
identity_service = get_identity_api()
80+
81+
customer_id = customer_id or str(
82+
local_config.ProjectConfig().get('groups_customer_id'))
83+
if not customer_id:
84+
logs.error('No customer ID set. Unable to create a new google group.')
85+
return None
86+
87+
group_key = {"id": group_name}
88+
group = {
89+
"parent": "customers/" + customer_id,
90+
"description": group_description,
91+
"displayName": group_display_name,
92+
"groupKey": group_key,
93+
# Set the label to specify creation of a Google Group.
94+
"labels": {
95+
"cloudidentity.googleapis.com/groups.discussion_forum": ""
96+
}
97+
}
98+
try:
99+
request = identity_service.groups().create(body=group)
100+
request.uri += "&initialGroupConfig=WITH_INITIAL_OWNER"
101+
response = request.execute(num_retries=_FAIL_RETRIES)
102+
group_id = response.get('response').get('name')
103+
logs.info(f'Created google group {group_name}', request_response=response)
104+
return group_id
105+
except errors.HttpError:
106+
logs.error(f'Failed to create google group {group_name}')
107+
return None
108+
109+
110+
def get_google_group_memberships(group_id: str) -> dict[str, str] | None:
111+
"""Get dict of membership name to members id from a google group."""
112+
identity_service = get_identity_api()
113+
114+
try:
115+
response = identity_service.groups().memberships().list(
116+
parent=group_id).execute()
117+
memberships = {
118+
member.get('preferredMemberKey').get('id'): member.get('name')
119+
for member in response.get('memberships', [])
120+
}
121+
return memberships
122+
except errors.HttpError:
123+
logs.error(f'Failed to get list of members from group {group_id}')
124+
return None
125+
126+
127+
def add_member_to_group(group_id: str, member: str) -> bool:
128+
"""Add a new member to a google group."""
129+
identity_service = get_identity_api()
130+
131+
try:
132+
# Create a membership object with a role type MEMBER
133+
membership = {
134+
"preferredMemberKey": {
135+
"id": member
136+
},
137+
"roles": {
138+
"name": "MEMBER",
139+
}
140+
}
141+
# Create a membership using the group ID and the membership object
142+
response = identity_service.groups().memberships().create(
143+
parent=group_id, body=membership).execute(num_retries=_FAIL_RETRIES)
144+
logs.info(
145+
f'Added {member} to google group {group_id}', request_response=response)
146+
return True
147+
except errors.HttpError:
148+
logs.error(f'Failed to add {member} to google group {group_id}')
149+
return False
150+
151+
152+
def delete_google_group_membership(group_id: str,
153+
member: str,
154+
membership_name: str | None = None) -> bool:
155+
"""Delete a google group membership."""
156+
identity_service = get_identity_api()
157+
158+
try:
159+
if not membership_name:
160+
membership_lookup_request = identity_service.groups().memberships(
161+
).lookup(parent=group_id)
162+
membership_lookup_request.uri += "&memberKey.id=" + member
163+
membership_lookup_response = membership_lookup_request.execute()
164+
membership_name = membership_lookup_response.get("name")
165+
166+
response = identity_service.groups().memberships().delete(
167+
name=membership_name).execute(num_retries=_FAIL_RETRIES)
168+
logs.info(
169+
f'Removed {member} ({membership_name}) from google group {group_id}',
170+
request_response=response)
171+
return True
172+
except errors.HttpError:
173+
logs.error(f'Failed to remove {member} from google group {group_id}')
174+
return False

src/clusterfuzz/_internal/tests/appengine/libs/access_test.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ def _get_value_mock(self, key):
7676
def setUp(self):
7777
test_helpers.patch(self, [
7878
'clusterfuzz._internal.config.db_config.get_value',
79-
'libs.auth.get_google_group_id',
80-
'libs.auth.check_transitive_group_membership',
81-
'libs.auth.get_identity_api'
79+
'clusterfuzz._internal.google_cloud_utils.google_groups.get_group_id',
80+
'clusterfuzz._internal.google_cloud_utils.google_groups.check_transitive_group_membership',
81+
'clusterfuzz._internal.google_cloud_utils.google_groups.get_identity_api'
8282
])
8383

8484
def test_none(self):
@@ -108,35 +108,35 @@ def test_privileged_group(self):
108108
"""Test success access for member of privileged group."""
109109
self.mock.get_value.side_effect = self._get_value_mock
110110
self.mock.get_identity_api.return_value = None
111-
self.mock.get_google_group_id.return_value = 1
111+
self.mock.get_group_id.return_value = 1
112112
self.mock.check_transitive_group_membership.return_value = True
113113

114114
self.assertTrue(access._is_privileged_user('usertest@google.com'))
115-
self.mock.get_google_group_id.assert_called_with('test@group.com', None)
115+
self.mock.get_group_id.assert_called_with('test@group.com')
116116
self.mock.check_transitive_group_membership.assert_called_with(
117-
1, 'usertest@google.com', None)
117+
1, 'usertest@google.com')
118118

119119
def test_privileged_group_id_not_available(self):
120120
"""Test failed access if privileged group not found."""
121121
self.mock.get_value.side_effect = self._get_value_mock
122122
self.mock.get_identity_api.return_value = None
123-
self.mock.get_google_group_id.return_value = None
123+
self.mock.get_group_id.return_value = None
124124

125125
self.assertFalse(access._is_privileged_user('usertest@google.com'))
126-
self.mock.get_google_group_id.assert_called_with('test@group.com', None)
126+
self.mock.get_group_id.assert_called_with('test@group.com')
127127
self.mock.check_transitive_group_membership.assert_not_called()
128128

129129
def test_not_member_privileged_group(self):
130130
"""Test failed access if user not member of privileged group."""
131131
self.mock.get_value.side_effect = self._get_value_mock
132132
self.mock.get_identity_api.return_value = None
133-
self.mock.get_google_group_id.return_value = 1
133+
self.mock.get_group_id.return_value = 1
134134
self.mock.check_transitive_group_membership.return_value = False
135135

136136
self.assertFalse(access._is_privileged_user('usertest@google.com'))
137-
self.mock.get_google_group_id.assert_called_with('test@group.com', None)
137+
self.mock.get_group_id.assert_called_with('test@group.com')
138138
self.mock.check_transitive_group_membership.assert_called_with(
139-
1, 'usertest@google.com', None)
139+
1, 'usertest@google.com')
140140

141141

142142
class IsDomainAllowedTest(unittest.TestCase):

0 commit comments

Comments
 (0)