• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

uc-cdis / fence / 20339659455

18 Dec 2025 02:07PM UTC coverage: 75.002% (+0.02%) from 74.987%
20339659455

Pull #1312

github

nss10
Update Fence project version -- since running as non-root is a breaking change
Pull Request #1312: Update Fence to Python 3.13 + Run as gen3 user

8440 of 11253 relevant lines covered (75.0%)

0.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.17
fence/scripting/google_monitor.py
1
"""
2
Google Monitoring and Validation Logic
3

4
This file contains scripts to monitor user-registered service accounts and
5
their respective Google projects. The functions in this file will also
6
handle invalid service accounts and projects.
7
"""
8
import traceback
1✔
9

10
from gen3cirrus.google_cloud.iam import GooglePolicyMember
1✔
11
from gen3cirrus import GoogleCloudManager
1✔
12
from gen3cirrus.google_cloud.errors import GoogleAPIError
1✔
13

14
from cdislogging import get_logger
1✔
15

16
from fence.resources.google.validity import (
1✔
17
    GoogleProjectValidity,
18
    GoogleServiceAccountValidity,
19
)
20

21
from fence.resources.google.utils import (
1✔
22
    get_all_registered_service_accounts,
23
    get_linked_google_account_email,
24
    is_google_managed_service_account,
25
)
26

27
from fence.resources.google.access_utils import (
1✔
28
    get_google_project_number,
29
    get_project_from_auth_id,
30
    get_user_by_email,
31
    get_user_by_linked_email,
32
    force_remove_service_account_from_access,
33
    force_remove_service_account_from_db,
34
    user_has_access_to_project,
35
)
36

37
from fence import utils
1✔
38
from fence.config import config
1✔
39
from fence.models import User
1✔
40
from fence.errors import Unauthorized
1✔
41

42
logger = get_logger(__name__)
1✔
43

44

45
def validation_check(db):
1✔
46
    """
47
    Google validation check for all user-registered service accounts
48
    and projects.
49

50
    This will remove any invalid registered service accounts. It will also
51
    remove all registered service accounts for a given project if the project
52
    itself is invalid.
53

54
    NOTE: This entire function should be time-efficient and finish in less
55
          than 90 seconds.
56
          TODO: Test this function with various amounts of service accounts
57
                and delays from the google API
58
    """
59
    registered_service_accounts = get_all_registered_service_accounts(db=db)
1✔
60
    project_service_account_mapping = _get_project_service_account_mapping(
1✔
61
        registered_service_accounts
62
    )
63

64
    for google_project_id, sa_emails in project_service_account_mapping.items():
1✔
65
        email_required = False
1✔
66
        invalid_registered_service_account_reasons = {}
1✔
67
        invalid_project_reasons = {}
1✔
68
        sa_emails_removed = []
1✔
69
        for sa_email in sa_emails:
1✔
70
            logger.debug("Validating Google Service Account: {}".format(sa_email))
1✔
71
            # Do some basic service account checks, this won't validate
72
            # the data access, that's done when the project's validated
73
            try:
1✔
74
                validity_info = _is_valid_service_account(sa_email, google_project_id)
1✔
75
            except Unauthorized:
×
76
                """
×
77
                is_validity_service_account can raise an exception if the monitor does
78
                not have access, which will be caught and handled during the Project check below
79
                The logic in the endpoints is reversed (Project is checked first,
80
                not SAs) which is why there's is a sort of weird handling of it here.
81
                """
82
                logger.info(
×
83
                    "Monitor does not have access to validate "
84
                    "service account {}. This should be handled "
85
                    "in project validation.".format(sa_email)
86
                )
87
                continue
×
88

89
            if not validity_info:
1✔
90
                logger.info(
1✔
91
                    "INVALID SERVICE ACCOUNT {} DETECTED. REMOVING. Validity Information: {}".format(
92
                        sa_email, str(getattr(validity_info, "_info", None))
93
                    )
94
                )
