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

uc-cdis / fence / 12324526000

20 Nov 2024 06:58PM UTC coverage: 75.24% (-0.004%) from 75.244%
12324526000

push

github

web-flow
Merge pull request #1202 from uc-cdis/feat/remove_role_from_admin_endpoints

Feat: remove role from POST /admin/user endpoint

7852 of 10436 relevant lines covered (75.24%)

0.75 hits per line

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

87.43
fence/resources/ga4gh/passports.py
1
import flask
1✔
2
import os
1✔
3
import collections
1✔
4
import hashlib
1✔
5
import time
1✔
6
import datetime
1✔
7
import jwt
1✔
8

9
# the whole fence_create module is imported to avoid issue with circular imports
10
import fence.scripting.fence_create
1✔
11

12
from authutils.errors import JWTError
1✔
13
from authutils.token.core import get_iss, get_kid
1✔
14
from cdislogging import get_logger
1✔
15
from flask import current_app
1✔
16

17
from fence.jwt.validate import validate_jwt
1✔
18
from fence.config import config
1✔
19
from fence.models import (
1✔
20
    create_user,
21
    query_for_user,
22
    query_for_user_by_id,
23
    GA4GHVisaV1,
24
    GA4GHPassportCache,
25
    IdentityProvider,
26
    IssSubPairToUser,
27
)
28

29
logger = get_logger(__name__)
1✔
30

31
# cache will be in following format
32
#   passport_hash: ([user_id_0, user_id_1, ...], expires_at)
33
PASSPORT_CACHE = {}
1✔
34

35

36
def sync_gen3_users_authz_from_ga4gh_passports(
1✔
37
    passports,
38
    pkey_cache=None,
39
    db_session=None,
40
):
41
    """
42
    Validate passports and embedded visas, using each valid visa's identity
43
    established by <iss, sub> combination to possibly create and definitely
44
    determine a Fence user who is added to the list returned by this
45
    function. In the process of determining Fence users from visas, visa
46
    authorization information is also persisted in Fence and synced to
47
    Arborist.
48

49
    Args:
50
        passports (list): a list of raw encoded passport strings, each
51
                          including header, payload, and signature
52

53
    Return:
54
        list: a list of users, each corresponding to a valid visa identity
55
              embedded within the passports passed in
56
    """
57
    db_session = db_session or current_app.scoped_session()
1✔
58

59
    # {"username": user, "username2": user2}
60
    users_from_all_passports = {}
1✔
61
    for passport in passports:
1✔
62
        try:
1✔
63
            cached_usernames = get_gen3_usernames_for_passport_from_cache(
1✔
64
                passport=passport, db_session=db_session
65
            )
66
            if cached_usernames:
1✔
67
                # there's a chance a given username exists in the cache but no longer in
68
                # the database. if not all are in db, ignore the cache and actually parse
69
                # and validate the passport
70
                all_users_exist_in_db = True
1✔
71
                usernames_to_update = {}
1✔
72
                for username in cached_usernames:
1✔
73
                    user = query_for_user(session=db_session, username=username)
1✔
74
                    if not user:
1✔
75
                        all_users_exist_in_db = False
1✔
76
                        continue
1✔
77
                    usernames_to_update[user.username] = user
1✔
78

79
                if all_users_exist_in_db:
1✔
80
                    users_from_all_passports.update(usernames_to_update)
1✔
81
                    # existence in the cache and a user in db means that this passport
82
                    # was validated previously (expiration was also checked)
83
                    continue
1✔
84

85
            # below function also validates passport (or raises exception)
86
            raw_visas = get_unvalidated_visas_from_valid_passport(
1✔
87
                passport, pkey_cache=pkey_cache
88
            )
89
        except Exception as exc:
×
90
            logger.warning(f"Invalid passport provided, ignoring. Error: {exc}")
×
91
            continue
×
92

93
        # an empty raw_visas list means that either the current passport is
94
        # invalid or that it has no visas. in both cases, the current passport
95
        # is ignored and we move on to the next passport
96
        if not raw_visas:
1✔
97
            continue
1✔
98

99
        identity_to_visas = collections.defaultdict(list)
1✔
100
        min_visa_expiration = int(time.time()) + datetime.timedelta(hours=1).seconds
1✔
101
        for raw_visa in raw_visas:
1✔
102
            try:
1✔
103
                validated_decoded_visa = validate_visa(raw_visa, pkey_cache=pkey_cache)
