Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Access codes for automatic user approval #2285

Merged
merged 28 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
01a028b
first draft of access codes implementation
jtherrmann May 20, 2024
9b73490
pass AccessCodesTable name to API stack, add a TODO
jtherrmann May 20, 2024
80fd79f
add access codes table to cfg.env, add a TODO
jtherrmann May 20, 2024
c758ea0
add a TODO
jtherrmann May 20, 2024
e3a75ba
add access_code to user record
jtherrmann May 20, 2024
66b5328
remove a TODO
jtherrmann May 21, 2024
f366a10
mock access codes table
jtherrmann May 21, 2024
31580c0
factor out a dynamo.util.current_time function
jtherrmann May 21, 2024
9edf91c
add test_update_user_access_code
jtherrmann May 21, 2024
c49a217
add a todo
jtherrmann May 21, 2024
e99a071
split into multiple tests
jtherrmann May 22, 2024
86934e5
add test_patch_user_access_code
jtherrmann May 22, 2024
6fd2570
add test_patch_user_access_code_expired
jtherrmann May 22, 2024
30909b4
add test_patch_user_access_code_invalid
jtherrmann May 22, 2024
4f6b71e
remove default_credits parameter from _reset_credits_if_needed
jtherrmann May 22, 2024
31231e6
refactor update_user to call _reset_credits_if_needed
jtherrmann May 22, 2024
a957251
changelog
jtherrmann May 22, 2024
565dda3
Merge branch 'develop' into access-codes
jtherrmann May 22, 2024
6c006f2
unused import
jtherrmann May 22, 2024
093257c
Merge branch 'access-codes' of github.com:ASFHyP3/hyp3 into access-codes
jtherrmann May 22, 2024
3222602
rename `expires` field to `end_date`
jtherrmann May 23, 2024
ff9a7f6
allow any string for access_code
jtherrmann May 23, 2024
7992fac
Update lib/dynamo/dynamo/user.py
jtherrmann May 23, 2024
a53bce2
rename function
jtherrmann May 23, 2024
adf2e8a
rename some tests
jtherrmann May 23, 2024
b76294d
rename function in mocks
jtherrmann May 23, 2024
5e4edee
add start_date for access codes
jtherrmann May 23, 2024
d3ac85c
update changelog
jtherrmann May 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [7.3.0]

This release adds support for access codes. If a user specifies an active access code when they apply for HyP3 access, they will be granted automatic approval without the need for a HyP3 operator to review their application.

If you operate a HyP3 deployment, you can create a new access code by adding an item to the `AccessCodesTable` DynamoDB table for your deployment, with any string for the `access_code` attribute and an ISO-formatted UTC timestamp for the `start_date` and `end_date` attributes, e.g. `2024-06-01T00:00:00+00:00` and `2024-06-02T00:00:00+00:00` for an access code that becomes active on June 1, 2024 and expires on June 2, 2024.

### Added
- The `PATCH /user` endpoint now includes an optional `access_code` parameter and returns a `403` response if given an invalid or inactive access code.

## [7.2.1]

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions apps/api/api-cf.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Parameters:
UsersTable:
Type: String

AccessCodesTable:
Type: String

AuthPublicKey:
Type: String

Expand Down Expand Up @@ -171,6 +174,10 @@ Resources:
- dynamodb:PutItem
- dynamodb:UpdateItem
Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UsersTable}*"
- Effect: Allow
Action:
- dynamodb:GetItem
Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${AccessCodesTable}*"

Lambda:
Type: AWS::Lambda::Function
Expand All @@ -179,6 +186,7 @@ Resources:
Variables:
JOBS_TABLE_NAME: !Ref JobsTable
USERS_TABLE_NAME: !Ref UsersTable
ACCESS_CODES_TABLE_NAME: !Ref AccessCodesTable
AUTH_PUBLIC_KEY: !Ref AuthPublicKey
AUTH_ALGORITHM: !Ref AuthAlgorithm
DEFAULT_CREDITS_PER_USER: !Ref DefaultCreditsPerUser
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ components:
properties:
use_case:
$ref: "#/components/schemas/use_case"
access_code:
$ref: "#/components/schemas/access_code"

