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

uc-cdis / fence / 13394405562

18 Feb 2025 03:40PM UTC coverage: 75.268% (-0.02%) from 75.283%
13394405562

Pull #1207

github

web-flow
Merge branch 'master' into chore/ccrypt_usersync
Pull Request #1207: Docker Image Change

7858 of 10440 relevant lines covered (75.27%)

0.75 hits per line

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

82.59
fence/sync/sync_users.py
1
import backoff
1✔
2
import glob
1✔
3
import jwt
1✔
4
import os
1✔
5
import re
1✔
6
import subprocess as sp
1✔
7
import yaml
1✔
8
import copy
1✔
9
import datetime
1✔
10
import uuid
1✔
11
import collections
1✔
12
import hashlib
1✔
13

14
from contextlib import contextmanager
1✔
15
from collections import defaultdict
1✔
16
from csv import DictReader
1✔
17
from io import StringIO
1✔
18
from stat import S_ISDIR
1✔
19

20
import paramiko
1✔
21
from cdislogging import get_logger
1✔
22
from email_validator import validate_email, EmailNotValidError
1✔
23
from gen3authz.client.arborist.errors import ArboristError
1✔
24
from gen3users.validation import validate_user_yaml
1✔
25
from paramiko.proxy import ProxyCommand
1✔
26
from sqlalchemy.exc import IntegrityError
1✔
27
from sqlalchemy import func
1✔
28

29
from fence.config import config
1✔
30
from fence.models import (
1✔
31
    AccessPrivilege,
32
    AuthorizationProvider,
33
    Project,
34
    Tag,
35
    User,
36
    query_for_user,
37
    Client,
38
    IdentityProvider,
39
    get_project_to_authz_mapping,
40
)
41
from fence.resources.google.utils import get_or_create_proxy_group_id
1✔
42
from fence.resources.storage import StorageManager
1✔
43
from fence.resources.google.access_utils import update_google_groups_for_users
1✔
44
from fence.resources.google.access_utils import GoogleUpdateException
1✔
45
from fence.sync import utils
1✔
46
from fence.sync.passport_sync.ras_sync import RASVisa
1✔
47
from fence.utils import get_SQLAlchemyDriver, DEFAULT_BACKOFF_SETTINGS
1✔
48

49

50
def _format_policy_id(path, privilege):
1✔
51
    resource = ".".join(name for name in path.split("/") if name)
1✔
52
    return "{}-{}".format(resource, privilege)
1✔
53

54

55
def download_dir(sftp, remote_dir, local_dir):
1✔
56
    """
57
    Recursively download file from remote_dir to local_dir
58
    Args:
59
        remote_dir(str)
60
        local_dir(str)
61
    Returns: None
62
    """
63
    dir_items = sftp.listdir_attr(remote_dir)
×
64

65
    for item in dir_items:
×
66
        remote_path = remote_dir + "/" + item.filename
×
67
        local_path = os.path.join(local_dir, item.filename)
×
68
        if S_ISDIR(item.st_mode):
×
69
            download_dir(sftp, remote_path, local_path)
×
70
        else:
71
            sftp.get(remote_path, local_path)
×
72

73

74
def arborist_role_for_permission(permission):
1✔
75
    """
76
    For the programs/projects in the existing fence access control model, in order to
77
    use arborist for checking permissions we generate a policy for each combination of
78
    program/project and privilege. The roles involved all contain only one permission,
79
    for one privilege from the project access model.
80
    """
81
    return {
1✔
82
        "id": permission,
83
        "permissions": [
84
            {"id": permission, "action": {"service": "*", "method": permission}}
85
        ],
86
    }
87

88

89
@contextmanager
1✔
90
def _read_file(filepath, encrypted=True, key=None, logger=None):
1✔
91
    """
92
    Context manager for reading and optionally decrypting file it only
93
    decrypts files encrypted by unix 'crypt' tool which is used by dbGaP.
94

95
    Args:
96
        filepath (str): path to the file
97
        encrypted (bool): whether the file is encrypted
98

99
    Returns:
100
        Generator[file-like class]: file like object for the file
101
    """
102
    if encrypted:
1✔
103
        p = sp.Popen(
×
104
            [
105
                "ccdecrypt",
106
                "-u",
107
                "-K",
108
                key,
109
                filepath,
110
            ]
111
        )
112
        try:
×
113
            yield StringIO(p.communicate()[0])
×
114
        except UnicodeDecodeError:
×
115
            logger.error("Could not decode file. Check the decryption key.")
×
116
    else:
117
        f = open(filepath, "r")
1✔
118
        yield f
1✔
119
        f.close()
1✔
120

121

122
class UserYAML(object):
1✔
123
    """
124
    Representation of the information in a YAML file describing user, project, and ABAC
125
    information for access control.
126
    """
127

128
    def __init__(
1✔
129
        self,
130
        projects=None,
131
        user_info=None,
132
        policies=None,
133
        clients=None,
134
        authz=None,
135
        project_to_resource=None,
136
        logger=None,
137
        user_abac=None,
138
    ):
139
        self.projects = projects or {}
1✔
140
        self.user_info = user_info or {}
1✔
141
        self.user_abac = user_abac or {}
1✔
142
        self.policies = policies or {}
1✔
143
        self.clients = clients or {}
1✔
144
        self.authz = authz or {}
1✔
145
        self.project_to_resource = project_to_resource or {}
1✔
146
        self.logger = logger
1✔
147

148
    @classmethod
1✔
149
    def from_file(cls, filepath, encrypted=True, key=None, logger=None):
1✔
150
        """
151
        Add access by "auth_id" to "self.projects" to update the Fence DB.
152
        Add access by "resource" to "self.user_abac" to update Arborist.
153
        """
154
        data = {}
1✔
155
        if filepath:
1✔
156
            with _read_file(filepath, encrypted=encrypted, key=key, logger=logger) as f:
1✔
157
                file_contents = f.read()
1✔
158
                validate_user_yaml(file_contents)  # run user.yaml validation tests
1✔
159
                data = yaml.safe_load(file_contents)
1✔
160
        else:
161
            if logger:
1✔
162
                logger.info("Did not sync a user.yaml, no file path provided.")
1✔
163

164
        projects = dict()
1✔
165
        user_info = dict()
1✔
166
        policies = dict()
1✔
167

168
        # resources should be the resource tree to construct in arborist
169
        user_abac = dict()
1✔
170

171
        # Fall back on rbac block if no authz. Remove when rbac in useryaml fully deprecated.
172
        if not data.get("authz") and data.get("rbac"):
1✔
173
            if logger:
×
174
                logger.info(
×
175
                    "No authz block found but rbac block present. Using rbac block"
176
                )
177
            data["authz"] = data["rbac"]
×
178

179
        # get user project mapping to arborist resources if it exists
180
        project_to_resource = data.get("authz", dict()).get(
1✔
181
            "user_project_to_resource", dict()
182
        )
183

184
        # read projects and privileges for each user
185
        users = data.get("users", {})
1✔
186
        for username, details in users.items():
1✔
187
            # users should occur only once each; skip if already processed
188
            if username in projects:
1✔
189
                msg = "invalid yaml file: user `{}` occurs multiple times".format(
×
190
                    username
191
                )
192
                if logger:
×
193
                    logger.error(msg)
×
194
                raise EnvironmentError(msg)
×
195

196
            privileges = {}
1✔
197
            resource_permissions = dict()
1✔
198
            for project in details.get("projects", {}):
1✔
199
                try:
1✔
200
                    privileges[project["auth_id"]] = set(project["privilege"])
1✔
201
                except KeyError as e:
×
202
                    if logger:
×
203
                        logger.error("project {} missing field: {}".format(project, e))
×
204
                    continue
×
205

206
                # project may not have `resource` field.
207
                # prefer resource field;
208
                # if no resource or mapping, assume auth_id is resource.
209
                resource = project.get("resource", project["auth_id"])
1✔
210

211
                if project["auth_id"] not in project_to_resource:
1✔
212
                    project_to_resource[project["auth_id"]] = resource
1✔
213
                resource_permissions[resource] = set(project["privilege"])
1✔
214

215
            user_info[username] = {
1✔
216
                "email": details.get("email", ""),
217
                "display_name": details.get("display_name", ""),
218
                "phone_number": details.get("phone_number", ""),
219
                "tags": details.get("tags", {}),
220
                "admin": details.get("admin", False),
221
            }
222
            if not details.get("email"):
1✔
223
                try:
1✔
224
                    valid = validate_email(
1✔
225
                        username, allow_smtputf8=False, check_deliverability=False
226
                    )
227
                    user_info[username]["email"] = valid.email
1✔
228
                except EmailNotValidError:
1✔
229
                    pass
1✔
230
            projects[username] = privileges
1✔
231
            user_abac[username] = resource_permissions
1✔
232

233
            # list of policies we want to grant to this user, which get sent to arborist
234
            # to check if they're allowed to do certain things
235
            policies[username] = details.get("policies", [])
1✔
236

237
        if logger:
1✔
238
            logger.info(
1✔
239
                "Got user project to arborist resource mapping:\n{}".format(
240
                    str(project_to_resource)
241
                )
242
            )
243

244
        authz = data.get("authz", dict())
1✔
245
        if not authz:
1✔
246
            # older version: resources in root, no `authz` section or `rbac` section
247
            if logger:
1✔
248
                logger.warning(
1✔
249
                    "access control YAML file is using old format (missing `authz`/`rbac`"
250
                    " section in the root); assuming that if it exists `resources` will"
251
                    " be on the root level, and continuing"
252
                )
253
            # we're going to throw it into the `authz` dictionary anyways, so the rest of
254
            # the code can pretend it's in the normal place that we expect
255
            resources = data.get("resources", [])
1✔
256
            # keep authz empty dict if resources is not specified
257
            if resources:
1✔
258
                authz["resources"] = data.get("resources", [])
×
259

260
        clients = data.get("clients", {})
1✔
261

262
        return cls(
1✔
263
            projects=projects,
264
            user_info=user_info,
265
            user_abac=user_abac,
266
            policies=policies,
267
            clients=clients,
268
            authz=authz,
269
            project_to_resource=project_to_resource,
270
            logger=logger,
271
        )
272

273
    def persist_project_to_resource(self, db_session):
1✔
274
        """
275
        Store the mappings from Project.auth_id to authorization resource (Project.authz)
276

277
        The mapping comes from an external source, this function persists what was parsed
278
        into memory into the database for future use.
279
        """
280
        for auth_id, authz_resource in self.project_to_resource.items():
1✔
281
            project = (
1✔
282
                db_session.query(Project).filter(Project.auth_id == auth_id).first()
283
            )
284
            if project:
1✔
285
                project.authz = authz_resource
1✔
286
            else:
287
                project = Project(name=auth_id, auth_id=auth_id, authz=authz_resource)
×
288
                db_session.add(project)
×
289
        db_session.commit()
1✔
290

291

292
class UserSyncer(object):
1✔
293
    def __init__(
1✔
294
        self,
295
        dbGaP,
296
        DB,
297
        project_mapping,
298
        storage_credentials=None,
299
        db_session=None,
300
        is_sync_from_dbgap_server=False,
301
        sync_from_local_csv_dir=None,
302
        sync_from_local_yaml_file=None,
303
        arborist=None,
304
        folder=None,
305
    ):
306
        """
307
        Syncs ACL files from dbGap to auth database and storage backends
308
        Args:
309
            dbGaP: a list of dict containing creds to access dbgap sftp
310
            DB: database connection string
311
            project_mapping: a dict containing how dbgap ids map to projects
312
            storage_credentials: a dict containing creds for storage backends
313
            sync_from_dir: path to an alternative dir to sync from instead of
314
                           dbGaP
315
            arborist:
316
                ArboristClient instance if the syncer should also create
317
                resources in arborist
318
            folder: a local folder where dbgap telemetry files will sync to
319
        """
320
        self.sync_from_local_csv_dir = sync_from_local_csv_dir
1✔
321
        self.sync_from_local_yaml_file = sync_from_local_yaml_file
1✔
322
        self.is_sync_from_dbgap_server = is_sync_from_dbgap_server
1✔
323
        self.dbGaP = dbGaP
1✔
324
        self.session = db_session
1✔
325
        self.driver = get_SQLAlchemyDriver(DB)
1✔
326
        self.project_mapping = project_mapping or {}
1✔
327
        self._projects = dict()
1✔
328
        self._created_roles = set()
1✔
329
        self._created_policies = set()
1✔
330
        self._dbgap_study_to_resources = dict()