1✔
104
                identity_to_visas[
1✔
105
                    (
106
                        validated_decoded_visa.get("iss"),
107
                        validated_decoded_visa.get("sub"),
108
                    )
109
                ].append((raw_visa, validated_decoded_visa))
110
                min_visa_expiration = min(
1✔
111
                    min_visa_expiration, validated_decoded_visa.get("exp")
112
                )
113
            except Exception as exc:
1✔
114
                logger.warning(f"Invalid visa provided, ignoring. Error: {exc}")
1✔
115
                continue
1✔
116

117
        expired_authz_removal_job_freq_in_seconds = config[
1✔
118
            "EXPIRED_AUTHZ_REMOVAL_JOB_FREQ_IN_SECONDS"
119
        ]
120
        min_visa_expiration -= expired_authz_removal_job_freq_in_seconds
1✔
121
        if min_visa_expiration <= int(time.time()):
1✔
122
            logger.warning(
1✔
123
                "The passport's earliest valid visa expiration time is set to "
124
                f"occur within {expired_authz_removal_job_freq_in_seconds} "
125
                "seconds from now, which is too soon an expiration to handle."
126
            )
127
            continue
1✔
128

129
        users_from_current_passport = []
1✔
130
        for (issuer, subject_id), visas in identity_to_visas.items():
1✔
131
            gen3_user = get_or_create_gen3_user_from_iss_sub(
1✔
132
                issuer, subject_id, db_session=db_session
133
            )
134

135
            ga4gh_visas = [
1✔
136
                GA4GHVisaV1(
137
                    user=gen3_user,
138
                    source=validated_decoded_visa["ga4gh_visa_v1"]["source"],
139
                    type=validated_decoded_visa["ga4gh_visa_v1"]["type"],
140
                    asserted=int(validated_decoded_visa["ga4gh_visa_v1"]["asserted"]),
141
                    expires=int(validated_decoded_visa["exp"]),
142
                    ga4gh_visa=raw_visa,
143
                )
144
                for raw_visa, validated_decoded_visa in visas
145
            ]
146
            # NOTE: does not validate, assumes validation occurs above.
147
            #       This adds the visas to the database session but doesn't commit until
148
            #       the end of this function
149
            _sync_validated_visa_authorization(
1✔
150
                gen3_user=gen3_user,
151
                ga4gh_visas=ga4gh_visas,
152
                expiration=min_visa_expiration,
153
                db_session=db_session,
154
            )
155
            users_from_current_passport.append(gen3_user)
1✔
156

157
        for user in users_from_current_passport:
1✔
158
            users_from_all_passports[user.username] = user
1✔
159

160
        put_gen3_usernames_for_passport_into_cache(
1✔
161
            passport=passport,
162
            user_ids_from_passports=list(users_from_all_passports.keys()),
163
            expires_at=min_visa_expiration,
164
            db_session=db_session,
165
        )
166

167
    db_session.commit()
1✔
168

169
    logger.info(
1✔
170
        f"Got Gen3 usernames from passport(s): {list(users_from_all_passports.keys())}"
171
    )
172
    return users_from_all_passports
1✔
173

174

175
def get_unvalidated_visas_from_valid_passport(passport, pkey_cache=None):
1✔
176
    """
177
    Return encoded visas after extracting and validating encoded passport
178

179
    Args:
180
        passport (string): encoded ga4gh passport
181
        pkey_cache (dict): app cache of public keys_dir
182

183
    Return:
184
        list: list of encoded GA4GH visas
185
    """
186
    decoded_passport = {}
1✔
187
    passport_issuer, passport_kid = None, None
1✔
188

189
    if not pkey_cache:
1✔
190
        pkey_cache = {}
1✔
191

192
    try:
1✔
193
        passport_issuer = get_iss(passport)
1✔
194
        passport_kid = get_kid(passport)
1✔
195
    except Exception as e:
1✔
196
        logger.error(
1✔
197
            "Could not get issuer or kid from passport: {}. Discarding passport.".format(
198
                e
199
            )
200
        )
201
        # ignore malformed/invalid passports
202
        return []
1✔
203

204
    public_key = pkey_cache.get(passport_issuer, {}).get(passport_kid)
1✔
205

206
    try:
1✔
207
        decoded_passport = validate_jwt(
1✔
208
            encoded_token=passport,
209
            public_key=public_key,
210
            attempt_refresh=True,
211
            require_purpose=False,
212
            scope={"openid"},
213
            issuers=config.get("GA4GH_VISA_ISSUER_ALLOWLIST", []),
214
            options={
215
                "require_iat": True,
216
                "require_exp": True,
217
                "verify_aud": False,
218
            },
219
        )