user:
description: Information about a user
Expand Down Expand Up @@ -315,6 +317,11 @@ components:
type: string
example: I want to process data.

access_code:
description: Grants automatic user approval while the code remains active.
type: string
example: 123

user_id:
description: Username from Earthdata Login.
type: string
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/hyp3_api/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from flask import abort, jsonify, request

import dynamo
from dynamo.exceptions import InsufficientCreditsError, UnexpectedApplicationStatusError
from dynamo.exceptions import AccessCodeError, InsufficientCreditsError, UnexpectedApplicationStatusError
from hyp3_api import util
from hyp3_api.validation import GranuleValidationError, validate_jobs

Expand Down Expand Up @@ -65,6 +65,8 @@ def patch_user(body: dict, user: str, edl_access_token: str) -> dict:
print(body)
try:
user_record = dynamo.user.update_user(user, edl_access_token, body)
except AccessCodeError as e:
abort(problem_format(403, str(e)))
except UnexpectedApplicationStatusError as e:
abort(problem_format(403, str(e)))
return _user_response(user_record)
Expand Down
12 changes: 12 additions & 0 deletions apps/main-cf.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Resources:
Parameters:
JobsTable: !Ref JobsTable
UsersTable: !Ref UsersTable
AccessCodesTable: !Ref AccessCodesTable
AuthPublicKey: !Ref AuthPublicKey
AuthAlgorithm: !Ref AuthAlgorithm
DefaultCreditsPerUser: !Ref DefaultCreditsPerUser
Expand Down Expand Up @@ -356,6 +357,17 @@ Resources:
- AttributeName: user_id
KeyType: HASH

AccessCodesTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: access_code
AttributeType: S
KeySchema:
- AttributeName: access_code
KeyType: HASH

{% if security_environment == 'EDC' %}
DisablePrivateDNS:
Type: AWS::CloudFormation::Stack
Expand Down
4 changes: 4 additions & 0 deletions lib/dynamo/dynamo/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ class DatabaseConditionException(Exception):
"""Raised when a DynamoDB condition expression check fails."""


class AccessCodeError(Exception):
"""Raised when a user application includes an invalid or expired access code."""


class InsufficientCreditsError(Exception):
"""Raised when trying to submit jobs whose total cost exceeds the user's remaining credits."""

Expand Down
5 changes: 2 additions & 3 deletions lib/dynamo/dynamo/jobs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
from datetime import datetime, timezone
from decimal import Decimal
from os import environ
from pathlib import Path
Expand All @@ -17,7 +16,7 @@
RejectedApplicationError,
)
from dynamo.user import APPLICATION_APPROVED, APPLICATION_NOT_STARTED, APPLICATION_PENDING, APPLICATION_REJECTED
from dynamo.util import DYNAMODB_RESOURCE, convert_floats_to_decimals, format_time, get_request_time_expression
from dynamo.util import DYNAMODB_RESOURCE, convert_floats_to_decimals, current_utc_time, get_request_time_expression

costs_file = Path(__file__).parent / 'costs.json'
COSTS = convert_floats_to_decimals(json.loads(costs_file.read_text()))
Expand All @@ -32,7 +31,7 @@

def put_jobs(user_id: str, jobs: List[dict], dry_run=False) -> List[dict]:
table = DYNAMODB_RESOURCE.Table(environ['JOBS_TABLE_NAME'])
request_time = format_time(datetime.now(timezone.utc))
request_time = current_utc_time()

user_record = dynamo.user.get_or_create_user(user_id)

Expand Down
58 changes: 44 additions & 14 deletions lib/dynamo/dynamo/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
import botocore.exceptions
import requests