1✔
331
        self.logger = get_logger(
1✔
332
            "user_syncer", log_level="debug" if config["DEBUG"] is True else "info"
333
        )
334
        self.arborist_client = arborist
1✔
335
        self.folder = folder
1✔
336

337
        self.auth_source = defaultdict(set)
1✔
338
        # auth_source used for logging. username : [source1, source2]
339
        self.visa_types = config.get("USERSYNC", {}).get("visa_types", {})
1✔
340
        self.parent_to_child_studies_mapping = {}
1✔
341
        for dbgap_config in dbGaP:
1✔
342
            self.parent_to_child_studies_mapping.update(
1✔
343
                dbgap_config.get("parent_to_child_studies_mapping", {})
344
            )
345
        if storage_credentials:
1✔
346
            self.storage_manager = StorageManager(
1✔
347
                storage_credentials, logger=self.logger
348
            )
349
        self.id_patterns = []
1✔
350

351
    @staticmethod
1✔
352
    def _match_pattern(filepath, id_patterns, encrypted=True):
1✔
353
        """
354
        Check if the filename matches dbgap access control file pattern
355

356
        Args:
357
            filepath (str): path to file
358
            encrypted (bool): whether the file is encrypted
359

360
        Returns:
361
            bool: whether the pattern matches
362
        """
363
        id_patterns.append(r"authentication_file_phs(\d{6}).(csv|txt)")
1✔
364
        for pattern in id_patterns:
1✔
365
            if encrypted:
1✔
366
                pattern += r".enc"
×
367
            pattern += r"$"
1✔
368
            # when converting the YAML from fence-config,
369
            # python reads it as Python string literal. So "\" turns into "\\"
370
            # which messes with the regex match
371
            pattern.replace("\\\\", "\\")
1✔
372
            if re.match(pattern, os.path.basename(filepath)):
1✔
373
                return True
1✔
374
        return False
1✔
375

376
    def _get_from_sftp_with_proxy(self, server, path):
1✔
377
        """
378
        Download all data from sftp sever to a local dir
379

380
        Args:
381
            server (dict) : dictionary containing info to access sftp server
382
            path (str): path to local directory
383

384
        Returns:
385
            None
386
        """
387
        proxy = None
1✔
388
        if server.get("proxy", "") != "":
1✔
389
            command = "ssh -oHostKeyAlgorithms=+ssh-rsa -i ~/.ssh/id_rsa {user}@{proxy} nc {host} {port}".format(
×
390
                user=server.get("proxy_user", ""),
391
                proxy=server.get("proxy", ""),
392
                host=server.get("host", ""),
393
                port=server.get("port", 22),
394
            )
395
            self.logger.info("SSH proxy command: {}".format(command))
×
396

397
            proxy = ProxyCommand(command)
×
398

399
        with paramiko.SSHClient() as client:
1✔
400
            client.set_log_channel(self.logger.name)
1✔
401

402
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
1✔
403
            parameters = {
1✔
404
                "hostname": str(server.get("host", "")),
405
                "username": str(server.get("username", "")),
406
                "password": str(server.get("password", "")),
407
                "port": int(server.get("port", 22)),
408
            }
409
            if proxy:
1✔
410
                parameters["sock"] = proxy
×
411

412
            self.logger.info(
1✔
413
                "SSH connection hostname:post {}:{}".format(
414
                    parameters.get("hostname", "unknown"),
415
                    parameters.get("port", "unknown"),
416
                )
417
            )
418
            self._connect_with_ssh(ssh_client=client, parameters=parameters)
1✔
419
            with client.open_sftp() as sftp:
×
420
                download_dir(sftp, "./", path)
1✔
421

422
        if proxy:
×
423
            proxy.close()
×
424

425
    @backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS)
1✔
426
    def _connect_with_ssh(self, ssh_client, parameters):
1✔
427
        ssh_client.connect(**parameters)
1✔
428

429
    def _get_from_ftp_with_proxy(self, server, path):
1✔
430
        """
431
        Download data from ftp sever to a local dir
432

433
        Args:
434
            server (dict): dictionary containing information for accessing server
435
            path(str): path to local files
436

437
        Returns:
438
            None
439
        """
440
        execstr = (
×
441
            'lftp -u {},{}  {} -e "set ftp:proxy http://{}; mirror . {}; exit"'.format(
442
                server.get("username", ""),
443
                server.get("password", ""),
444
                server.get("host", ""),
445
                server.get("proxy", ""),
446
                path,
447
            )
448
        )
449
        os.system(execstr)
×
450

451
    def _get_parse_consent_code(self, dbgap_config={}):
1✔
452
        return dbgap_config.get(
1✔
453
            "parse_consent_code", True
454
        )  # Should this really be true?
455

456
    def _parse_csv(self, file_dict, sess, dbgap_config={}, encrypted=True):
1✔
457
        """
458
        parse csv files to python dict
459

460
        Args:
461
            file_dict: a dictionary with key(file path) and value(privileges)
462
            sess: sqlalchemy session
463
            dbgap_config: a dictionary containing information about the dbGaP sftp server
464
                (comes from fence config)
465
            encrypted: boolean indicating whether those files are encrypted
466

467

468
        Return:
469
            Tuple[[dict, dict]]:
470
                (user_project, user_info) where user_project is a mapping from
471
                usernames to project permissions and user_info is a mapping
472
                from usernames to user details, such as email
473

474
        Example:
475

476
            (
477
                {
478
                    username: {
479
                        'project1': {'read-storage','write-storage'},
480
                        'project2': {'read-storage'},
481
                    }
482
                },
483
                {
484
                    username: {
485
                        'email': 'email@mail.com',
486
                        'display_name': 'display name',
487
                        'phone_number': '123-456-789',
488
                        'tags': {'dbgap_role': 'PI'}
489
                    }
490
                },
491
            )
492

493
        """
494
        user_projects = dict()
1✔
495
        user_info = defaultdict(dict)
1✔
496

497
        # parse dbGaP sftp server information
498
        dbgap_key = dbgap_config.get("decrypt_key", None)
1✔
499

500
        self.id_patterns += (
1✔
501
            [
502
                item.replace("\\\\", "\\")
503
                for item in dbgap_config.get("allowed_whitelist_patterns", [])
504
            ]
505
            if dbgap_config.get("allow_non_dbGaP_whitelist", False)
506
            else []
507
        )
508

509
        enable_common_exchange_area_access = dbgap_config.get(
1✔
510
            "enable_common_exchange_area_access", False
511
        )
512
        study_common_exchange_areas = dbgap_config.get(
1✔
513
            "study_common_exchange_areas", {}
514
        )
515
        parse_consent_code = self._get_parse_consent_code(dbgap_config)
1✔
516

517
        if parse_consent_code and enable_common_exchange_area_access:
1✔
518
            self.logger.info(
1✔
519
                f"using study to common exchange area mapping: {study_common_exchange_areas}"
520
            )
521

522
        project_id_patterns = [r"phs(\d{6})"]
1✔
523
        if "additional_allowed_project_id_patterns" in dbgap_config:
1✔
524
            patterns = dbgap_config.get("additional_allowed_project_id_patterns")
1✔
525
            patterns = [
1✔
526
                pattern.replace("\\\\", "\\") for pattern in patterns
527
            ]  # when converting the YAML from fence-config, python reads it as Python string literal. So "\" turns into "\\" which messes with the regex match
528
            project_id_patterns += patterns
1✔
529

530
        self.logger.info(f"Using these file paths: {file_dict.items()}")
1✔
531
        for filepath, privileges in file_dict.items():
1✔
532
            self.logger.info("Reading file {}".format(filepath))
1✔
533
            if os.stat(filepath).st_size == 0:
1✔
534
                self.logger.warning("Empty file {}".format(filepath))
×
535
                continue
×
536
            if not self._match_pattern(
1✔
537
                filepath, id_patterns=self.id_patterns, encrypted=encrypted
538
            ):
539
                self.logger.warning(
1✔
540
                    "Filename {} does not match dbgap access control filename pattern;"
541
                    " this could mean that the filename has an invalid format, or has"
542
                    " an unexpected .enc extension, or lacks the .enc extension where"
543
                    " expected. This file is NOT being processed by usersync!".format(
544
                        filepath
545
                    )
546
                )
547
                continue
1✔
548

549
            with _read_file(
1✔
550
                filepath, encrypted=encrypted, key=dbgap_key, logger=self.logger
551
            ) as f:
552
                csv = DictReader(f, quotechar='"', skipinitialspace=True)
1✔
553
                for row in csv:
1✔
554
                    username = row.get("login") or ""
1✔
555
                    if username == "":
1✔
556
                        continue
×
557

558
                    if dbgap_config.get("allow_non_dbGaP_whitelist", False):
1✔
559
                        phsid = (
1✔
560
                            row.get("phsid") or (row.get("project_id") or "")
561
                        ).split(".")
562
                    else:
563
                        phsid = (row.get("phsid") or "").split(".")
1✔
564

565
                    dbgap_project = phsid[0]
1✔
566
                    # There are issues where dbgap has a wrong entry in their whitelist. Since we do a bulk arborist request, there are wrong entries in it that invalidates the whole request causing other correct entries not to be added
567
                    skip = False
1✔
568
                    for pattern in project_id_patterns:
1✔
569
                        self.logger.debug(
1✔
570
                            "Checking pattern:{} with project_id:{}".format(
571
                                pattern, dbgap_project
572
                            )
573
                        )
574
                        if re.match(pattern, dbgap_project):
1✔
575
                            skip = False
1✔
576
                            break
1✔
577
                        else:
578
                            skip = True
1✔
579
                    if skip:
1✔
580
                        self.logger.warning(
1✔
581
                            "Skip processing from file {}, user {} with project {}".format(
582
                                filepath,
583
                                username,
584
                                dbgap_project,
585
                            )
586
                        )
587
                        continue
1✔
588
                    if len(phsid) > 1 and parse_consent_code:
1✔
589
                        consent_code = phsid[-1]
1✔
590

591
                        # c999 indicates full access to all consents and access
592
                        # to a study-specific exchange area
593
                        # access to at least one study-specific exchange area implies access
594
                        # to the parent study's common exchange area
595
                        #
596
                        # NOTE: Handling giving access to all consents is done at
597
                        #       a later time, when we have full information about possible
598
                        #       consents
599
                        self.logger.debug(
1✔
600
                            f"got consent code {consent_code} from dbGaP project "
601
                            f"{dbgap_project}"
602
                        )
603
                        if (
1✔
604
                            consent_code == "c999"
605
                            and enable_common_exchange_area_access
606
                            and dbgap_project in study_common_exchange_areas
607
                        ):
608
                            self.logger.info(
1✔
609
                                "found study with consent c999 and Fence "
610
                                "is configured to parse exchange area data. Giving user "
611
                                f"{username} {privileges} privileges in project: "
612
                                f"{study_common_exchange_areas[dbgap_project]}."
613
                            )
614
                            self._add_dbgap_project_for_user(
1✔
615
                                study_common_exchange_areas[dbgap_project],
616
                                privileges,
617
                                username,
618
                                sess,
619
                                user_projects,
620
                                dbgap_config,
621
                            )
622

623
                        dbgap_project += "." + consent_code
1✔
624

625
                    self._add_children_for_dbgap_project(
1✔
626
                        dbgap_project,
627
                        privileges,
628
                        username,
629
                        sess,
630
                        user_projects,
631
                        dbgap_config,
632
                    )
633

634
                    display_name = row.get("user name") or ""
1✔
635
                    tags = {"dbgap_role": row.get("role") or ""}
1✔
636

637
                    # some dbgap telemetry files have information about a researchers PI
638
                    if "downloader for" in row:
1✔
639
                        tags["pi"] = row["downloader for"]
1✔
640

641
                    # prefer name over previous "downloader for" if it exists
642
                    if "downloader for names" in row:
1✔
643
                        tags["pi"] = row["downloader for names"]
×
644

645
                    user_info[username] = {
1✔
646
                        "email": row.get("email")
647
                        or user_info[username].get("email")
648
                        or "",
649
                        "display_name": display_name,
650
                        "phone_number": row.get("phone")
651
                        or user_info[username].get("phone_number")
652
                        or "",
653
                        "tags": tags,
654
                    }
655

656
                    self._process_dbgap_project(
1✔
657
                        dbgap_project,
658
                        privileges,
659
                        username,
660
                        sess,
661
                        user_projects,
662
                        dbgap_config,
663
                    )
664

665
        return user_projects, user_info
1✔
666

667
    def _get_children(self, dbgap_project):
1✔
668
        return self.parent_to_child_studies_mapping.get(dbgap_project.split(".")[0])