95
                force_remove_service_account_from_access(
1✔
96
                    sa_email, google_project_id, db=db
97
                )
98
                if validity_info["policy_accessible"] is False:
1✔
99
                    logger.info(
1✔
100
                        "SERVICE ACCOUNT POLICY NOT ACCESSIBLE OR DOES NOT "
101
                        "EXIST. SERVICE ACCOUNT WILL BE REMOVED FROM FENCE DB"
102
                    )
103
                    force_remove_service_account_from_db(sa_email, db=db)
1✔
104

105
                # remove from list so we don't try to remove again
106
                # if project is invalid too
107
                sa_emails_removed.append(sa_email)
1✔
108

109
                invalid_registered_service_account_reasons[
1✔
110
                    sa_email
111
                ] = _get_service_account_removal_reasons(validity_info)
112
                email_required = True
1✔
113

114
        for sa_email in sa_emails_removed:
1✔
115
            sa_emails.remove(sa_email)
1✔
116

117
        logger.debug("Validating Google Project: {}".format(google_project_id))
1✔
118
        google_project_validity = _is_valid_google_project(google_project_id, db=db)
1✔
119

120
        if not google_project_validity:
1✔
121
            # for now, if we detect in invalid project, remove ALL service
122
            # accounts from access for that project.
123
            #
124
            # TODO: If the issue is ONLY a specific service account,
125
            # it may be possible to isolate it and only remove that
126
            # from access.
127
            logger.info(
×
128
                "INVALID GOOGLE PROJECT {} DETECTED. REMOVING ALL SERVICE ACCOUNTS. "
129
                "Validity Information: {}".format(
130
                    google_project_id,
131
                    str(getattr(google_project_validity, "_info", None)),
132
                )
133
            )
134
            for sa_email in sa_emails:
×
135
                force_remove_service_account_from_access(
×
136
                    sa_email, google_project_id, db=db
137
                )
138

139
            # projects can be invalid for project-related reasons or because
140
            # of NON-registered service accounts
141
            invalid_project_reasons["general"] = _get_general_project_removal_reasons(
×
142
                google_project_validity
143
            )
144
            invalid_project_reasons[
×
145
                "non_registered_service_accounts"
146
            ] = _get_invalid_sa_project_removal_reasons(google_project_validity)
147
            invalid_project_reasons["access"] = _get_access_removal_reasons(
×
148
                google_project_validity
149
            )
150
            email_required = True
×
151

152
        email_required &= config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["enable"]
1✔
153
        if email_required:
1✔
154
            logger.debug(
1✔
155
                "Sending email with service account removal reasons: {} and project "
156
                "removal reasons: {}.".format(
157
                    invalid_registered_service_account_reasons, invalid_project_reasons
158
                )
159
            )
160

161
            try:
1✔
162
                user_email_list = (
1✔
163
                    _get_user_email_list_from_google_project_with_owner_role(
164
                        google_project_id
165
                    )
166
                )
167
            except GoogleAPIError:
×
168
                logger.warning(
×
169
                    "DID NOT EMAIL USERS. Unable to get user(s) email(s) about service account "
170
                    "removal in Google project {}. If fence's monitoring SA is not present "
171
                    "then we cannot make the Google API call to know who to email.".format(
172
                        google_project_id
173
                    )
174
                )
175
                return
×
176

177
            _send_emails_informing_service_account_removal(
1✔
178
                user_email_list,
179
                invalid_registered_service_account_reasons,
180
                invalid_project_reasons,
181
                google_project_id,
182
            )
183

184

185
def _is_valid_service_account(sa_email, google_project_id):
1✔
186
    """
187
    Validate the given registered service account and remove if invalid.
188

189
    Args:
190
        sa_email(str): service account email
191
        google_project_id(str): google project id
192
    """
193
    with GoogleCloudManager(google_project_id) as gcm:
1✔
194
        google_project_number = get_google_project_number(google_project_id, gcm)
1✔
195

196
    has_access = bool(google_project_number)