import dynamo.util
from dynamo.exceptions import (
ApprovedApplicationError, DatabaseConditionException, InvalidApplicationStatusError, RejectedApplicationError
AccessCodeError,
ApprovedApplicationError,
DatabaseConditionException,
InvalidApplicationStatusError,
RejectedApplicationError,
)
from dynamo.util import DYNAMODB_RESOURCE

Expand All @@ -21,26 +26,44 @@ def update_user(user_id: str, edl_access_token: str, body: dict) -> dict:
user = get_or_create_user(user_id)
application_status = user['application_status']
if application_status in (APPLICATION_NOT_STARTED, APPLICATION_PENDING):
access_code = body.get('access_code')
if access_code:
_validate_access_code(access_code)
updated_application_status = APPLICATION_APPROVED
access_code_expression = ', access_code = :access_code'
access_code_value = {':access_code': access_code}
else:
updated_application_status = APPLICATION_PENDING
access_code_expression = ''
access_code_value = {}
edl_profile = _get_edl_profile(user_id, edl_access_token)
users_table = DYNAMODB_RESOURCE.Table(environ['USERS_TABLE_NAME'])
try:
user = users_table.update_item(
Key={'user_id': user_id},
UpdateExpression='SET #edl_profile = :edl_profile, use_case = :use_case, application_status = :pending',
UpdateExpression=(
'SET #edl_profile = :edl_profile,'
' use_case = :use_case,'
' application_status = :updated_application_status'
f'{access_code_expression}'
),
ConditionExpression='application_status IN (:not_started, :pending)',
ExpressionAttributeNames={'#edl_profile': '_edl_profile'},
ExpressionAttributeValues={
':edl_profile': edl_profile,
':use_case': body['use_case'],
':not_started': APPLICATION_NOT_STARTED,
':pending': APPLICATION_PENDING
':pending': APPLICATION_PENDING,
':updated_application_status': updated_application_status,
**access_code_value
},
ReturnValues='ALL_NEW',
)['Attributes']
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
raise DatabaseConditionException(f'Failed to update record for user {user_id}')
raise
user = _reset_credits_if_needed(user=user, current_month=_get_current_month(), users_table=users_table)
return user
if application_status == APPLICATION_REJECTED:
raise RejectedApplicationError(user_id)
Expand All @@ -49,6 +72,21 @@ def update_user(user_id: str, edl_access_token: str, body: dict) -> dict:
raise InvalidApplicationStatusError(user_id, application_status)


def _validate_access_code(access_code: str) -> None:
access_codes_table = DYNAMODB_RESOURCE.Table(environ['ACCESS_CODES_TABLE_NAME'])
item = access_codes_table.get_item(Key={'access_code': access_code}).get('Item')

if item is None:
raise AccessCodeError(f'{access_code} is not a valid access code')

jtherrmann marked this conversation as resolved.
Show resolved Hide resolved
now = dynamo.util.current_utc_time()
if now < item['start_date']:
raise AccessCodeError(f'Access code {access_code} will become active on {item["start_date"]}')

if now >= item['end_date']:
raise AccessCodeError(f'Access code {access_code} expired on {item["end_date"]}')


def _get_edl_profile(user_id: str, edl_access_token: str) -> dict:
url = f'https://urs.earthdata.nasa.gov/api/users/{user_id}'
response = requests.get(url, headers={'Authorization': f'Bearer {edl_access_token}'})
Expand All @@ -57,21 +95,13 @@ def _get_edl_profile(user_id: str, edl_access_token: str) -> dict:


def get_or_create_user(user_id: str) -> dict:
current_month = _get_current_month()
default_credits = Decimal(os.environ['DEFAULT_CREDITS_PER_USER'])

users_table = DYNAMODB_RESOURCE.Table(environ['USERS_TABLE_NAME'])
user = users_table.get_item(Key={'user_id': user_id}).get('Item')

if user is None:
user = _create_user(user_id, users_table)

return _reset_credits_if_needed(
user=user,
default_credits=default_credits,
current_month=current_month,
users_table=users_table,
)
return _reset_credits_if_needed(user=user, current_month=_get_current_month(), users_table=users_table)


def _get_current_month() -> str:
Expand All @@ -93,7 +123,7 @@ def _create_user(user_id: str, users_table) -> dict:
return user


def _reset_credits_if_needed(user: dict, default_credits: Decimal, current_month: str, users_table) -> dict:
def _reset_credits_if_needed(user: dict, current_month: str, users_table) -> dict:
if (
user['application_status'] == APPLICATION_APPROVED
and user.get('_month_of_last_credit_reset', '0') < current_month # noqa: W503
Expand All @@ -112,7 +142,7 @@ def _reset_credits_if_needed(user: dict, default_credits: Decimal, current_month
ExpressionAttributeNames={'#month_of_last_credit_reset': '_month_of_last_credit_reset'},
ExpressionAttributeValues={
':approved': APPLICATION_APPROVED,
':credits': user.get('credits_per_month', default_credits),
':credits': user.get('credits_per_month', Decimal(os.environ['DEFAULT_CREDITS_PER_USER'])),
':current_month': current_month,
':number': 'N',
},
Expand Down
6 changes: 5 additions & 1 deletion lib/dynamo/dynamo/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ def get_request_time_expression(start, end):
return key.lte(formatted_end)


def format_time(time: datetime):
def format_time(time: datetime) -> str:
if time.tzinfo is None:
raise ValueError(f'missing tzinfo for datetime {time}')
utc_time = time.astimezone(timezone.utc)
return utc_time.isoformat(timespec='seconds')


def current_utc_time() -> str:
return format_time(datetime.now(timezone.utc))


def convert_floats_to_decimals(element):
if type(element) is float:
return Decimal(str(element))
Expand Down
1 change: 1 addition & 0 deletions tests/cfg.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FLASK_DEBUG=true
JOBS_TABLE_NAME=hyp3-db-table-job
USERS_TABLE_NAME=hyp3-db-table-user
ACCESS_CODES_TABLE_NAME=hyp3-db-table-access-codes
AUTH_PUBLIC_KEY=123456789
AUTH_ALGORITHM=HS256
DEFAULT_CREDITS_PER_USER=25
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def table_properties():
class TableProperties:
jobs_table = get_table_properties_from_template('JobsTable')
users_table = get_table_properties_from_template('UsersTable')
access_codes_table = get_table_properties_from_template('AccessCodesTable')
return TableProperties()


Expand Down Expand Up @@ -40,6 +41,10 @@ class Tables:
TableName=environ['USERS_TABLE_NAME'],
**table_properties.users_table,
)
access_codes_table = DYNAMODB_RESOURCE.create_table(
TableName=environ['ACCESS_CODES_TABLE_NAME'],
**table_properties.access_codes_table,
)
tables = Tables()
yield tables

Expand Down
5 changes: 2 additions & 3 deletions tests/test_api/test_get_user.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from datetime import datetime, timezone
from http import HTTPStatus

from test_api.conftest import USER_URI, login, make_db_record

from dynamo.user import APPLICATION_APPROVED, APPLICATION_NOT_STARTED, APPLICATION_REJECTED
from dynamo.util import format_time
from dynamo.util import current_utc_time


def test_get_new_user(client, tables, monkeypatch):
Expand Down Expand Up @@ -46,7 +45,7 @@ def test_get_user_with_jobs(client, tables):
}
tables.users_table.put_item(Item=user)

request_time = format_time(datetime.now(timezone.utc))
request_time = current_utc_time()
items = [
make_db_record('job1', user_id=user_id, request_time=request_time, status_code='PENDING', name='job1'),
make_db_record('job2', user_id=user_id, request_time=request_time, status_code='RUNNING', name='job1'),
Expand Down
Loading
Loading