1✔
669

670
    def _add_children_for_dbgap_project(
1✔
671
        self, dbgap_project, privileges, username, sess, user_projects, dbgap_config
672
    ):
673
        """
674
        Adds the configured child studies for the given dbgap_project, adding it to the provided user_projects. If
675
        parse_consent_code is true, then the consents granted in the provided dbgap_project will also be granted to the
676
        child studies.
677
        """
678
        parent_phsid = dbgap_project
1✔
679
        parse_consent_code = self._get_parse_consent_code(dbgap_config)
1✔
680
        child_suffix = ""
1✔
681
        if parse_consent_code and re.match(
1✔
682
            config["DBGAP_ACCESSION_WITH_CONSENT_REGEX"], dbgap_project
683
        ):
684
            parent_phsid_parts = dbgap_project.split(".")
1✔
685
            parent_phsid = parent_phsid_parts[0]
1✔
686
            child_suffix = "." + parent_phsid_parts[1]
1✔
687

688
        if parent_phsid not in self.parent_to_child_studies_mapping:
1✔
689
            return
1✔
690

691
        self.logger.info(
1✔
692
            f"found parent study {parent_phsid} and Fence "
693
            "is configured to provide additional access to child studies. Giving user "
694
            f"{username} {privileges} privileges in projects: "
695
            f"{{k + child_suffix: v + child_suffix for k, v in self.parent_to_child_studies_mapping.items()}}."
696
        )
697
        child_studies = self.parent_to_child_studies_mapping.get(parent_phsid, [])
1✔
698
        for child_study in child_studies:
1✔
699
            self._add_dbgap_project_for_user(
1✔
700
                child_study + child_suffix,
701
                privileges,
702
                username,
703
                sess,
704
                user_projects,
705
                dbgap_config,
706
            )
707

708
    def _add_dbgap_project_for_user(
1✔
709
        self, dbgap_project, privileges, username, sess, user_projects, dbgap_config
710
    ):
711
        """
712
        Helper function for csv parsing that adds a given dbgap project to Fence/Arborist
713
        and then updates the dictionary containing all user's project access
714
        """
715
        if dbgap_project not in self._projects:
1✔
716
            self.logger.debug(
1✔
717
                "creating Project in fence for dbGaP study: {}".format(dbgap_project)
718
            )
719

720
            project = self._get_or_create(sess, Project, auth_id=dbgap_project)
1✔
721

722
            # need to add dbgap project to arborist
723
            if self.arborist_client:
1✔
724
                self._determine_arborist_resource(dbgap_project, dbgap_config)
1✔
725

726
            if project.name is None:
1✔
727
                project.name = dbgap_project
1✔
728
            self._projects[dbgap_project] = project
1✔
729
        phsid_privileges = {dbgap_project: set(privileges)}
1✔
730
        if username in user_projects:
1✔
731
            user_projects[username].update(phsid_privileges)
1✔
732
        else:
733
            user_projects[username] = phsid_privileges
1✔
734

735
    @staticmethod
1✔
736
    def sync_two_user_info_dict(user_info1, user_info2):
1✔
737
        """
738
        Merge user_info1 into user_info2. Values in user_info2 are overriden
739
        by values in user_info1. user_info2 ends up containing the merged dict.
740

741
        Args:
742
            user_info1 (dict): nested dict
743
            user_info2 (dict): nested dict
744

745
            Example:
746
            {username: {'email': 'abc@email.com'}}
747

748
        Returns:
749
            None
750
        """
751
        user_info2.update(user_info1)
1✔
752

753
    def sync_two_phsids_dict(
1✔
754
        self,
755
        phsids1,
756
        phsids2,
757
        source1=None,
758
        source2=None,
759
        phsids2_overrides_phsids1=True,
760
    ):
761
        """
762
        Merge phsids1 into phsids2. If `phsids2_overrides_phsids1`, values in
763
        phsids1 are overriden by values in phsids2. phsids2 ends up containing
764
        the merged dict (see explanation below).
765
        `source1` and `source2`: for logging.
766

767
        Args:
768
            phsids1, phsids2: nested dicts mapping phsids to sets of permissions
769

770
            source1, source2: source of authz information (eg. dbgap, user_yaml, visas)
771

772
            Example:
773
            {
774
                username: {
775
                    phsid1: {'read-storage','write-storage'},
776
                    phsid2: {'read-storage'},
777
                }
778
            }
779

780
        Return:
781
            None
782

783
        Explanation:
784
            Consider merging projects of the same user:
785

786
                {user1: {phsid1: privillege1}}
787

788
                {user1: {phsid2: privillege2}}
789

790
            case 1: phsid1 != phsid2. Output:
791

792
                {user1: {phsid1: privillege1, phsid2: privillege2}}
793

794
            case 2: phsid1 == phsid2 and privillege1! = privillege2. Output:
795

796
                {user1: {phsid1: union(privillege1, privillege2)}}
797

798
            For the other cases, just simple addition
799
        """
800

801
        for user, projects1 in phsids1.items():
1✔
802
            if not phsids2.get(user):
1✔
803
                if source1:
1✔
804
                    self.auth_source[user].add(source1)
1✔
805
                phsids2[user] = projects1
1✔
806
            elif phsids2_overrides_phsids1:
1✔
807
                if source1:
1✔
808
                    self.auth_source[user].add(source1)
×
809
                if source2:
1✔
810
                    self.auth_source[user].add(source2)
×
811
                for phsid1, privilege1 in projects1.items():
1✔
812
                    if phsid1 not in phsids2[user]:
1✔
813
                        phsids2[user][phsid1] = set()
1✔
814
                    phsids2[user][phsid1].update(privilege1)
1✔
815
            elif source2:
×
816
                self.auth_source[user].add(source2)
×
817

818
    def sync_to_db_and_storage_backend(
1✔
819
        self,
820
        user_project,
821
        user_info,
822
        sess,
823
        do_not_revoke_from_db_and_storage=False,
824
        expires=None,
825
    ):
826
        """
827
        sync user access control to database and storage backend
828

829
        Args:
830
            user_project (dict): a dictionary of
831

832
                {
833
                    username: {
834
                        'project1': {'read-storage','write-storage'},
835
                        'project2': {'read-storage'}
836
                    }
837
                }
838

839
            user_info (dict): a dictionary of {username: user_info{}}
840
            sess: a sqlalchemy session
841

842
        Return:
843
            None
844
        """
845
        google_bulk_mapping = None
1✔
846
        if config["GOOGLE_BULK_UPDATES"]:
1✔
847
            google_bulk_mapping = {}
1✔
848

849
        self._init_projects(user_project, sess)
1✔
850

851
        auth_provider_list = [
1✔
852
            self._get_or_create(sess, AuthorizationProvider, name="dbGaP"),
853
            self._get_or_create(sess, AuthorizationProvider, name="fence"),
854
        ]
855

856
        cur_db_user_project_list = {
1✔
857
            (ua.user.username.lower(), ua.project.auth_id)
858
            for ua in sess.query(AccessPrivilege).all()
859
        }
860

861
        # we need to compare db -> whitelist case-insensitively for username.
862
        # db stores case-sensitively, but we need to query case-insensitively
863
        user_project_lowercase = {}
1✔
864
        syncing_user_project_list = set()
1✔
865
        for username, projects in user_project.items():
1✔
866
            user_project_lowercase[username.lower()] = projects
1✔
867
            for project, _ in projects.items():
1✔
868
                syncing_user_project_list.add((username.lower(), project))
1✔
869

870
        user_info_lowercase = {
1✔
871
            username.lower(): info for username, info in user_info.items()
872
        }
873

874
        to_delete = set.difference(cur_db_user_project_list, syncing_user_project_list)
1✔
875
        to_add = set.difference(syncing_user_project_list, cur_db_user_project_list)
1✔
876
        to_update = set.intersection(
1✔
877
            cur_db_user_project_list, syncing_user_project_list
878
        )
879

880
        # when updating users we want to maintain case sesitivity in the username so
881
        # pass the original, non-lowered user_info dict
882
        self._upsert_userinfo(sess, user_info)
1✔
883

884
        if not do_not_revoke_from_db_and_storage:
1✔
885
            self._revoke_from_storage(
1✔
886
                to_delete, sess, google_bulk_mapping=google_bulk_mapping
887
            )
888
            self._revoke_from_db(sess, to_delete)
1✔
889

890
        self._grant_from_storage(
1✔
891
            to_add,
892
            user_project_lowercase,
893
            sess,
894
            google_bulk_mapping=google_bulk_mapping,
895
            expires=expires,
896
        )
897

898
        self._grant_from_db(
1✔
899
            sess,
900
            to_add,
901
            user_info_lowercase,
902
            user_project_lowercase,
903
            auth_provider_list,
904
        )
905

906
        # re-grant
907
        self._grant_from_storage(
1✔
908
            to_update,
909
            user_project_lowercase,
910
            sess,
911
            google_bulk_mapping=google_bulk_mapping,
912
            expires=expires,
913
        )
914
        self._update_from_db(sess, to_update, user_project_lowercase)
1✔
915

916
        if not do_not_revoke_from_db_and_storage:
1✔
917
            self._validate_and_update_user_admin(sess, user_info_lowercase)
1✔
918

919
        sess.commit()
1✔
920

921
        if config["GOOGLE_BULK_UPDATES"]:
1✔
922
            self.logger.info("Doing bulk Google update...")
1✔
923
            update_google_groups_for_users(google_bulk_mapping)
1✔
924
            self.logger.info("Bulk Google update done!")
×
925

926
        sess.commit()
1✔
927

928
    def sync_to_storage_backend(
1✔
929
        self, user_project, user_info, sess, expires, skip_google_updates=False
930
    ):
931
        """
932
        sync user access control to storage backend with given expiration
933

934
        Args:
935
            user_project (dict): a dictionary of
936

937
                {
938
                    username: {
939
                        'project1': {'read-storage','write-storage'},
940
                        'project2': {'read-storage'}
941
                    }
942
                }
943

944
            user_info (dict): a dictionary of attributes for a user.
945
            sess: a sqlalchemy session
946
            expires (int): time at which synced Arborist policies and
947
                   inclusion in any GBAG are set to expire
948
            skip_google_updates (bool): True if google group updates should be skipped. False if otherwise.
949
        Return:
950
            None
951
        """
952
        if not expires:
1✔
953
            raise Exception(
×
954
                f"sync to storage backend requires an expiration. you provided: {expires}"
955
            )
956

957
        google_group_user_mapping = None
1✔
958
        if config["GOOGLE_BULK_UPDATES"]:
1✔
959
            google_group_user_mapping = {}
×
960
            get_or_create_proxy_group_id(
×
961
                expires=expires,
962
                user_id=user_info["user_id"],
963
                username=user_info["username"],
964
                session=sess,
965
                storage_manager=self.storage_manager,
966
            )
967

968
        # TODO: eventually it'd be nice to remove this step but it's required
969
        #       so that grant_from_storage can determine what storage backends
970
        #       are needed for a project.
971
        self._init_projects(user_project, sess)
1✔
972

973
        # we need to compare db -> whitelist case-insensitively for username.
974
        # db stores case-sensitively, but we need to query case-insensitively
975
        user_project_lowercase = {}
1✔
976
        syncing_user_project_list = set()
1✔
977
        for username, projects in user_project.items():
1✔
978
            user_project_lowercase[username.lower()] = projects
1✔
979
            for project, _ in projects.items():
1✔
980
                syncing_user_project_list.add((username.lower(), project))
1✔
981

982
        to_add = set(syncing_user_project_list)
1✔
983

984
        # when updating users we want to maintain case sensitivity in the username so
985
        # pass the original, non-lowered user_info dict
986
        self._upsert_userinfo(sess, {user_info["username"].lower(): user_info})
1✔
987
        if not skip_google_updates:
1✔
988
            self._grant_from_storage(
1✔
989
                to_add,
990
                user_project_lowercase,
991
                sess,
992
                google_bulk_mapping=google_group_user_mapping,
993
                expires=expires,
994
            )
995

996
            if config["GOOGLE_BULK_UPDATES"]:
1✔
997
                self.logger.info("Updating user's google groups ...")
×
998
                update_google_groups_for_users(google_group_user_mapping)
×
999
                self.logger.info("Google groups update done!!")
×
1000

1001
        sess.commit()
1✔
1002

1003
    def _revoke_from_db(self, sess, to_delete):
1✔
1004
        """
1005
        Revoke user access to projects in the auth database
1006

1007
        Args:
1008
            sess: sqlalchemy session
1009
            to_delete: a set of (username, project.auth_id) to be revoked from db
1010
        Return:
1011
            None
1012
        """