1✔
197
    if not has_access:
1✔
198
        # if our monitor doesn't have access at this point, just don't return any
199
        # information. When the project check runs, it will catch the monitor missing
200
        # error and add it to the removal reasons
201
        raise Unauthorized(
×
202
            "Google Monitoring SA doesn't have access to Google Project: {}".format(
203
                google_project_id
204
            )
205
        )
206

207
    try:
1✔
208
        sa_validity = GoogleServiceAccountValidity(
1✔
209
            sa_email, google_project_id, google_project_number=google_project_number
210
        )
211

212
        if is_google_managed_service_account(sa_email):
1✔
213
            sa_validity.check_validity(
×
214
                early_return=True,
215
                check_type=True,
216
                check_policy_accessible=True,
217
                check_external_access=False,
218
            )
219
        else:
220
            sa_validity.check_validity(
1✔
221
                early_return=True,
222
                check_type=True,
223
                check_policy_accessible=True,
224
                check_external_access=True,
225
            )
226

227
    except Exception as exc:
×
228
        # any issues, assume invalid
229
        # TODO not sure if this is the right way to handle this...
230
        logger.warning(
×
231
            "Service Account {} determined invalid due to unhandled exception: {}. "
232
            "Assuming service account is invalid.".format(sa_email, str(exc))
233
        )
234
        traceback.print_exc()
×
235
        sa_validity = None
×
236

237
    return sa_validity
1✔
238

239

240
def _is_valid_google_project(google_project_id, db=None):
1✔
241
    """
242
    Validate the given google project id and remove all registered service
243
    accounts under that project if invalid.
244
    """
245
    try:
1✔
246
        project_validity = GoogleProjectValidity(google_project_id)
1✔
247
        project_validity.check_validity(early_return=True, db=db)
1✔
248
    except Exception as exc:
×
249
        # any issues, assume invalid
250
        # TODO not sure if this is the right way to handle this...
251
        logger.warning(
×
252
            "Project {} determined invalid due to unhandled exception: {}. "
253
            "Assuming project is invalid.".format(google_project_id, str(exc))
254
        )
255
        traceback.print_exc()
×
256
        project_validity = None
×
257

258
    return project_validity
1✔
259

260

261
def _get_service_account_removal_reasons(service_account_validity):
1✔
262
    """
263
    Get service account removal reason
264

265
    Args:
266
        service_account_validity(GoogleServiceAccountValidity): service account validity
267

268
    Returns:
269
        List[str]: the reason(s) the service account was removed
270
    """
271
    removal_reasons = []
×
272

273
    if service_account_validity is None:
×
274
        return removal_reasons
×
275

276
    if service_account_validity["valid_type"] is False:
×
277
        removal_reasons.append(
×
278
            "It must be a Compute Engine service account or an user-managed service account."
279
        )
280
    if service_account_validity["no_external_access"] is False:
×
281
        removal_reasons.append(
×
282
            "It has either roles attached to it or service account keys generated. We do not allow this because we need to restrict external access."
283
        )
284
    if service_account_validity["owned_by_project"] is False:
×
285
        removal_reasons.append("It is not owned by the project.")
×
286
    if service_account_validity["policy_accessible"] is False:
×
287
        removal_reasons.append(
×
288
            "Either it doesn't exist in Google or "
289
            "we could not access its policy, "
290
            "which is need for further checks."
291
        )
292

293
    return removal_reasons
×
294

295

296
def _get_general_project_removal_reasons(google_project_validity):
1✔
297
    """
298
    Get service account removal reason
299

300
    Args:
301
        google_project_validity(GoogleProjectValidity): google project validity
302

303
    Returns:
304
        List[str]: the reason(s) project was removed
305
    """
306
    removal_reasons = []
×
307

308
    if google_project_validity is None:
×
309
        return removal_reasons
×
310

311
    if google_project_validity["user_has_access"] is False:
×
312
        removal_reasons.append("User isn't a member on the Google Project.")