220

221
        if "sub" not in decoded_passport:
1✔
222
            raise JWTError(f"Passport is missing the 'sub' claim")
×
223
    except Exception as e:
×
224
        logger.error("Passport failed validation: {}. Discarding passport.".format(e))
×
225
        # ignore malformed/invalid passports
226
        return []
×
227

228
    return decoded_passport.get("ga4gh_passport_v1", [])
1✔
229

230

231
def validate_visa(raw_visa, pkey_cache=None):
1✔
232
    """
233
    Validate a raw visa in accordance with:
234
        - GA4GH AAI spec (https://github.com/ga4gh/data-security/blob/master/AAI/AAIConnectProfile.md)
235
        - GA4GH DURI spec (https://github.com/ga4gh-duri/ga4gh-duri.github.io/blob/master/researcher_ids/ga4gh_passport_v1.md)
236

237
    Args:
238
        raw_visa (str): a raw, encoded visa including header, payload, and signature
239

240
    Return:
241
        dict: the decoded payload if validation was successful. an exception
242
              is raised if validation was unsuccessful
243
    """
244
    if jwt.get_unverified_header(raw_visa).get("jku"):
1✔
245
        raise Exception(
×
246
            "Visa Document Tokens are not currently supported by passing "
247
            '"jku" in the header. Only Visa Access Tokens are supported.'
248
        )
249

250
    logger.info("Attempting to validate visa")
1✔
251

252
    decoded_visa = validate_jwt(
1✔
253
        raw_visa,
254
        attempt_refresh=True,
255
        scope={"openid", "ga4gh_passport_v1"},
256
        require_purpose=False,
257
        issuers=config["GA4GH_VISA_ISSUER_ALLOWLIST"],
258
        options={"require_iat": True, "require_exp": True, "verify_aud": False},
259
        pkey_cache=pkey_cache,
260
    )
261
    logger.info(f'Visa jti: "{decoded_visa.get("jti", "")}"')
1✔
262
    logger.info(f'Visa txn: "{decoded_visa.get("txn", "")}"')
1✔
263

264
    for claim in ["sub", "ga4gh_visa_v1"]:
1✔
265
        if claim not in decoded_visa:
1✔
266
            raise Exception(f'Visa does not contain REQUIRED "{claim}" claim')
×
267

268
    if "aud" in decoded_visa:
1✔
269
        raise Exception('Visa MUST NOT contain "aud" claim')
×
270

271
    field_to_allowed_values = config["GA4GH_VISA_V1_CLAIM_REQUIRED_FIELDS"]
1✔
272
    for field, allowed_values in field_to_allowed_values.items():
1✔
273
        if field not in decoded_visa["ga4gh_visa_v1"]:
1✔
274
            raise Exception(
×
275
                f'"ga4gh_visa_v1" claim does not contain REQUIRED "{field}" field'
276
            )
277
        if decoded_visa["ga4gh_visa_v1"][field] not in allowed_values:
1✔
278
            raise Exception(
×
279
                f'{field}={decoded_visa["ga4gh_visa_v1"][field]} field in "ga4gh_visa_v1" is not equal to one of the allowed_values: {allowed_values}'
280
            )
281

282
    if "asserted" not in decoded_visa["ga4gh_visa_v1"]:
1✔
283
        raise Exception(
×
284
            '"ga4gh_visa_v1" claim does not contain REQUIRED "asserted" field'
285
        )
286
    asserted = decoded_visa["ga4gh_visa_v1"]["asserted"]
1✔
287
    if type(asserted) not in (int, float):
1✔
288
        raise Exception(
×
289
            '"ga4gh_visa_v1" claim object\'s "asserted" field\'s type is not '
290
            "JSON numeric"
291
        )
292
    if decoded_visa["iat"] < asserted:
1✔
293
        raise Exception(
×
294
            "The Passport Visa Assertion Source made the claim after the visa "
295
            'was minted (i.e. "ga4gh_visa_v1" claim object\'s "asserted" '
296
            'field is greater than the visa\'s "iat" claim)'
297
        )
298

299
    if "conditions" in decoded_visa["ga4gh_visa_v1"]:
1✔
300
        logger.warning(
×
301
            'Condition checking is not yet supported, but a visa was received that contained the "conditions" field'
302
        )
303
        if decoded_visa["ga4gh_visa_v1"]["conditions"]:
×
304
            raise Exception('"conditions" field in "ga4gh_visa_v1" is not empty')
×
305

306
    logger.info("Visa was successfully validated")