1013
        for username, project_auth_id in to_delete:
1✔
1014
            q = (
1✔
1015
                sess.query(AccessPrivilege)
1016
                .filter(AccessPrivilege.project.has(auth_id=project_auth_id))
1017
                .join(AccessPrivilege.user)
1018
                .filter(func.lower(User.username) == username)
1019
                .all()
1020
            )
1021
            for access in q:
1✔
1022
                self.logger.info(
1✔
1023
                    "revoke {} access to {} in db".format(username, project_auth_id)
1024
                )
1025
                sess.delete(access)
1✔
1026

1027
    def _validate_and_update_user_admin(self, sess, user_info):
1✔
1028
        """
1029
        Make sure there is no admin user that is not in yaml/csv files
1030

1031
        Args:
1032
            sess: sqlalchemy session
1033
            user_info: a dict of
1034
            {
1035
                username: {
1036
                    'email': email,
1037
                    'display_name': display_name,
1038
                    'phone_number': phonenum,
1039
                    'tags': {'k1':'v1', 'k2': 'v2'}
1040
                    'admin': is_admin
1041
                }
1042
            }
1043
        Returns:
1044
            None
1045
        """
1046
        for admin_user in sess.query(User).filter_by(is_admin=True).all():
1✔
1047
            if admin_user.username.lower() not in user_info:
1✔
1048
                admin_user.is_admin = False
×
1049
                sess.add(admin_user)
×
1050
                self.logger.info(
×
1051
                    "remove admin access from {} in db".format(
1052
                        admin_user.username.lower()
1053
                    )
1054
                )
1055

1056
    def _update_from_db(self, sess, to_update, user_project):
1✔
1057
        """
1058
        Update user access to projects in the auth database
1059

1060
        Args:
1061
            sess: sqlalchemy session
1062
            to_update:
1063
                a set of (username, project.auth_id) to be updated from db
1064

1065
        Return:
1066
            None
1067
        """
1068

1069
        for username, project_auth_id in to_update:
1✔
1070
            q = (
1✔
1071
                sess.query(AccessPrivilege)
1072
                .filter(AccessPrivilege.project.has(auth_id=project_auth_id))
1073
                .join(AccessPrivilege.user)
1074
                .filter(func.lower(User.username) == username)
1075
                .all()
1076
            )
1077
            for access in q:
1✔
1078
                access.privilege = user_project[username][project_auth_id]
1✔
1079
                self.logger.info(
1✔
1080
                    "update {} with {} access to {} in db".format(
1081
                        username, access.privilege, project_auth_id
1082
                    )
1083
                )
1084

1085
    def _grant_from_db(self, sess, to_add, user_info, user_project, auth_provider_list):
1✔
1086
        """
1087
        Grant user access to projects in the auth database
1088
        Args:
1089
            sess: sqlalchemy session
1090
            to_add: a set of (username, project.auth_id) to be granted
1091
            user_project:
1092
                a dictionary of {username: {project: {'read','write'}}
1093
        Return:
1094
            None
1095
        """
1096
        for username, project_auth_id in to_add:
1✔
1097
            u = query_for_user(session=sess, username=username)
1✔
1098

1099
            auth_provider = auth_provider_list[0]
1✔
1100
            if "dbgap_role" not in user_info[username]["tags"]:
1✔
1101
                auth_provider = auth_provider_list[1]
1✔
1102
            user_access = AccessPrivilege(
1✔
1103
                user=u,
1104
                project=self._projects[project_auth_id],
1105
                privilege=list(user_project[username][project_auth_id]),
1106
                auth_provider=auth_provider,
1107
            )
1108
            self.logger.info(
1✔
1109
                "grant user {} to {} with access {}".format(
1110
                    username, user_access.project, user_access.privilege
1111
                )
1112
            )
1113
            sess.add(user_access)
1✔
1114

1115
    def _upsert_userinfo(self, sess, user_info):
1✔
1116
        """
1117
        update user info to database.
1118

1119
        Args:
1120
            sess: sqlalchemy session
1121
            user_info:
1122
                a dict of {username: {display_name, phone_number, tags, admin}
1123

1124
        Return:
1125
            None
1126
        """
1127

1128
        for username in user_info:
1✔
1129
            u = query_for_user(session=sess, username=username)
1✔
1130

1131
            if u is None:
1✔
1132
                self.logger.info("create user {}".format(username))
1✔
1133
                u = User(username=username)
1✔
1134
                sess.add(u)
1✔
1135

1136
            if self.arborist_client:
1✔
1137
                self.arborist_client.create_user({"name": username})
1✔
1138

1139
            u.email = user_info[username].get("email", "")
1✔
1140
            u.display_name = user_info[username].get("display_name", "")
1✔
1141
            u.phone_number = user_info[username].get("phone_number", "")
1✔
1142
            u.is_admin = user_info[username].get("admin", False)
1✔
1143

1144
            idp_name = user_info[username].get("idp_name", "")
1✔
1145
            if idp_name and not u.identity_provider:
1✔
1146
                idp = (
×
1147
                    sess.query(IdentityProvider)
1148
                    .filter(IdentityProvider.name == idp_name)
1149
                    .first()
1150
                )
1151
                if not idp:
×
1152
                    idp = IdentityProvider(name=idp_name)
×
1153
                u.identity_provider = idp
×
1154

1155
            # do not update if there is no tag
1156
            if not user_info[username].get("tags"):
1✔
1157
                continue
1✔
1158

1159
            # remove user db tags if they are not shown in new tags
1160
            for tag in u.tags:
1✔
1161
                if tag.key not in user_info[username]["tags"]:
1✔
1162
                    u.tags.remove(tag)
1✔
1163

1164
            # sync
1165
            for k, v in user_info[username]["tags"].items():
1✔
1166
                found = False
1✔
1167
                for tag in u.tags:
1✔
1168
                    if tag.key == k:
1✔
1169
                        found = True
1✔
1170
                        tag.value = v
1✔
1171
                # create new tag if not found
1172
                if not found:
1✔
1173
                    tag = Tag(key=k, value=v)
1✔
1174
                    u.tags.append(tag)
1✔
1175

1176
    def _revoke_from_storage(self, to_delete, sess, google_bulk_mapping=None):
1✔
1177
        """
1178
        If a project have storage backend, revoke user's access to buckets in
1179
        the storage backend.
1180

1181
        Args:
1182
            to_delete: a set of (username, project.auth_id) to be revoked
1183

1184
        Return:
1185
            None
1186
        """
1187
        for username, project_auth_id in to_delete:
1✔
1188
            project = (
1✔
1189
                sess.query(Project).filter(Project.auth_id == project_auth_id).first()
1190
            )
1191
            for sa in project.storage_access:
1✔
1192
                if not hasattr(self, "storage_manager"):
1✔
1193
                    self.logger.error(
×
1194
                        (
1195
                            "CANNOT revoke {} access to {} in {} because there is NO "
1196
                            "configured storage accesses at all. See configuration. "
1197
                            "Continuing anyway..."
1198
                        ).format(username, project_auth_id, sa.provider.name)
1199
                    )
1200
                    continue
×
1201

1202
                self.logger.info(
1✔
1203
                    "revoke {} access to {} in {}".format(
1204
                        username, project_auth_id, sa.provider.name
1205
                    )
1206
                )
1207
                self.storage_manager.revoke_access(
1✔
1208
                    provider=sa.provider.name,
1209
                    username=username,
1210
                    project=project,
1211
                    session=sess,
1212
                    google_bulk_mapping=google_bulk_mapping,
1213
                )
1214

1215
    def _grant_from_storage(
1✔
1216
        self, to_add, user_project, sess, google_bulk_mapping=None, expires=None
1217
    ):
1218
        """
1219
        If a project have storage backend, grant user's access to buckets in
1220
        the storage backend.
1221

1222
        Args:
1223
            to_add: a set of (username, project.auth_id)  to be granted
1224
            user_project: a dictionary like:
1225

1226
                    {username: {phsid: {'read-storage','write-storage'}}}
1227

1228
        Return:
1229
            dict of the users' storage usernames to their user_projects and the respective storage access.
1230
        """
1231
        storage_user_to_sa_and_user_project = defaultdict()
1✔
1232
        for username, project_auth_id in to_add:
1✔
1233
            project = self._projects[project_auth_id]
1✔
1234
            for sa in project.storage_access:
1✔
1235
                access = list(user_project[username][project_auth_id])
1✔
1236
                if not hasattr(self, "storage_manager"):
1✔
1237
                    self.logger.error(
×
1238
                        (
1239
                            "CANNOT grant {} access {} to {} in {} because there is NO "
1240
                            "configured storage accesses at all. See configuration. "
1241
                            "Continuing anyway..."
1242
                        ).format(username, access, project_auth_id, sa.provider.name)
1243
                    )
1244
                    continue
×
1245

1246
                self.logger.info(
1✔
1247
                    "grant {} access {} to {} in {}".format(
1248
                        username, access, project_auth_id, sa.provider.name
1249
                    )
1250
                )
1251
                storage_username = self.storage_manager.grant_access(
1✔
1252
                    provider=sa.provider.name,
1253
                    username=username,
1254
                    project=project,
1255
                    access=access,
1256
                    session=sess,
1257
                    google_bulk_mapping=google_bulk_mapping,
1258
                    expires=expires,
1259
                )
1260

1261
                storage_user_to_sa_and_user_project[storage_username] = (sa, project)
1✔
1262
        return storage_user_to_sa_and_user_project
1✔
1263

1264
    def _init_projects(self, user_project, sess):
1✔
1265
        """
1266
        initialize projects
1267
        """
1268
        if self.project_mapping:
1✔
1269
            for projects in list(self.project_mapping.values()):
1✔
1270
                for p in projects:
1✔
1271
                    self.logger.debug(
1✔
1272
                        "creating Project with info from project_mapping: {}".format(p)
1273
                    )
1274
                    project = self._get_or_create(sess, Project, **p)
1✔
1275
                    self._projects[p["auth_id"]] = project
1✔
1276
        for _, projects in user_project.items():
1✔
1277
            for auth_id in list(projects.keys()):
1✔
1278
                project = sess.query(Project).filter(Project.auth_id == auth_id).first()
1✔
1279
                if not project:
1✔
1280
                    data = {"name": auth_id, "auth_id": auth_id}
1✔
1281
                    try:
1✔
1282
                        project = self._get_or_create(sess, Project, **data)
1✔
1283
                    except IntegrityError as e:
×
1284
                        sess.rollback()
×
1285
                        self.logger.error(
×
1286
                            f"Project {auth_id} already exists. Detail {str(e)}"
1287
                        )
1288
                        raise Exception(
×
1289
                            "Project {} already exists. Detail {}. Please contact your system administrator.".format(
1290
                                auth_id, str(e)
1291
                            )
1292
                        )
1293
                if auth_id not in self._projects:
1✔
1294
                    self._projects[auth_id] = project
1✔
1295

1296
    @staticmethod
1✔
1297
    def _get_or_create(sess, model, **kwargs):
1✔
1298
        instance = sess.query(model).filter_by(**kwargs).first()
1✔
1299
        if not instance:
1✔
1300
            instance = model(**kwargs)
1✔
1301
            sess.add(instance)
1✔
1302
        return instance
1✔
1303

1304
    def _process_dbgap_files(self, dbgap_config, sess):
1✔
1305
        """
1306
        Args:
1307
            dbgap_config : a dictionary containing information about a single
1308
                           dbgap sftp server (from fence config)
1309
            sess: database session
1310

1311
        Return:
1312
            user_projects (dict)
1313
            user_info (dict)
1314
        """
1315
        dbgap_file_list = []
1✔
1316
        hostname = dbgap_config["info"]["host"]
1✔
1317
        username = dbgap_config["info"]["username"]
1✔
1318
        encrypted = dbgap_config["info"].get("encrypted", True)
1✔
1319
        folderdir = os.path.join(str(self.folder), str(hostname), str(username))
1✔
1320

1321
        try:
1✔
1322
            if os.path.exists(folderdir):
1✔
1323
                dbgap_file_list = glob.glob(
×
1324
                    os.path.join(folderdir, "*")
1325
                )  # get lists of file from folder
1326
            else:
1327
                self.logger.info("Downloading files from: {}".format(hostname))
1✔
1328
                dbgap_file_list = self._download(dbgap_config)
1✔
1329
        except Exception as e:
1✔
1330
            self.logger.error(e)
1✔
1331
            exit(1)