×
313

314
    if google_project_validity["monitor_has_access"] is False:
×
315
        removal_reasons.append(
×
316
            "Cannot access the project, ensure monitoring service accounts have necessary permissions."
317
        )
318

319
    if google_project_validity["valid_parent_org"] is False:
×
320
        removal_reasons.append("Google Project has a parent orgnization.")
×
321

322
    if google_project_validity["valid_member_types"] is False:
×
323
        removal_reasons.append(
×
324
            "There are members in the Google Project other than Google Users or Google Service Accounts."
325
        )
326

327
    if google_project_validity["members_exist_in_fence"] is False:
×
328
        removal_reasons.append(
×
329
            "Some Google Users on the Google Project do not exist in authentication database."
330
        )
331

332
    return removal_reasons
×
333

334

335
def _get_invalid_sa_project_removal_reasons(google_project_validity):
1✔
336
    """
337
    Get invalid non-registered service account removal reasons
338

339
    Args:
340
        google_project_validity(GoogleProjectValidity): google project validity
341

342
    Returns:
343
        dict: service_account_email: ["list of of why removed", "more reasons"]
344
    """
345
    removal_reasons = {}
×
346

347
    if google_project_validity is None:
×
348
        return removal_reasons
×
349

350
    for sa_email, sa_validity in google_project_validity.get("service_accounts", {}):
×
351
        if not sa_validity:
×
352
            removal_reasons[sa_email] = _get_service_account_removal_reasons(
×
353
                sa_validity
354
            )
355

356
    return removal_reasons
×
357

358

359
def _get_access_removal_reasons(google_project_validity):
1✔
360
    removal_reasons = {}
×
361

362
    if google_project_validity is None:
×
363
        return removal_reasons
×
364

365
    for project, access_validity in google_project_validity.get("access", {}):
×
366
        removal_reasons[project] = []
×
367
        if access_validity["exists"] is False:
×
368
            removal_reasons[project].append(
×
369
                "Data access project {} no longer exists.".format(project)
370
            )
371

372
        if access_validity["all_users_have_access"] is False:
×
373
            removal_reasons[project].append(
×
374
                "Not all users on the Google Project have access to data project {}.".format(
375
                    project
376
                )
377
            )
378

379
    return removal_reasons
×
380

381

382
def _get_google_project_ids_from_service_accounts(registered_service_accounts):
1✔
383
    """
384
    Return a set of just the google project ids that have registered
385
    service accounts.
386
    """
387
    google_projects = set([sa.google_project_id for sa in registered_service_accounts])
×
388
    return google_projects
×
389

390

391
def _get_project_service_account_mapping(registered_service_accounts):
1✔
392
    """
393
    Return a dict with google projects as keys and a list of service accounts
394
    as values.
395

396
    Example:
397
    {
398
        'project_a': [
399
            'service_acount_a@email.com',
400
            'service_acount_b@email.com'
401
        ],
402
        'project_b': [
403
            'service_acount_c@email.com',
404
            'service_acount_d@email.com'
405
        ]
406
    }
407
    """
408
    output = {}
1✔
409
    for sa in registered_service_accounts:
1✔
410
        if sa.google_project_id in output:
1✔
411
            output[sa.google_project_id].append(sa.email)
1✔
412
        else:
413
            output[sa.google_project_id] = [sa.email]
1✔
414

415
    return output
1✔
416

417

418
def _get_user_email_list_from_google_project_with_owner_role(project_id):
1✔
419
    """
420
    Get a list of emails associated to google project id
421

422
    Args:
423
        project_id(str): project id
424

425
    Returns:
426
        list(str): list of emails belong to the project
427

428
    """
429

430
    with GoogleCloudManager(project_id, use_default=False) as prj:
×
431
        members = prj.get_project_membership(project_id)
×
432
        users = [
×
433
            member
434
            for member in members
435
            if member.member_type == GooglePolicyMember.USER
436
        ]
437