1✔
307
    return decoded_visa
1✔
308

309

310
def get_or_create_gen3_user_from_iss_sub(issuer, subject_id, db_session=None):
1✔
311
    """
312
    Get a user from the Fence database corresponding to the visa identity
313
    indicated by the <issuer, subject_id> combination. If a Fence user has
314
    not yet been created for the given <issuer, subject_id> combination,
315
    create and return such a user.
316

317
    Args:
318
        issuer (str): the issuer of a given visa
319
        subject_id (str): the subject of a given visa
320

321
    Return:
322
        userdatamodel.user.User: the Fence user corresponding to issuer and subject_id
323
    """
324
    db_session = db_session or current_app.scoped_session()
1✔
325
    logger.debug(
1✔
326
        f"get_or_create_gen3_user_from_iss_sub: issuer: {issuer} & subject_id: {subject_id}"
327
    )
328
    iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((issuer, subject_id))
1✔
329
    if not iss_sub_pair_to_user:
1✔
330
        username = subject_id + issuer[len("https://") :]
1✔
331
        gen3_user = query_for_user(session=db_session, username=username)
1✔
332
        idp_name = IssSubPairToUser.ISSUER_TO_IDP.get(issuer)
1✔
333
        logger.debug(f"issuer_to_idp: {IssSubPairToUser.ISSUER_TO_IDP}")
1✔
334
        if not gen3_user:
1✔
335
            gen3_user = create_user(db_session, logger, username, idp_name=idp_name)
1✔
336
            if not idp_name:
1✔
337
                logger.info(
1✔
338
                    f"The user (id:{gen3_user.id}) was created without a linked identity "
339
                    f"provider since it could not be determined based on "
340
                    f"the issuer {issuer}"
341
                )
342

343
        # ensure user has an associated identity provider
344
        if not gen3_user.identity_provider:
1✔
345
            idp = (
1✔
346
                db_session.query(IdentityProvider)
347
                .filter(IdentityProvider.name == idp_name)
348
                .first()
349
            )
350
            if not idp:
1✔
351
                idp = IdentityProvider(name=idp_name)
1✔
352
            gen3_user.identity_provider = idp
1✔
353

354
        logger.info(
1✔
355
            f'Mapping subject id ("{subject_id}") and issuer '
356
            f'("{issuer}") combination to Fence user '
357
            f'"{gen3_user.username}" with IdP = "{idp_name}"'
358
        )
359
        iss_sub_pair_to_user = IssSubPairToUser(iss=issuer, sub=subject_id)
1✔
360
        iss_sub_pair_to_user.user = gen3_user
1✔
361

362
        db_session.add(iss_sub_pair_to_user)
1✔
363
        db_session.commit()
1✔
364

365
    return iss_sub_pair_to_user.user
1✔
366

367

368
def _sync_validated_visa_authorization(
1✔
369
    gen3_user, ga4gh_visas, expiration, db_session=None
370
):
371
    """
372
    Wrapper around UserSyncer.sync_single_user_visas method, which parses
373
    authorization information from the provided visas, persists it in Fence,
374
    and syncs it to Arborist.
375

376
    IMPORTANT NOTE: THIS DOES NOT VALIDATE THE VISAS. ENSURE THIS IS DONE
377
                    BEFORE THIS.
378

379
    Args:
380
        gen3_user (userdatamodel.user.User): the Fence user whose visas'
381
                                             authz info is being synced
382
        ga4gh_visas (list): a list of fence.models.GA4GHVisaV1 objects
383
                            that are parsed
384
        expiration (int): time at which synced Arborist policies and
385
                          inclusion in any GBAG are set to expire
386

387
    Return:
388
        None
389
    """
390
    db_session = db_session or current_app.scoped_session()
1✔
391
    default_args = fence.scripting.fence_create.get_default_init_syncer_inputs(
1✔
392
        authz_provider="GA4GH"
393
    )
394
    syncer = fence.scripting.fence_create.init_syncer(**default_args)
1✔
395

396
    synced_visas = syncer.sync_single_user_visas(
1✔
397
        gen3_user,
398
        ga4gh_visas,
399
        db_session,
400
        expires=expiration,
401
    )
402

403
    # after syncing authorization, persist the visas that were parsed successfully.
404
    for visa in ga4gh_visas:
1✔
405
        if visa not in synced_visas:
1✔
406
            logger.debug(f"deleting visa with id={visa.id} from db session")
×
407
            db_session.delete(visa)
×
408
        else:
409
            logger.debug(f"adding visa with id={visa.id} to db session")
1✔
410
            db_session.add(visa)
1✔
411

412

413
def get_gen3_usernames_for_passport_from_cache(passport, db_session=None):
1✔
414
    """
415
    Attempt to retrieve a cached list of users ids for a previously validated and
416
    non-expired passport.
417

418
    Args:
419
        passport (str): ga4gh encoded passport JWT
420
        db_session (None, sqlalchemy session): optional database session to use
421

422
    Returns:
423
        list[str]: list of usernames for users referred to by the previously validated
424
                   and non-expired passport
425
    """
426
    db_session = db_session or current_app.scoped_session()
1✔
427
    user_ids_from_passports = None
1✔
428
    current_time = int(time.time())
1✔
429

430
    passport_hash = hashlib.sha256(passport.encode("utf-8")).hexdigest()
1✔
431

432
    # try to retrieve from local in-memory cache
433
    if passport_hash in PASSPORT_CACHE:
1✔
434
        user_ids_from_passports, expires = PASSPORT_CACHE[passport_hash]
1✔
435
        if expires > current_time:
1✔
436
            logger.debug(
1✔
437
                f"Got users {user_ids_from_passports} for provided passport from in-memory cache. "
438
                f"Expires: {expires}, Current Time: {current_time}"
439
            )
440
            return user_ids_from_passports
1✔
441
        else:
442
            # expired, so remove it
443
            del PASSPORT_CACHE[passport_hash]
1✔
444

445
    # try to retrieve from database cache
446
    cached_passport = (
1✔
447
        db_session.query(GA4GHPassportCache)
448
        .filter(GA4GHPassportCache.passport_hash == passport_hash)
449
        .first()
450
    )
451
    if cached_passport:
1✔
452
        if cached_passport.expires_at > current_time:
1✔
453
            user_ids_from_passports = cached_passport.user_ids
1✔
454

455
            # update local cache
456
            PASSPORT_CACHE[passport_hash] = (
1✔
457
                user_ids_from_passports,
458
                cached_passport.expires_at,
459
            )
460

461
            logger.debug(
1✔
462
                f"Got users {user_ids_from_passports} for provided passport from "
463
                f"database cache and placed in in-memory cache. "
464
                f"Expires: {cached_passport.expires_at}, Current Time: {current_time}"
465
            )
466
            return user_ids_from_passports
1✔
467
        else:
468
            # expired, so delete it
469
            db_session.delete(cached_passport)
×
470
            db_session.commit()
×
471

472
    return user_ids_from_passports
1✔
473

474

475
def put_gen3_usernames_for_passport_into_cache(
1✔
476
    passport, user_ids_from_passports, expires_at, db_session=None
477
):
478
    """
479
    Cache a validated and non-expired passport and map to the user_ids referenced
480
    by the content.
481

482
    Args:
483
        passport (str): ga4gh encoded passport JWT
484
        db_session (None, sqlalchemy session): optional database session to use
485
        user_ids_from_passports (list[str]): list of user identifiers referred to by
486
            the previously validated and non-expired passport
487
        expires_at (int): expiration time in unix time
488
    """
489
    db_session = db_session or current_app.scoped_session()
1✔
490

491
    passport_hash = hashlib.sha256(passport.encode("utf-8")).hexdigest()
1✔
492

493
    # stores back to cache and db
494
    PASSPORT_CACHE[passport_hash] = user_ids_from_passports, expires_at
1✔
495

496
    db_session.execute(
1✔
497
        """\
498
        INSERT INTO ga4gh_passport_cache (
499
            passport_hash,
500
            expires_at,
501
            user_ids
502
        ) VALUES (
503
            :passport_hash,
504
            :expires_at,
505
            :user_ids
506
        ) ON CONFLICT (passport_hash) DO UPDATE SET
507
            expires_at = EXCLUDED.expires_at,
508
            user_ids = EXCLUDED.user_ids;""",
509
        dict(
510
            passport_hash=passport_hash,
511
            expires_at=expires_at,
512
            user_ids=user_ids_from_passports,
513
        ),
514
    )
515

516
    logger.debug(
1✔
517
        f"Cached users {user_ids_from_passports} for provided passport in "
518
        f"database cache and placed in in-memory cache. "
519
        f"Expires: {expires_at}"
520
    )
521

522

523
# TODO to be called after login
524
def map_gen3_iss_sub_pair_to_user(gen3_issuer, gen3_subject_id, gen3_user):
1✔
525
    pass
×
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

© 2025 Coveralls, Inc