1✔
1332
        self.logger.info("dbgap files: {}".format(dbgap_file_list))
×
1333
        user_projects, user_info = self._get_user_permissions_from_csv_list(
×
1334
            dbgap_file_list,
1335
            encrypted=encrypted,
1336
            session=sess,
1337
            dbgap_config=dbgap_config,
1338
        )
1339

1340
        user_projects = self.parse_projects(user_projects)
×
1341
        return user_projects, user_info
×
1342

1343
    def _get_user_permissions_from_csv_list(
1✔
1344
        self, file_list, encrypted, session, dbgap_config={}
1345
    ):
1346
        """
1347
        Args:
1348
            file_list: list of files (represented as strings)
1349
            encrypted: boolean indicating whether those files are encrypted
1350
            session: sqlalchemy session
1351
            dbgap_config: a dictionary containing information about the dbGaP sftp server
1352
                    (comes from fence config)
1353

1354
        Return:
1355
            user_projects (dict)
1356
            user_info (dict)
1357
        """
1358
        permissions = [{"read-storage", "read"} for _ in file_list]
1✔
1359
        user_projects, user_info = self._parse_csv(
1✔
1360
            dict(list(zip(file_list, permissions))),
1361
            sess=session,
1362
            dbgap_config=dbgap_config,
1363
            encrypted=encrypted,
1364
        )
1365
        return user_projects, user_info
1✔
1366

1367
    def _merge_multiple_local_csv_files(
1✔
1368
        self, dbgap_file_list, encrypted, dbgap_configs, session
1369
    ):
1370
        """
1371
        Args:
1372
            dbgap_file_list (list): a list of whitelist file locations stored locally
1373
            encrypted (bool): whether the file is encrypted (comes from fence config)
1374
            dbgap_configs (list): list of dictionaries containing information about the dbgap server (comes from fence config)
1375
            session (sqlalchemy.Session): database session
1376

1377
        Return:
1378
            merged_user_projects (dict)
1379
            merged_user_info (dict)
1380
        """
1381
        merged_user_projects = {}
1✔
1382
        merged_user_info = {}
1✔
1383

1384
        for dbgap_config in dbgap_configs:
1✔
1385
            user_projects, user_info = self._get_user_permissions_from_csv_list(
1✔
1386
                dbgap_file_list,
1387
                encrypted,
1388
                session=session,
1389
                dbgap_config=dbgap_config,
1390
            )
1391
            self.sync_two_user_info_dict(user_info, merged_user_info)
1✔
1392
            self.sync_two_phsids_dict(user_projects, merged_user_projects)
1✔
1393
        return merged_user_projects, merged_user_info
1✔
1394

1395
    def _merge_multiple_dbgap_sftp(self, dbgap_servers, sess):
1✔
1396
        """
1397
        Args:
1398
            dbgap_servers : a list of dictionaries each containging config on
1399
                           dbgap sftp server (comes from fence config)
1400
            sess: database session
1401

1402
        Return:
1403
            merged_user_projects (dict)
1404
            merged_user_info (dict)
1405
        """
1406
        merged_user_projects = {}
1✔
1407
        merged_user_info = {}
1✔
1408
        for dbgap in dbgap_servers:
1✔
1409
            user_projects, user_info = self._process_dbgap_files(dbgap, sess)
1✔
1410
            # merge into merged_user_info
1411
            # user_info overrides original info in merged_user_info
1412
            self.sync_two_user_info_dict(user_info, merged_user_info)
1✔
1413

1414
            # merge all access info dicts into "merged_user_projects".
1415
            # the access info is combined - if the user_projects access is
1416
            # ["read"] and the merged_user_projects is ["read-storage"], the
1417
            # resulting access is ["read", "read-storage"].
1418
            self.sync_two_phsids_dict(user_projects, merged_user_projects)
1✔
1419
        return merged_user_projects, merged_user_info
1✔
1420

1421
    def parse_projects(self, user_projects):
1✔
1422
        """
1423
        helper function for parsing projects
1424
        """
1425
        return {key.lower(): value for key, value in user_projects.items()}
1✔
1426

1427
    def _process_dbgap_project(
1✔
1428
        self, dbgap_project, privileges, username, sess, user_projects, dbgap_config
1429
    ):
1430
        if dbgap_project not in self.project_mapping:
1✔
1431
            self._add_dbgap_project_for_user(
1✔
1432
                dbgap_project,
1433
                privileges,
1434
                username,
1435
                sess,
1436
                user_projects,
1437
                dbgap_config,
1438
            )
1439

1440
        for element_dict in self.project_mapping.get(dbgap_project, []):
1✔
1441
            try:
1✔
1442
                phsid_privileges = {element_dict["auth_id"]: set(privileges)}
1✔
1443

1444
                # need to add dbgap project to arborist
1445
                if self.arborist_client:
1✔
1446
                    self._determine_arborist_resource(
1✔
1447
                        element_dict["auth_id"], dbgap_config
1448
                    )
1449

1450
                if username not in user_projects:
1✔
1451
                    user_projects[username] = {}
1✔
1452
                user_projects[username].update(phsid_privileges)
1✔
1453

1454
            except ValueError as e:
×
1455
                self.logger.info(e)
×
1456

1457
    def _process_user_projects(
1✔
1458
        self,
1459
        user_projects,
1460
        enable_common_exchange_area_access,
1461
        study_common_exchange_areas,
1462
        dbgap_config,
1463
        sess,
1464
    ):
1465
        user_projects_to_modify = copy.deepcopy(user_projects)
1✔
1466
        for username in user_projects.keys():
1✔
1467
            for project in user_projects[username].keys():
1✔
1468
                phsid = project.split(".")
1✔
1469
                dbgap_project = phsid[0]
1✔
1470
                privileges = user_projects[username][project]
1✔
1471
                if len(phsid) > 1 and self._get_parse_consent_code(dbgap_config):
1✔
1472
                    consent_code = phsid[-1]
1✔
1473

1474
                    # c999 indicates full access to all consents and access
1475
                    # to a study-specific exchange area
1476
                    # access to at least one study-specific exchange area implies access
1477
                    # to the parent study's common exchange area
1478
                    #
1479
                    # NOTE: Handling giving access to all consents is done at
1480
                    #       a later time, when we have full information about possible
1481
                    #       consents
1482
                    self.logger.debug(
1✔
1483
                        f"got consent code {consent_code} from dbGaP project "
1484
                        f"{dbgap_project}"
1485
                    )
1486
                    if (
1✔
1487
                        consent_code == "c999"
1488
                        and enable_common_exchange_area_access
1489
                        and dbgap_project in study_common_exchange_areas
1490
                    ):
1491
                        self.logger.info(
1✔
1492
                            "found study with consent c999 and Fence "
1493
                            "is configured to parse exchange area data. Giving user "
1494
                            f"{username} {privileges} privileges in project: "
1495
                            f"{study_common_exchange_areas[dbgap_project]}."
1496
                        )
1497
                        self._add_dbgap_project_for_user(
1✔
1498
                            study_common_exchange_areas[dbgap_project],
1499
                            privileges,
1500
                            username,
1501
                            sess,
1502
                            user_projects_to_modify,
1503
                            dbgap_config,
1504
                        )
1505

1506
                    dbgap_project += "." + consent_code
1✔
1507

1508
                self._process_dbgap_project(
1✔
1509
                    dbgap_project,
1510
                    privileges,
1511
                    username,
1512
                    sess,
1513
                    user_projects_to_modify,
1514
                    dbgap_config,
1515
                )
1516
        for user in user_projects_to_modify.keys():
1✔
1517
            user_projects[user] = user_projects_to_modify[user]
1✔
1518

1519
    def sync(self):
1✔
1520
        if self.session:
1✔
1521
            self._sync(self.session)
1✔
1522
        else:
1523
            with self.driver.session as s:
×
1524
                self._sync(s)
×
1525

1526
    def download(self):
1✔
1527
        for dbgap_server in self.dbGaP:
×
1528
            self._download(dbgap_server)
×
1529

1530
    def _download(self, dbgap_config):
1✔
1531
        """
1532
        Download files from dbgap server.
1533
        """
1534
        server = dbgap_config["info"]
1✔
1535
        protocol = dbgap_config["protocol"]
1✔
1536
        hostname = server["host"]
1✔
1537
        username = server["username"]
1✔
1538
        folderdir = os.path.join(str(self.folder), str(hostname), str(username))
1✔
1539

1540
        if not os.path.exists(folderdir):
1✔
1541
            os.makedirs(folderdir)
1✔
1542

1543
        self.logger.info("Download from server")
1✔
1544
        try:
1✔
1545
            if protocol == "sftp":
1✔
1546
                self._get_from_sftp_with_proxy(server, folderdir)
1✔
1547
            else:
1548
                self._get_from_ftp_with_proxy(server, folderdir)
×
1549
            dbgap_files = glob.glob(os.path.join(folderdir, "*"))
×
1550
            return dbgap_files
×
1551
        except Exception as e:
1✔
1552
            self.logger.error(e)
1✔
1553
            raise
1✔
1554

1555
    def _sync(self, sess):
1✔
1556
        """
1557
        Collect files from dbgap server(s), sync csv and yaml files to storage
1558
        backend and fence DB
1559
        """
1560

1561
        # get all dbgap files
1562
        user_projects = {}
1✔
1563
        user_info = {}
1✔
1564
        if self.is_sync_from_dbgap_server:
1✔
1565
            self.logger.debug(
1✔
1566
                "Pulling telemetry files from {} dbgap sftp servers".format(
1567
                    len(self.dbGaP)
1568
                )
1569
            )
1570
            user_projects, user_info = self._merge_multiple_dbgap_sftp(self.dbGaP, sess)
1✔
1571

1572
        local_csv_file_list = []
1✔
1573
        if self.sync_from_local_csv_dir:
1✔
1574
            local_csv_file_list = glob.glob(
1✔
1575
                os.path.join(self.sync_from_local_csv_dir, "*")
1576
            )
1577
            # Sort the list so the order of of files is consistent across platforms
1578
            local_csv_file_list.sort()
1✔
1579

1580
        user_projects_csv, user_info_csv = self._merge_multiple_local_csv_files(
1✔
1581
            local_csv_file_list,
1582
            encrypted=False,
1583
            session=sess,
1584
            dbgap_configs=self.dbGaP,
1585
        )
1586

1587
        try:
1✔
1588
            user_yaml = UserYAML.from_file(
1✔
1589
                self.sync_from_local_yaml_file, encrypted=False, logger=self.logger
1590
            )
1591
        except (EnvironmentError, AssertionError) as e:
1✔
1592
            self.logger.error(str(e))
1✔
1593
            self.logger.error("aborting early")
1✔
1594
            raise
1✔
1595

1596
        # parse all projects
1597
        user_projects_csv = self.parse_projects(user_projects_csv)
1✔
1598
        user_projects = self.parse_projects(user_projects)
1✔
1599
        user_yaml.projects = self.parse_projects(user_yaml.projects)
1✔
1600

1601
        # merge all user info dicts into "user_info".
1602
        # the user info (such as email) in the user.yaml files
1603
        # overrides the user info from the CSV files.
1604
        self.sync_two_user_info_dict(user_info_csv, user_info)
1✔
1605
        self.sync_two_user_info_dict(user_yaml.user_info, user_info)
1✔
1606

1607
        # merge all access info dicts into "user_projects".
1608
        # the access info is combined - if the user.yaml access is
1609
        # ["read"] and the CSV file access is ["read-storage"], the
1610
        # resulting access is ["read", "read-storage"].
1611
        self.sync_two_phsids_dict(
1✔
1612
            user_projects_csv, user_projects, source1="local_csv", source2="dbgap"
1613
        )
1614
        self.sync_two_phsids_dict(
1✔
1615
            user_yaml.projects, user_projects, source1="user_yaml", source2="dbgap"
1616
        )
1617

1618
        # Note: if there are multiple dbgap sftp servers configured
1619
        # this parameter is always from the config for the first dbgap sftp server
1620
        # not any additional ones
1621
        for dbgap_config in self.dbGaP:
1✔
1622
            if self._get_parse_consent_code(dbgap_config):
1✔
1623
                self._grant_all_consents_to_c999_users(
1✔
1624
                    user_projects, user_yaml.project_to_resource
1625
                )
1626

1627
        google_update_ex = None
1✔
1628

1629
        try:
1✔
1630
            # update the Fence DB
1631
            if user_projects:
1✔
1632
                self.logger.info("Sync to db and storage backend")
1✔
1633
                self.sync_to_db_and_storage_backend(user_projects, user_info, sess)
1✔
1634
                self.logger.info("Finish syncing to db and storage backend")
1✔
1635
            else:
1636
                self.logger.info("No users for syncing")
×
1637
        except GoogleUpdateException as ex:
1✔
1638
            # save this to reraise later after all non-Google syncing has finished
1639
            # this way, any issues with Google only affect Google data access and don't
1640
            # cascade problems into non-Google AWS or Azure access
1641
            google_update_ex = ex
1✔
1642

1643
        # update the Arborist DB (resources, roles, policies, groups)
1644
        if user_yaml.authz:
1✔
1645
            if not self.arborist_client:
1✔
1646
                raise EnvironmentError(
×
1647
                    "yaml file contains authz section but sync is not configured with"
1648
                    " arborist client--did you run sync with --arborist <arborist client> arg?"
1649
                )
1650
            self.logger.info("Synchronizing arborist...")
1✔
1651
            success = self._update_arborist(sess, user_yaml)
1✔
1652
            if success:
1✔
1653
                self.logger.info("Finished synchronizing arborist")
1✔
1654
            else:
1655
                self.logger.error("Could not synchronize successfully")
×
1656
                exit(1)
×
1657
        else:
1658
            self.logger.info("No `authz` section; skipping arborist sync")
×
1659

1660
        # update the Arborist DB (user access)
1661
        if self.arborist_client:
1✔
1662
            self.logger.info("Synchronizing arborist with authorization info...")
1✔
1663
            success = self._update_authz_in_arborist(sess, user_projects, user_yaml)
1✔
1664
            if success:
1✔
1665
                self.logger.info(
1✔
1666
                    "Finished synchronizing authorization info to arborist"
1667
                )
1668
            else:
1669
                self.logger.error(
×
1670
                    "Could not synchronize authorization info successfully to arborist"
1671
                )
1672
                exit(1)
×
1673
        else:
1674
            self.logger.error("No arborist client set; skipping arborist sync")
×
1675

1676
        # Logging authz source
1677
        for u, s in self.auth_source.items():
1✔
1678
            self.logger.info("Access for user {} from {}".format(u, s))
1✔
1679

1680
        self.logger.info(
1✔
1681
            f"Persisting authz mapping to database: {user_yaml.project_to_resource}"
1682
        )
1683
        user_yaml.persist_project_to_resource(db_session=sess)
1✔
1684
        if google_update_ex is not None:
1✔
1685
            raise google_update_ex
1✔
1686

1687
    def _grant_all_consents_to_c999_users(
1✔
1688
        self, user_projects, user_yaml_project_to_resources
1689
    ):
1690
        access_number_matcher = re.compile(config["DBGAP_ACCESSION_WITH_CONSENT_REGEX"])
1✔
1691
        # combine dbgap/user.yaml projects into one big list (in case not all consents
1692
        # are in either)
1693
        all_projects = set(
1✔
1694
            list(self._projects.keys()) + list(user_yaml_project_to_resources.keys())
1695
        )
1696

1697
        self.logger.debug(f"all projects: {all_projects}")
1✔
1698

1699
        # construct a mapping from phsid (without consent) to all accessions with consent
1700
        consent_mapping = {}
1✔
1701
        for project in all_projects:
1✔
1702
            phs_match = access_number_matcher.match(project)
1✔
1703
            if phs_match:
1✔
1704
                accession_number = phs_match.groupdict()
1✔
1705

1706
                # TODO: This is not handling the .v1.p1 at all
1707
                consent_mapping.setdefault(accession_number["phsid"], set()).add(
1✔
1708
                    ".".join([accession_number["phsid"], accession_number["consent"]])
1709
                )
1710
                children = self._get_children(accession_number["phsid"])
1✔
1711
                if children:
1✔
1712
                    for child_phs in children:
1✔
1713
                        consent_mapping.setdefault(child_phs, set()).add(
1✔
1714
                            ".".join(
1715
                                [child_phs, accession_number["consent"]]
1716
                            )  # Assign parent consent to child study
1717
                        )
1718

1719
        self.logger.debug(f"consent mapping: {consent_mapping}")
1✔
1720

1721
        # go through existing access and find any c999's and make sure to give access to
1722
        # all accessions with consent for that phsid
1723
        for username, user_project_info in copy.deepcopy(user_projects).items():
1✔
1724
            for project, _ in user_project_info.items():
1✔
1725
                phs_match = access_number_matcher.match(project)
1✔
1726
                if phs_match and phs_match.groupdict()["consent"] == "c999":
1✔
1727
                    # give access to all consents
1728
                    all_phsids_with_consent = consent_mapping.get(
1✔
1729
                        phs_match.groupdict()["phsid"], []
1730
                    )
1731
                    self.logger.info(
1✔
1732
                        f"user {username} has c999 consent group for: {project}. "
1733
                        f"Granting access to all consents: {all_phsids_with_consent}"
1734
                    )
1735
                    # NOTE: Only giving read-storage at the moment (this is same
1736
                    #       permission we give for other dbgap projects)
1737
                    for phsid_with_consent in all_phsids_with_consent:
1✔
1738
                        user_projects[username].update(
1✔
1739
                            {phsid_with_consent: {"read-storage", "read"}}
1740
                        )
1741

1742
    def _update_arborist(self, session, user_yaml):
1✔
1743
        """
1744
        Create roles, resources, policies, groups in arborist from the information in
1745
        ``user_yaml``.
1746

1747
        The projects are sent to arborist as resources with paths like
1748
        ``/projects/{project}``. Roles are created with just the original names
1749
        for the privileges like ``"read-storage", "read"`` etc.
1750

1751
        Args:
1752
            session (sqlalchemy.Session)
1753
            user_yaml (UserYAML)
1754

1755
        Return:
1756
            bool: success
1757
        """
1758
        healthy = self._is_arborist_healthy()
1✔
1759
        if not healthy:
1✔
1760
            return False
×
1761

1762
        # Set up the resource tree in arborist by combining provided resources with any
1763
        # dbgap resources that were created before this.
1764
        #
1765
        # Why add dbgap resources if they've already been created?
1766
        #   B/C Arborist's PUT update will override existing subresources. So if a dbgap
1767
        #   resources was created under `/programs/phs000178` anything provided in
1768
        #   user.yaml under `/programs` would completely wipe it out.
1769
        resources = user_yaml.authz.get("resources", [])
1✔
1770

1771
        dbgap_resource_paths = []
1✔
1772
        for path_list in self._dbgap_study_to_resources.values():
1✔
1773
            dbgap_resource_paths.extend(path_list)
1✔
1774

1775
        self.logger.debug("user_yaml resources: {}".format(resources))
1✔
1776
        self.logger.debug("dbgap resource paths: {}".format(dbgap_resource_paths))
1✔
1777

1778
        combined_resources = utils.combine_provided_and_dbgap_resources(
1✔
1779
            resources, dbgap_resource_paths
1780
        )
1781

1782
        for resource in combined_resources:
1✔
1783
            try:
1✔
1784
                self.logger.debug(
1✔
1785
                    "attempting to update arborist resource: {}".format(resource)
1786
                )
1787
                self.arborist_client.update_resource("/", resource, merge=True)
1✔
1788
            except ArboristError as e:
×
1789
                self.logger.error(e)
×
1790
                # keep going; maybe just some conflicts from things existing already
1791

1792
        # update roles
1793
        roles = user_yaml.authz.get("roles", [])
1✔
1794
        for role in roles:
1✔
1795
            try:
1✔
1796
                response = self.arborist_client.update_role(role["id"], role)
1✔
1797
                if response:
1✔
1798
                    self._created_roles.add(role["id"])
1✔
1799
            except ArboristError as e:
×
1800
                self.logger.info(
×
1801
                    "couldn't update role '{}', creating instead".format(str(e))
1802
                )
1803
                try:
×
1804
                    response = self.arborist_client.create_role(role)
×
1805
                    if response:
×
1806
                        self._created_roles.add(role["id"])
×
1807
                except ArboristError as e:
×
1808
                    self.logger.error(e)
×
1809
                    # keep going; maybe just some conflicts from things existing already
1810

1811
        # update policies
1812
        policies = user_yaml.authz.get("policies", [])
1✔
1813
        for policy in policies:
1✔
1814
            policy_id = policy.pop("id")
1✔
1815
            try:
1✔
1816
                self.logger.debug(
1✔
1817
                    "Trying to upsert policy with id {}".format(policy_id)
1818
                )
1819
                response = self.arborist_client.update_policy(
1✔
1820
                    policy_id, policy, create_if_not_exist=True
1821
                )
1822
            except ArboristError as e:
×
1823
                self.logger.error(e)
×
1824
                # keep going; maybe just some conflicts from things existing already
1825
            else:
1826
                if response:
1✔
1827
                    self.logger.debug("Upserted policy with id {}".format(policy_id))
1✔
1828
                    self._created_policies.add(policy_id)
1✔
1829

1830
        # update groups
1831
        groups = user_yaml.authz.get("groups", [])
1✔
1832

1833
        # delete from arborist the groups that have been deleted
1834
        # from the user.yaml
1835
        arborist_groups = set(
1✔
1836
            g["name"] for g in self.arborist_client.list_groups().get("groups", [])
1837
        )
1838
        useryaml_groups = set(g["name"] for g in groups)
1✔
1839
        for deleted_group in arborist_groups.difference(useryaml_groups):
1✔
1840
            # do not try to delete built in groups
1841
            if deleted_group not in ["anonymous", "logged-in"]:
×
1842
                self.arborist_client.delete_group(deleted_group)
×
1843

1844
        # create/update the groups defined in the user.yaml
1845
        for group in groups:
1✔
1846
            missing = {"name", "users", "policies"}.difference(set(group.keys()))
×
1847
            if missing:
×
1848
                name = group.get("name", "{MISSING NAME}")
×
1849
                self.logger.error(
×
1850
                    "group {} missing required field(s): {}".format(name, list(missing))
1851
                )
1852
                continue
×
1853
            try:
×
1854
                response = self.arborist_client.put_group(
×
1855
                    group["name"],
1856
                    # Arborist doesn't handle group descriptions yet
1857
                    # description=group.get("description", ""),
1858
                    users=group["users"],
1859
                    policies=group["policies"],
1860
                )
1861
            except ArboristError as e:
×
1862
                self.logger.info("couldn't put group: {}".format(str(e)))
×
1863

1864
        # Update policies for built-in (`anonymous` and `logged-in`) groups
1865

1866
        # First recreate these groups in order to clear out old, possibly deleted policies
1867
        for builtin_group in ["anonymous", "logged-in"]:
1✔
1868
            try:
1✔
1869
                response = self.arborist_client.put_group(builtin_group)
1✔
1870
            except ArboristError as e:
×
1871
                self.logger.info("couldn't put group: {}".format(str(e)))
×
1872

1873
        # Now add back policies that are in the user.yaml
1874
        for policy in user_yaml.authz.get("anonymous_policies", []):
1✔
1875
            self.arborist_client.grant_group_policy("anonymous", policy)
×
1876

1877
        for policy in user_yaml.authz.get("all_users_policies", []):
1✔
1878
            self.arborist_client.grant_group_policy("logged-in", policy)
×
1879

1880
        return True
1✔
1881

1882
    def _revoke_all_policies_preserve_mfa(self, username, idp=None):
1✔
1883
        """
1884
        If MFA is enabled for the user's idp, check if they have the /multifactor_auth resource and restore the
1885
        mfa_policy after revoking all policies.
1886
        """
1887

1888
        is_mfa_enabled = "multifactor_auth_claim_info" in config["OPENID_CONNECT"].get(
1✔
1889
            idp, {}
1890
        )
1891

1892
        if not is_mfa_enabled:
1✔
1893
            # TODO This should be a diff, not a revocation of all policies.
1894
            self.arborist_client.revoke_all_policies_for_user(username)
1✔
1895
            return
1✔
1896

1897
        policies = []
1✔
1898
        try:
1✔
1899
            user_data_from_arborist = self.arborist_client.get_user(username)
1✔
1900
            policies = user_data_from_arborist["policies"]
1✔
1901
        except Exception as e:
×
1902
            self.logger.error(
×
1903
                f"Could not retrieve user's policies, revoking all policies anyway. {e}"
1904
            )
1905
        finally:
1906
            # TODO This should be a diff, not a revocation of all policies.
1907
            self.arborist_client.revoke_all_policies_for_user(username)
1✔
1908

1909
        if "mfa_policy" in policies:
1✔
1910
            self.arborist_client.grant_user_policy(username, "mfa_policy")
1✔
1911

1912
    def _update_authz_in_arborist(
1✔
1913
        self,
1914
        session,
1915
        user_projects,
1916
        user_yaml=None,
1917
        single_user_sync=False,
1918
        expires=None,
1919
    ):
1920
        """
1921
        Assign users policies in arborist from the information in
1922
        ``user_projects`` and optionally a ``user_yaml``.
1923

1924
        The projects are sent to arborist as resources with paths like
1925
        ``/projects/{project}``. Roles are created with just the original names
1926
        for the privileges like ``"read-storage", "read"`` etc.
1927

1928
        Args:
1929
            user_projects (dict)
1930
            user_yaml (UserYAML) optional, if there are policies for users in a user.yaml
1931
            single_user_sync (bool) whether authz update is for a single user
1932
            expires (int) time at which authz info in Arborist should expire
1933

1934
        Return:
1935
            bool: success
1936
        """
1937
        healthy = self._is_arborist_healthy()
1✔
1938
        if not healthy:
1✔
1939
            return False
×
1940

1941
        self.logger.debug("user_projects: {}".format(user_projects))
1✔
1942

1943
        if user_yaml:
1✔
1944
            self.logger.debug(
1✔
1945
                "useryaml abac before lowering usernames: {}".format(
1946
                    user_yaml.user_abac
1947
                )
1948
            )
1949
            user_yaml.user_abac = {
1✔
1950
                key.lower(): value for key, value in user_yaml.user_abac.items()
1951
            }
1952
            # update the project info with `projects` specified in user.yaml
1953
            self.sync_two_phsids_dict(user_yaml.user_abac, user_projects)
1✔
1954

1955
        # get list of users from arborist to make sure users that are completely removed
1956
        # from authorization sources get policies revoked
1957
        arborist_user_projects = {}
1✔
1958
        if not single_user_sync:
1✔
1959
            try:
1✔
1960
                arborist_users = self.arborist_client.get_users().json["users"]
1✔
1961

1962
                # construct user information, NOTE the lowering of the username. when adding/
1963
                # removing access, the case in the Fence db is used. For combining access, it is
1964
                # case-insensitive, so we lower
1965
                arborist_user_projects = {
1✔
1966
                    user["name"].lower(): {} for user in arborist_users
1967
                }
1968
            except (ArboristError, KeyError, AttributeError) as error:
×
1969
                # TODO usersync should probably exit with non-zero exit code at the end,
1970
                #      but sync should continue from this point so there are no partial
1971
                #      updates
1972
                self.logger.warning(
×
1973
                    "Could not get list of users in Arborist, continuing anyway. "
1974
                    "WARNING: this sync will NOT remove access for users no longer in "
1975
                    f"authorization sources. Error: {error}"
1976
                )
1977

1978
            # update the project info with users from arborist
1979
            self.sync_two_phsids_dict(arborist_user_projects, user_projects)
1✔
1980

1981
        policy_id_list = []
1✔
1982
        policies = []
1✔
1983

1984
        # prefer in-memory if available from user_yaml, if not, get from database
1985
        if user_yaml and user_yaml.project_to_resource:
1✔
1986
            project_to_authz_mapping = user_yaml.project_to_resource
1✔
1987
            self.logger.debug(
1✔
1988
                f"using in-memory project to authz resource mapping from "
1989
                f"user.yaml (instead of database): {project_to_authz_mapping}"
1990
            )
1991
        else:
1992
            project_to_authz_mapping = get_project_to_authz_mapping(session)
1✔
1993
            self.logger.debug(
1✔
1994
                f"using persisted project to authz resource mapping from database "
1995
                f"(instead of user.yaml - as it may not be available): {project_to_authz_mapping}"
1996
            )
1997

1998
        self.logger.debug(
1✔
1999
            f"_dbgap_study_to_resources: {self._dbgap_study_to_resources}"
2000
        )
2001
        all_resources = [
1✔
2002
            r
2003
            for resources in self._dbgap_study_to_resources.values()
2004
            for r in resources
2005
        ]
2006
        all_resources.extend(r for r in project_to_authz_mapping.values())
1✔
2007
        self._create_arborist_resources(all_resources)
1✔
2008

2009
        for username, user_project_info in user_projects.items():
1✔
2010
            self.logger.info("processing user `{}`".format(username))
1✔
2011
            user = query_for_user(session=session, username=username)
1✔
2012
            idp = None
1✔
2013
            if user:
1✔
2014
                username = user.username
1✔
2015
                idp = user.identity_provider.name if user.identity_provider else None
1✔
2016

2017
            self.arborist_client.create_user_if_not_exist(username)
1✔
2018
            if not single_user_sync:
1✔
2019
                self._revoke_all_policies_preserve_mfa(username, idp)
1✔
2020

2021
            # as of 2/11/2022, for single_user_sync, as RAS visa parsing has
2022
            # previously mapped each project to the same set of privileges
2023
            # (i.e.{'read', 'read-storage'}), unique_policies will just be a
2024
            # single policy with ('read', 'read-storage') being the single
2025
            # key
2026
            unique_policies = self._determine_unique_policies(
1✔
2027
                user_project_info, project_to_authz_mapping
2028
            )
2029

2030
            for roles in unique_policies.keys():
1✔
2031
                for role in roles:
1✔
2032
                    self._create_arborist_role(role)
1✔
2033

2034
            if single_user_sync:
1✔
2035
                for ordered_roles, ordered_resources in unique_policies.items():
1✔
2036
                    policy_hash = self._hash_policy_contents(
1✔
2037
                        ordered_roles, ordered_resources
2038
                    )
2039
                    self._create_arborist_policy(
1✔
2040
                        policy_hash,
2041
                        ordered_roles,
2042
                        ordered_resources,
2043
                        skip_if_exists=True,
2044
                    )
2045
                    # return here as it is not expected single_user_sync
2046
                    # will need any of the remaining user_yaml operations
2047
                    # left in _update_authz_in_arborist
2048
                    return self._grant_arborist_policy(
1✔
2049
                        username, policy_hash, expires=expires
2050
                    )
2051
            else:
2052
                for roles, resources in unique_policies.items():
1✔
2053
                    for role in roles:
1✔
2054
                        for resource in resources:
1✔
2055
                            # grant a policy to this user which is a single
2056
                            # role on a single resource
2057

2058
                            # format project '/x/y/z' -> 'x.y.z'
2059
                            # so the policy id will be something like 'x.y.z-create'
2060
                            policy_id = _format_policy_id(resource, role)
1✔
2061
                            if policy_id not in self._created_policies:
1✔
2062
                                try:
1✔
2063
                                    self.arborist_client.update_policy(
1✔
2064
                                        policy_id,
2065
                                        {
2066
                                            "description": "policy created by fence sync",
2067
                                            "role_ids": [role],
2068
                                            "resource_paths": [resource],
2069
                                        },
2070
                                        create_if_not_exist=True,
2071
                                    )
2072
                                except ArboristError as e:
×
2073
                                    self.logger.info(
×
2074
                                        "not creating policy in arborist; {}".format(
2075
                                            str(e)
2076
                                        )
2077
                                    )
2078
                                self._created_policies.add(policy_id)
1✔
2079

2080
                            self._grant_arborist_policy(
1✔
2081
                                username, policy_id, expires=expires
2082
                            )
2083

2084
            if user_yaml:
1✔
2085
                for policy in user_yaml.policies.get(username, []):
1✔
2086
                    self.arborist_client.grant_user_policy(
1✔
2087
                        username,
2088
                        policy,
2089
                        expires_at=expires,
2090
                    )
2091

2092
        if user_yaml:
1✔
2093
            for client_name, client_details in user_yaml.clients.items():
1✔
2094
                client_policies = client_details.get("policies", [])
×
2095
                clients = session.query(Client).filter_by(name=client_name).all()
×
2096
                # update existing clients, do not create new ones
2097
                if not clients:
×
2098
                    self.logger.warning(
×
2099
                        "client to update (`{}`) does not exist in fence: skipping".format(
2100
                            client_name
2101
                        )
2102
                    )
2103
                    continue
×
2104
                self.logger.debug(
×
2105
                    "updating client `{}` (found {} client IDs)".format(
2106
                        client_name, len(clients)
2107
                    )
2108
                )
2109
                # there may be more than 1 client with this name if credentials are being rotated,
2110
                # so we grant access to each client ID
2111
                for client in clients:
×
2112
                    try:
×
2113
                        self.arborist_client.update_client(
×
2114
                            client.client_id, client_policies
2115
                        )
2116
                    except ArboristError as e:
×
2117
                        self.logger.info(
×
2118
                            "not granting policies {} to client `{}` (`{}`); {}".format(
2119
                                client_policies, client_name, client.client_id, str(e)
2120
                            )
2121
                        )
2122

2123
        return True
1✔
2124

2125
    def _determine_unique_policies(self, user_project_info, project_to_authz_mapping):
1✔
2126
        """
2127
        Determine and return a dictionary of unique policies.
2128

2129
        Args (examples):
2130
            user_project_info (dict):
2131
            {
2132
                'phs000002.c1': { 'read-storage', 'read' },
2133
                'phs000001.c1': { 'read', 'read-storage' },
2134
                'phs000004.c1': { 'write', 'read' },
2135
                'phs000003.c1': { 'read', 'write' },
2136
                'phs000006.c1': { 'write-storage', 'write', 'read-storage', 'read' }
2137
                'phs000005.c1': { 'read', 'read-storage', 'write', 'write-storage' },
2138
            }
2139
            project_to_authz_mapping (dict):
2140
            {
2141
                'phs000001.c1': '/programs/DEV/projects/phs000001.c1'
2142
            }
2143

2144
        Return (for examples):
2145
            dict:
2146
            {
2147
                ('read', 'read-storage'): ('phs000001.c1', 'phs000002.c1'),
2148
                ('read', 'write'): ('phs000003.c1', 'phs000004.c1'),
2149
                ('read', 'read-storage', 'write', 'write-storage'): ('phs000005.c1', 'phs000006.c1'),
2150
            }
2151
        """
2152
        roles_to_resources = collections.defaultdict(list)
1✔
2153
        for study, roles in user_project_info.items():
1✔
2154
            ordered_roles = tuple(sorted(roles))
1✔
2155
            study_authz_paths = self._dbgap_study_to_resources.get(study, [study])
1✔
2156
            if study in project_to_authz_mapping:
1✔
2157
                study_authz_paths = [project_to_authz_mapping[study]]
1✔
2158
            roles_to_resources[ordered_roles].extend(study_authz_paths)
1✔
2159

2160
        policies = {}
1✔
2161
        for ordered_roles, unordered_resources in roles_to_resources.items():
1✔
2162
            policies[ordered_roles] = tuple(sorted(unordered_resources))
1✔
2163
        return policies
1✔
2164

2165
    def _create_arborist_role(self, role):
1✔
2166
        """
2167
        Wrapper around gen3authz's create_role with additional logging
2168

2169
        Args:
2170
            role (str): what the Arborist identity should be of the created role
2171

2172
        Return:
2173
            bool: True if the role was created successfully or it already
2174
                  exists. False otherwise
2175
        """
2176
        if role in self._created_roles:
1✔
2177
            return True
1✔
2178
        try:
1✔
2179
            response_json = self.arborist_client.create_role(
1✔
2180
                arborist_role_for_permission(role)
2181
            )
2182
        except ArboristError as e:
×
2183
            self.logger.error(
×
2184
                "could not create `{}` role in Arborist: {}".format(role, e)
2185
            )
2186
            return False
×
2187
        self._created_roles.add(role)
1✔
2188

2189
        if response_json is None:
1✔
2190
            self.logger.info("role `{}` already exists in Arborist".format(role))
×
2191
        else:
2192
            self.logger.info("created role `{}` in Arborist".format(role))
1✔
2193
        return True
1✔
2194

2195
    def _create_arborist_resources(self, resources):