438
        return list(
×
439
            {
440
                u.email_id
441
                for u in users
442
                for role in u.roles
443
                if role.name.upper() == "OWNER"
444
            }
445
        )
446

447

448
def _send_emails_informing_service_account_removal(
1✔
449
    to_emails, invalid_service_account_reasons, invalid_project_reasons, project_id
450
):
451
    """
452
    Send emails to list of emails
453

454
    Args:
455
        to_emails(list(str)): list of email addaresses
456
        invalid_service_account_reasons(dict): removal reasons of service accounts
457
        project_id(str): google project id
458

459
    Returns:
460
        httpResponse or None: None if input list is empty
461

462
    Exceptions:
463
        ValueError
464

465
    """
466

467
    if not to_emails:
×
468
        return None
×
469

470
    from_email = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["from"]
×
471
    subject = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["subject"]
×
472

473
    domain = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["domain"]
×
474
    if config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["admin"]:
×
475
        to_emails.extend(config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["admin"])
×
476

477
    text = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["content"]
×
478
    content = text.format(project_id)
×
479

480
    for email, removal_reasons in invalid_service_account_reasons.items():
×
481
        if removal_reasons:
×
482
            content += (
×
483
                "\n\t - Service account {} was removed from Google Project {}.".format(
484
                    email, project_id
485
                )
486
            )
487
            for reason in removal_reasons:
×
488
                content += "\n\t\t - {}".format(reason)
×
489

490
    general_project_errors = invalid_project_reasons.get("general", {})
×
491
    non_reg_sa_errors = invalid_project_reasons.get(
×
492
        "non_registered_service_accounts", {}
493
    )
494
    access_errors = invalid_project_reasons.get("access")
×
495
    if general_project_errors or non_reg_sa_errors or access_errors:
×
496
        content += (
×
497
            "\n\t - Google Project {} determined invalid. All service "
498
            "accounts with data access will be removed from access.".format(project_id)
499
        )
500
        for removal_reason in general_project_errors:
×
501
            if removal_reason:
×
502
                content += "\n\t\t - {}".format(removal_reason)
×
503

504
        if access_errors:
×
505
            for project, removal_reasons in access_errors.items():
×
506
                for reason in removal_reasons:
×
507
                    content += "\n\t\t - {}".format(reason)
×
508

509
        if non_reg_sa_errors:
×
510
            for sa_email, removal_reasons in non_reg_sa_errors.items():
×
511
                content += "\n\t\t - Google Project Service Account {} determined invalid.".format(
×
512
                    sa_email
513
                )
514
                for reason in removal_reasons:
×
515
                    content += "\n\t\t\t - {}".format(reason)
×
516

517
    return utils.send_email(from_email, to_emails, subject, content, domain)
×
518

519

520
def _get_users_without_access(db, auth_ids, user_emails, check_linking):
1✔
521
    """
522
    Build list of users without access to projects identified by auth_ids
523

524
    Args:
525
        db (str): database instance
526
        auth_ids (list(str)): list of project auth_ids to check access against
527
        user_emails (list(str)): list of emails to check access for
528
        check_linking (bool): flag to check for linked google email
529

530
    Returns:
531
        dict{str : (list(str))} : dictionary where keys are user emails,
532
        and values are list of project_ids they do not have access to
533

534
    """
535

536
    no_access = {}
1✔
537

538
    for user_email in user_emails:
1✔
539
        user = get_user_by_email(user_email, db) or get_user_by_linked_email(
1✔
540
            user_email, db
541
        )
542

543
        logger.info("Checking access for {}.".format(user.email))
1✔
544

545
        if not user:
1✔
546
            logger.info(
×
547
                "Email ({}) does not exist in fence database.".format(user_email)
548
            )
549
            continue
×
550

551
        if check_linking:
1✔
552
            link_email = get_linked_google_account_email(user.id, db)
×
553
            if not link_email:
×
554
                logger.info(
×
555
                    "User ({}) does not have a linked google account.".format(
556
                        user_email
557
                    )
558
                )