1✔
2196
        """
2197
        Create resources in Arborist
2198

2199
        Args:
2200
            resources (list): a list of full Arborist resource paths to create
2201
            [
2202
                "/programs/DEV/projects/phs000001.c1",
2203
                "/programs/DEV/projects/phs000002.c1",
2204
                "/programs/DEV/projects/phs000003.c1"
2205
            ]
2206

2207
        Return:
2208
            bool: True if the resources were successfully created, False otherwise
2209

2210

2211
        As of 2/11/2022, for resources above,
2212
        utils.combine_provided_and_dbgap_resources({}, resources) returns:
2213
        [
2214
            { 'name': 'programs', 'subresources': [
2215
                { 'name': 'DEV', 'subresources': [
2216
                    { 'name': 'projects', 'subresources': [
2217
                        { 'name': 'phs000001.c1', 'subresources': []},
2218
                        { 'name': 'phs000002.c1', 'subresources': []},
2219
                        { 'name': 'phs000003.c1', 'subresources': []}
2220
                    ]}
2221
                ]}
2222
            ]}
2223
        ]
2224
        Because this list has a single object, only a single network request gets
2225
        sent to Arborist.
2226

2227
        However, for resources = ["/phs000001.c1", "/phs000002.c1", "/phs000003.c1"],
2228
        utils.combine_provided_and_dbgap_resources({}, resources) returns:
2229
        [
2230
            {'name': 'phs000001.c1', 'subresources': []},
2231
            {'name': 'phs000002.c1', 'subresources': []},
2232
            {'name': 'phs000003.c1', 'subresources': []}
2233
        ]
2234
        Because this list has 3 objects, 3 network requests get sent to Arborist.
2235

2236
        As a practical matter, for sync_single_user_visas, studies
2237
        should be nested under the `/programs` resource as in the former
2238
        example (i.e. only one network request gets made).
2239

2240
        TODO for the sake of simplicity, it would be nice if only one network
2241
        request was made no matter the input.
2242
        """
2243
        for request_body in utils.combine_provided_and_dbgap_resources({}, resources):
1✔
2244
            try:
1✔
2245
                response_json = self.arborist_client.update_resource(
1✔
2246
                    "/", request_body, merge=True
2247
                )
2248
            except ArboristError as e:
×
2249
                self.logger.error(
×
2250
                    "could not create Arborist resources using request body `{}`. error: {}".format(
2251
                        request_body, e
2252
                    )
2253
                )
2254
                return False
×
2255

2256
        self.logger.debug(
1✔
2257
            "created {} resource(s) in Arborist: `{}`".format(len(resources), resources)
2258
        )
2259
        return True
1✔
2260

2261
    def _create_arborist_policy(
1✔
2262
        self, policy_id, roles, resources, skip_if_exists=False
2263
    ):
2264
        """
2265
        Wrapper around gen3authz's create_policy with additional logging
2266

2267
        Args:
2268
            policy_id (str): what the Arborist identity should be of the created policy
2269
            roles (iterable): what roles the create policy should have
2270
            resources (iterable): what resources the created policy should have
2271
            skip_if_exists (bool): if True, this function will not treat an already
2272
                                   existent policy as an error
2273

2274
        Return:
2275
            bool: True if policy creation was successful. False otherwise
2276
        """
2277
        try:
1✔
2278
            response_json = self.arborist_client.create_policy(
1✔
2279
                {
2280
                    "id": policy_id,
2281
                    "role_ids": roles,
2282
                    "resource_paths": resources,
2283
                },
2284
                skip_if_exists=skip_if_exists,
2285
            )
2286
        except ArboristError as e:
×
2287
            self.logger.error(
×
2288
                "could not create policy `{}` in Arborist: {}".format(policy_id, e)
2289
            )
2290
            return False
×
2291

2292
        if response_json is None:
1✔
2293
            self.logger.info("policy `{}` already exists in Arborist".format(policy_id))
×
2294
        else:
2295
            self.logger.info("created policy `{}` in Arborist".format(policy_id))
1✔
2296
        return True
1✔
2297

2298
    def _hash_policy_contents(self, ordered_roles, ordered_resources):
1✔
2299
        """
2300
        Generate a sha256 hexdigest representing ordered_roles and ordered_resources.
2301

2302
        Args:
2303
            ordered_roles (iterable): policy roles in sorted order
2304
            ordered_resources (iterable): policy resources in sorted order
2305

2306
        Return:
2307
            str: SHA256 hex digest
2308
        """
2309

2310
        def escape(s):
1✔
2311
            return s.replace(",", "\,")
1✔
2312

2313
        canonical_roles = ",".join(escape(r) for r in ordered_roles)
1✔
2314
        canonical_resources = ",".join(escape(r) for r in ordered_resources)
1✔
2315
        canonical_policy = f"{canonical_roles},,f{canonical_resources}"
1✔
2316
        policy_hash = hashlib.sha256(canonical_policy.encode("utf-8")).hexdigest()
1✔
2317

2318
        return policy_hash
1✔
2319

2320
    def _grant_arborist_policy(self, username, policy_id, expires=None):
1✔
2321
        """
2322
        Wrapper around gen3authz's grant_user_policy with additional logging
2323

2324
        Args:
2325
            username (str): username of user in Arborist who policy should be
2326
                            granted to
2327
            policy_id (str): Arborist policy id
2328
            expires (int): POSIX timestamp for when policy should expire
2329

2330
        Return:
2331
            bool: True if granting of policy was successful, False otherwise
2332
        """
2333
        try:
1✔
2334
            response_json = self.arborist_client.grant_user_policy(
1✔
2335
                username,
2336
                policy_id,
2337
                expires_at=expires,
2338
            )
2339
        except ArboristError as e:
×
2340
            self.logger.error(
×
2341
                "could not grant policy `{}` to user `{}`: {}".format(
2342
                    policy_id, username, e
2343
                )
2344
            )
2345
            return False
×
2346

2347
        self.logger.debug(
1✔
2348
            "granted policy `{}` to user `{}`".format(policy_id, username)
2349
        )
2350
        return True
1✔
2351

2352
    def _determine_arborist_resource(self, dbgap_study, dbgap_config):
1✔
2353
        """
2354
        Determine the arborist resource path and add it to
2355
        _self._dbgap_study_to_resources
2356

2357
        Args:
2358
            dbgap_study (str): study phs identifier
2359
            dbgap_config (dict): dictionary of config for dbgap server
2360

2361
        """
2362
        default_namespaces = dbgap_config.get("study_to_resource_namespaces", {}).get(
1✔
2363
            "_default", ["/"]
2364
        )
2365
        namespaces = dbgap_config.get("study_to_resource_namespaces", {}).get(
1✔
2366
            dbgap_study, default_namespaces
2367
        )
2368

2369
        self.logger.debug(f"dbgap study namespaces: {namespaces}")
1✔
2370

2371
        arborist_resource_namespaces = [
1✔
2372
            namespace.rstrip("/") + "/programs/" for namespace in namespaces
2373
        ]
2374

2375
        for resource_namespace in arborist_resource_namespaces:
1✔
2376
            full_resource_path = resource_namespace + dbgap_study
1✔
2377
            if dbgap_study not in self._dbgap_study_to_resources:
1✔
2378
                self._dbgap_study_to_resources[dbgap_study] = []
1✔
2379
            self._dbgap_study_to_resources[dbgap_study].append(full_resource_path)
1✔
2380
        return arborist_resource_namespaces
1✔
2381

2382
    def _is_arborist_healthy(self):
1✔
2383
        if not self.arborist_client:
1✔
2384
            self.logger.warning("no arborist client set; skipping arborist dbgap sync")
×
2385
            return False
×
2386
        if not self.arborist_client.healthy():
1✔
2387
            # TODO (rudyardrichter, 2019-01-07): add backoff/retry here
2388
            self.logger.error(
×
2389
                "arborist service is unavailable; skipping main arborist dbgap sync"
2390
            )
2391
            return False
×
2392
        return True
1✔
2393

2394
    def _pick_sync_type(self, visa):
1✔
2395
        """
2396
        Pick type of visa to parse according to the visa provider
2397
        """
2398
        sync_client = None
1✔
2399
        if visa.type in self.visa_types["ras"]:
1✔
2400
            sync_client = self.ras_sync_client
1✔
2401
        else:
2402
            raise Exception(
×
2403
                "Visa type {} not recognized. Configure in fence-config".format(
2404
                    visa.type
2405
                )
2406
            )
2407
        if not sync_client:
1✔
2408
            raise Exception("Sync client for {} not configured".format(visa.type))
×
2409

2410
        return sync_client
1✔
2411

2412
    def sync_single_user_visas(
1✔
2413
        self, user, ga4gh_visas, sess=None, expires=None, skip_google_updates=False
2414
    ):
2415
        """
2416
        Sync a single user's visas during login or DRS/data access
2417

2418
        IMPORTANT NOTE: THIS DOES NOT VALIDATE THE VISA. ENSURE THIS IS DONE
2419
                        BEFORE THIS.
2420

2421
        Args:
2422
            user (userdatamodel.user.User): Fence user whose visas'
2423
                                            authz info is being synced
2424
            ga4gh_visas (list): a list of fence.models.GA4GHVisaV1 objects
2425
                                that are ALREADY VALIDATED
2426
            sess (sqlalchemy.orm.session.Session): database session
2427
            expires (int): time at which synced Arborist policies and
2428
                           inclusion in any GBAG are set to expire
2429
            skip_google_updates (bool): True if google group updates should be skipped. False if otherwise.
2430

2431
        Return:
2432
            list of successfully parsed visas
2433
        """
2434
        self.ras_sync_client = RASVisa(logger=self.logger)
1✔
2435
        dbgap_config = self.dbGaP[0]
1✔
2436
        parse_consent_code = self._get_parse_consent_code(dbgap_config)
1✔
2437
        enable_common_exchange_area_access = dbgap_config.get(
1✔
2438
            "enable_common_exchange_area_access", False
2439
        )
2440
        study_common_exchange_areas = dbgap_config.get(
1✔
2441
            "study_common_exchange_areas", {}
2442
        )
2443

2444
        try:
1✔
2445
            user_yaml = UserYAML.from_file(
1✔
2446
                self.sync_from_local_yaml_file, encrypted=False, logger=self.logger
2447
            )
2448
        except (EnvironmentError, AssertionError) as e:
×
2449
            self.logger.error(str(e))
×
2450
            self.logger.error("aborting early")
×
2451
            raise
×
2452

2453
        user_projects = dict()
1✔
2454
        projects = {}
1✔
2455
        info = {}
1✔
2456
        parsed_visas = []
1✔
2457

2458
        for visa in ga4gh_visas:
1✔
2459
            project = {}
1✔
2460
            visa_type = self._pick_sync_type(visa)
1✔
2461
            encoded_visa = visa.ga4gh_visa
1✔
2462

2463
            try:
1✔
2464
                project, info = visa_type._parse_single_visa(
1✔
2465
                    user,
2466
                    encoded_visa,
2467
                    visa.expires,
2468
                    parse_consent_code,
2469
                )
2470
            except Exception:
×
2471
                self.logger.warning(
×
2472
                    f"ignoring unsuccessfully parsed or expired visa: {encoded_visa}"
2473
                )
2474
                continue
×
2475

2476
            projects = {**projects, **project}
1✔
2477
            parsed_visas.append(visa)
1✔
2478

2479
        info["user_id"] = user.id
1✔
2480
        info["username"] = user.username
1✔
2481
        user_projects[user.username] = projects
1✔
2482

2483
        user_projects = self.parse_projects(user_projects)
1✔
2484

2485
        if parse_consent_code and enable_common_exchange_area_access:
1✔
2486
            self.logger.info(
1✔
2487
                f"using study to common exchange area mapping: {study_common_exchange_areas}"
2488
            )
2489

2490
        self._process_user_projects(
1✔
2491
            user_projects,
2492
            enable_common_exchange_area_access,
2493
            study_common_exchange_areas,
2494
            dbgap_config,
2495
            sess,
2496
        )
2497

2498
        if parse_consent_code:
1✔
2499
            self._grant_all_consents_to_c999_users(
1✔
2500
                user_projects, user_yaml.project_to_resource
2501
            )
2502

2503
        if user_projects:
1✔
2504
            self.sync_to_storage_backend(
1✔
2505
                user_projects,
2506
                info,
2507
                sess,
2508
                expires=expires,
2509
                skip_google_updates=skip_google_updates,
2510
            )
2511
        else:
2512
            self.logger.info("No users for syncing")
×
2513

2514
        # update arborist db (user access)
2515
        if self.arborist_client:
1✔
2516
            self.logger.info("Synchronizing arborist with authorization info...")
1✔
2517
            success = self._update_authz_in_arborist(
1✔
2518
                sess,
2519
                user_projects,
2520
                user_yaml=user_yaml,
2521
                single_user_sync=True,
2522
                expires=expires,
2523
            )
2524
            if success:
1✔
2525
                self.logger.info(
1✔
2526
                    "Finished synchronizing authorization info to arborist"
2527
                )
2528
            else:
2529
                self.logger.error(
×
2530
                    "Could not synchronize authorization info successfully to arborist"
2531
                )
2532
        else:
2533
            self.logger.error("No arborist client set; skipping arborist sync")
×
2534

2535
        return parsed_visas
1✔
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