559
                continue
×
560

561
        no_access_auth_ids = []
1✔
562
        for auth_id in auth_ids:
1✔
563
            project = get_project_from_auth_id(auth_id, db)
1✔
564
            if project:
1✔
565
                if not user_has_access_to_project(user, project.id, db):
1✔
566
                    logger.info(
1✔
567
                        "User ({}) does NOT have access to project (auth_id: {})".format(
568
                            user_email, auth_id
569
                        )
570
                    )
571
                    # add to list to send email
572
                    no_access_auth_ids.append(auth_id)
1✔
573
                else:
574
                    logger.info(
1✔
575
                        "User ({}) has access to project (auth_id: {})".format(
576
                            user_email, auth_id
577
                        )
578
                    )
579
            else:
580
                logger.warning("Project (auth_id: {}) does not exist.".format(auth_id))
×
581

582
        if no_access_auth_ids:
1✔
583
            no_access[user_email] = no_access_auth_ids
1✔
584

585
    return no_access
1✔
586

587

588
def email_user_without_access(user_email, projects, google_project_id):
1✔
589
    """
590
    Send email to user, indicating no access to given projects
591

592
    Args:
593
        user_email (str): address to send email to
594
        projects (list(str)):  list of projects user does not have access to that they should
595
        google_project_id (str): id of google project user belongs to
596
    Returns:
597
        HTTP response
598

599
    """
600
    to_emails = [user_email]
×
601

602
    from_email = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["from"]
×
603
    subject = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["subject"]
×
604

605
    domain = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["domain"]
×
606
    if config["PROBLEM_USER_EMAIL_NOTIFICATION"]["admin"]:
×
607
        to_emails.extend(config["PROBLEM_USER_EMAIL_NOTIFICATION"]["admin"])
×
608

609
    text = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["content"]
×
610
    content = text.format(google_project_id, ",".join(projects))
×
611

612
    return utils.send_email(from_email, to_emails, subject, content, domain)
×
613

614

615
def email_users_without_access(
1✔
616
    db, auth_ids, user_emails, check_linking, google_project_id
617
):
618
    """
619
    Build list of users without acess and send emails.
620

621
    Args:
622
        db (str): database instance
623
        auth_ids (list(str)): list of project auth_ids to check access against
624
        user_emails (list(str)): list of emails to check access for
625
        check_linking (bool): flag to check for linked google email
626
    Returns:
627
        None
628
    """
629
    users_without_access = _get_users_without_access(
×
630
        db, auth_ids, user_emails, check_linking
631
    )
632

633
    if len(users_without_access) == len(user_emails):
×
634
        logger.warning(
×
635
            "No user has proper access to provided projects. Contact project administrator. No emails will be sent"
636
        )
637
        return
×
638
    elif len(users_without_access) > 0:
×
639
        logger.info(
×
640
            "Some user(s) do not have proper access to provided projects. Email(s) will be sent to user(s)."
641
        )
642

643
        with GoogleCloudManager(google_project_id) as gcm:
×
644
            members = gcm.get_project_membership(google_project_id)
×
645
            users = []
×
646
            for member in members:
×
647
                if member.member_type == GooglePolicyMember.USER:
×
648
                    users.append(member.email_id)
×
649

650
        for user, projects in users_without_access.items():
×
651
            logger.info(
×
652
                "{} does not have access to the following datasets: {}.".format(
653
                    user, ",".join(projects)
654
                )
655
            )
656
            if user in users:
×
657
                logger.info(
×
658
                    "{} is a member of google project: {}. User will be emailed.".format(
659
                        user, google_project_id
660
                    )
661
                )
662
                email_user_without_access(user, projects, google_project_id)
×
663
            else:
664
                logger.info(
×
665
                    "{} is NOT a member of google project: {}. User will NOT be emailed.".format(
666
                        user, google_project_id
667
                    )
668
                )
669
    else:
670
        logger.info("All users have proper access to provided projects.")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc