Skip to content

Commit ec41d33

Browse files
authored
VED-901-Auth: authorization for extended attribute (#1017)
1 parent 2d3f951 commit ec41d33

File tree

11 files changed

+405
-79
lines changed

11 files changed

+405
-79
lines changed

infrastructure/instance/file_name_processor.tf

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@ locals {
33
filename_lambda_dir = abspath("${path.root}/../../lambdas/filenameprocessor")
44
filename_lambda_files = fileset(local.filename_lambda_dir, "**")
55
filename_lambda_dir_sha = sha1(join("", [for f in local.filename_lambda_files : filesha1("${local.filename_lambda_dir}/${f}")]))
6+
dps_bucket_name_for_extended_attribute = (
7+
var.environment == "prod"
8+
? "nhsd-dspp-core-prod-extended-attributes-gdp"
9+
: "nhsd-dspp-core-ref-extended-attributes-gdp"
10+
)
11+
dps_bucket_arn_for_extended_attribute = [
12+
"arn:aws:s3:::${local.dps_bucket_name_for_extended_attribute}/*"
13+
]
614
}
715

16+
17+
818
resource "aws_ecr_repository" "file_name_processor_lambda_repository" {
919
image_scanning_configuration {
1020
scan_on_push = true
@@ -162,6 +172,13 @@ resource "aws_iam_policy" "filenameprocessor_lambda_exec_policy" {
162172
"firehose:PutRecordBatch"
163173
],
164174
"Resource" : "arn:aws:firehose:*:*:deliverystream/${module.splunk.firehose_stream_name}"
175+
},
176+
{
177+
"Effect" : "Allow",
178+
"Action" : [
179+
"s3:PutObject"
180+
],
181+
"Resource" : local.dps_bucket_arn_for_extended_attribute
165182
}
166183
]
167184
})
@@ -278,8 +295,10 @@ resource "aws_lambda_function" "file_processor_lambda" {
278295
environment {
279296
variables = {
280297
ACCOUNT_ID = var.immunisation_account_id
298+
DPS_ACCOUNT_ID = var.dspp_core_account_id
281299
SOURCE_BUCKET_NAME = aws_s3_bucket.batch_data_source_bucket.bucket
282300
ACK_BUCKET_NAME = aws_s3_bucket.batch_data_destination_bucket.bucket
301+
DPS_BUCKET_NAME = local.dps_bucket_name_for_extended_attribute
283302
QUEUE_URL = aws_sqs_queue.batch_file_created.url
284303
REDIS_HOST = data.aws_elasticache_cluster.existing_redis.cache_nodes[0].address
285304
REDIS_PORT = data.aws_elasticache_cluster.existing_redis.cache_nodes[0].port

infrastructure/instance/s3_config.tf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,18 @@ resource "aws_s3_bucket_lifecycle_configuration" "datasources_lifecycle" {
8484
filter {
8585
prefix = "archive/"
8686
}
87+
expiration {
88+
days = 7
89+
}
90+
}
8791

92+
rule {
93+
id = "DeleteExtendedAttributesFilesAfter7Days"
94+
status = "Enabled"
95+
96+
filter {
97+
prefix = "extended-attributes-archive/"
98+
}
8899
expiration {
89100
days = 7
90101
}

lambdas/filenameprocessor/src/constants.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,24 @@
1212

1313
SOURCE_BUCKET_NAME = os.getenv("SOURCE_BUCKET_NAME")
1414

15-
# We have used an internal temporary bucket here and an actual dps bucket will replace this
16-
DPS_DESTINATION_BUCKET_NAME = os.getenv("ACK_BUCKET_NAME")
17-
EXPECTED_BUCKET_OWNER_ACCOUNT = os.getenv("ACCOUNT_ID")
15+
16+
DPS_DESTINATION_BUCKET_NAME = os.getenv("DPS_BUCKET_NAME")
17+
EXPECTED_SOURCE_BUCKET_ACCOUNT = os.getenv("ACCOUNT_ID")
18+
EXPECTED_DPS_DESTINATION_ACCOUNT = os.getenv("DPS_ACCOUNT_ID")
1819
AUDIT_TABLE_NAME = os.getenv("AUDIT_TABLE_NAME")
1920
AUDIT_TABLE_TTL_DAYS = os.getenv("AUDIT_TABLE_TTL_DAYS")
2021
VALID_VERSIONS = ["V5"]
2122

2223
VACCINE_TYPE_TO_DISEASES_HASH_KEY = "vacc_to_diseases"
2324
ODS_CODE_TO_SUPPLIER_SYSTEM_HASH_KEY = "ods_code_to_supplier"
2425
EXTENDED_ATTRIBUTES_FILE_PREFIX = "Vaccination_Extended_Attributes"
26+
27+
# Currently only COVID extended attributes files are supported, might be extended in future for other vaccine types
2528
EXTENDED_ATTRIBUTES_VACC_TYPE = "COVID"
26-
DPS_DESTINATION_PREFIX = "dps_destination/"
2729

30+
DPS_DESTINATION_PREFIX = "dps_destination"
31+
EXTENDED_ATTRIBUTES_ARCHIVE_PREFIX = "extended-attributes-archive"
32+
VALID_EA_VERSIONS = ["V1_5"]
2833
ERROR_TYPE_TO_STATUS_CODE_MAP = {
2934
VaccineTypePermissionsError: 403,
3035
InvalidFileKeyError: 400, # Includes invalid ODS code, therefore unable to identify supplier
@@ -61,3 +66,9 @@ class AuditTableKeys(StrEnum):
6166
TIMESTAMP = "timestamp"
6267
EXPIRES_AT = "expires_at"
6368
ERROR_DETAILS = "error_details"
69+
70+
71+
class Operation(str):
72+
CREATE = "C"
73+
UPDATE = "U"
74+
DELETE = "D"

lambdas/filenameprocessor/src/file_name_processor.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@
1212
from audit_table import upsert_audit_table
1313
from common.aws_s3_utils import (
1414
copy_file_to_external_bucket,
15-
delete_file,
1615
move_file,
1716
)
1817
from common.clients import STREAM_NAME, get_s3_client, logger
1918
from common.log_decorator import logging_decorator
2019
from common.models.errors import UnhandledAuditTableError
2120
from constants import (
2221
DPS_DESTINATION_BUCKET_NAME,
22+
DPS_DESTINATION_PREFIX,
2323
ERROR_TYPE_TO_STATUS_CODE_MAP,
24-
EXPECTED_BUCKET_OWNER_ACCOUNT,
24+
EXPECTED_DPS_DESTINATION_ACCOUNT,
25+
EXPECTED_SOURCE_BUCKET_ACCOUNT,
26+
EXTENDED_ATTRIBUTES_ARCHIVE_PREFIX,
2527
EXTENDED_ATTRIBUTES_FILE_PREFIX,
26-
EXTENDED_ATTRIBUTES_VACC_TYPE,
2728
SOURCE_BUCKET_NAME,
2829
FileNotProcessedReason,
2930
FileStatus,
@@ -36,7 +37,7 @@
3637
VaccineTypePermissionsError,
3738
)
3839
from send_sqs_message import make_and_send_sqs_message
39-
from supplier_permissions import validate_vaccine_type_permissions
40+
from supplier_permissions import validate_permissions_for_extended_attributes_files, validate_vaccine_type_permissions
4041
from utils_for_filenameprocessor import get_creation_and_expiry_times
4142

4243

@@ -107,8 +108,8 @@ def handle_unexpected_bucket_name(bucket_name: str, file_key: str) -> dict:
107108
config and overarching design"""
108109
try:
109110
if file_key.startswith(EXTENDED_ATTRIBUTES_FILE_PREFIX):
110-
organization_code = validate_extended_attributes_file_key(file_key)
111-
extended_attribute_identifier = f"{organization_code}_{EXTENDED_ATTRIBUTES_VACC_TYPE}"
111+
vaccine_type, organisation_code = validate_extended_attributes_file_key(file_key)
112+
extended_attribute_identifier = f"{organisation_code}_{vaccine_type}"
112113
logger.error(
113114
"Unable to process file %s due to unexpected bucket name %s",
114115
file_key,
@@ -249,8 +250,10 @@ def handle_extended_attributes_file(
249250

250251
extended_attribute_identifier = None
251252
try:
252-
organization_code = validate_extended_attributes_file_key(file_key)
253-
extended_attribute_identifier = f"{organization_code}_{EXTENDED_ATTRIBUTES_VACC_TYPE}"
253+
vaccine_type, organisation_code = validate_extended_attributes_file_key(file_key)
254+
extended_attribute_identifier = validate_permissions_for_extended_attributes_files(
255+
vaccine_type, organisation_code
256+
)
254257

255258
upsert_audit_table(
256259
message_id,
@@ -262,16 +265,17 @@ def handle_extended_attributes_file(
262265
)
263266

264267
# TODO: agree the prefix with DPS
265-
dest_file_key = f"dps_destination/{file_key}"
268+
dest_file_key = f"{DPS_DESTINATION_PREFIX}/{file_key}"
266269
copy_file_to_external_bucket(
267270
bucket_name,
268271
file_key,
269272
DPS_DESTINATION_BUCKET_NAME,
270273
dest_file_key,
271-
EXPECTED_BUCKET_OWNER_ACCOUNT,
272-
EXPECTED_BUCKET_OWNER_ACCOUNT,
274+
EXPECTED_DPS_DESTINATION_ACCOUNT,
275+
EXPECTED_SOURCE_BUCKET_ACCOUNT,
273276
)
274-
delete_file(bucket_name, dest_file_key, EXPECTED_BUCKET_OWNER_ACCOUNT)
277+
278+
move_file(bucket_name, file_key, f"{EXTENDED_ATTRIBUTES_ARCHIVE_PREFIX}/{file_key}")
275279

276280
upsert_audit_table(
277281
message_id,
@@ -305,7 +309,7 @@ def handle_extended_attributes_file(
305309
extended_attribute_identifier = "unknown"
306310

307311
# Move file to archive
308-
move_file(bucket_name, file_key, f"archive/{file_key}")
312+
move_file(bucket_name, file_key, f"{EXTENDED_ATTRIBUTES_ARCHIVE_PREFIX}/{file_key}")
309313

310314
upsert_audit_table(
311315
message_id,

lambdas/filenameprocessor/src/file_validation.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime
44
from re import match
55

6-
from constants import VALID_VERSIONS
6+
from constants import EXTENDED_ATTRIBUTES_FILE_PREFIX, EXTENDED_ATTRIBUTES_VACC_TYPE, VALID_EA_VERSIONS, VALID_VERSIONS
77
from elasticache import (
88
get_supplier_system_from_cache,
99
get_valid_vaccine_types_from_cache,
@@ -37,17 +37,36 @@ def is_valid_datetime(timestamp: str) -> bool:
3737
return True
3838

3939

40-
def validate_extended_attributes_file_key(file_key: str) -> str:
40+
def validate_extended_attributes_file_key(file_key: str) -> tuple[str, str]:
4141
"""
4242
Checks that all elements of the file key are valid, raises an exception otherwise.
4343
Returns a string containing the organization code.
4444
"""
4545
if not match(r"^[^_.]*_[^_.]*_[^_.]*_[^_.]*_[^_.]*_[^_.]*_[^_.]*", file_key):
4646
raise InvalidFileKeyError("Initial file validation failed: invalid extended attributes file key format")
4747

48-
file_key_parts_without_extension, _ = split_file_key(file_key)
48+
file_key_parts_without_extension, extension = split_file_key(file_key)
49+
file_type = "_".join(file_key_parts_without_extension[:3])
50+
version = "_".join(file_key_parts_without_extension[3:5])
4951
organization_code = file_key_parts_without_extension[5]
50-
return organization_code
52+
timestamp = file_key_parts_without_extension[6]
53+
supplier = get_supplier_system_from_cache(organization_code)
54+
valid_vaccine_types = get_valid_vaccine_types_from_cache()
55+
vaccine_type = EXTENDED_ATTRIBUTES_VACC_TYPE
56+
57+
if not (
58+
vaccine_type in valid_vaccine_types
59+
and file_type == EXTENDED_ATTRIBUTES_FILE_PREFIX.upper()
60+
and version in VALID_EA_VERSIONS
61+
and supplier # Note that if supplier could be identified, this also implies that ODS code is valid
62+
and is_valid_datetime(timestamp)
63+
and (
64+
(extension == "CSV") or (extension == "DAT") or (extension == "CTL")
65+
) # The DAT extension has been added for MESH file processing
66+
):
67+
raise InvalidFileKeyError("Initial file validation failed: invalid file key")
68+
69+
return vaccine_type, organization_code
5170

5271

5372
def validate_batch_file_key(file_key: str) -> tuple[str, str]:

lambdas/filenameprocessor/src/supplier_permissions.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Functions for fetching supplier permissions"""
22

33
from common.clients import logger
4-
from elasticache import get_supplier_permissions_from_cache
4+
from constants import Operation
5+
from elasticache import get_supplier_permissions_from_cache, get_supplier_system_from_cache
56
from models.errors import VaccineTypePermissionsError
67

78

@@ -19,3 +20,26 @@ def validate_vaccine_type_permissions(vaccine_type: str, supplier: str) -> list:
1920
raise VaccineTypePermissionsError(error_message)
2021

2122
return supplier_permissions
23+
24+
25+
def validate_permissions_for_extended_attributes_files(vaccine_type: str, ods_code: str) -> str:
26+
"""
27+
Checks that the supplier has COVID vaccine type and its CUD permissions.
28+
Raises an exception if the supplier does not have at least one permission for the vaccine type.
29+
"""
30+
allowed_operations = {
31+
Operation.CREATE,
32+
Operation.UPDATE,
33+
Operation.DELETE,
34+
}
35+
supplier = get_supplier_system_from_cache(ods_code)
36+
supplier_permissions = get_supplier_permissions_from_cache(supplier)
37+
cached_operations = [
38+
permission.split(".")[1] for permission in supplier_permissions if permission.split(".")[0] == vaccine_type
39+
]
40+
if not (cached_operations and allowed_operations.issubset(set(cached_operations[0]))):
41+
error_message = f"Initial file validation failed: {supplier} does not have permissions for {vaccine_type}"
42+
logger.error(error_message)
43+
raise VaccineTypePermissionsError(error_message)
44+
45+
return f"{ods_code}_{vaccine_type}"

lambdas/filenameprocessor/tests/test_file_key_validation.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ def test_split_file_key(self, _):
113113
with self.subTest(f"SubTest for file key: {file_key}"):
114114
self.assertEqual(split_file_key(file_key), expected)
115115

116-
def test_validate_extended_attributes_file_key(self, _):
117-
"""Tests that validate_extended_attributes_file_key returns organization code if all
116+
def test_validate_extended_attributes_file_key(self, mock_get_redis_client):
117+
"""Tests that validate_extended_attributes_file_key returns organization code and COVID vaccine type if all
118118
elements pass validation, and raises an exception otherwise"""
119119
test_cases_for_success_scenarios = [
120120
# Valid extended attributes file key
@@ -131,8 +131,13 @@ def test_validate_extended_attributes_file_key(self, _):
131131

132132
for file_key, expected_result in test_cases_for_success_scenarios:
133133
with self.subTest(f"SubTest for file key: {file_key}"):
134+
mock_redis = Mock()
135+
mock_redis.hget.side_effect = create_mock_hget(MOCK_ODS_CODE_TO_SUPPLIER, {})
136+
mock_redis.hkeys.return_value = ["COVID"]
137+
mock_get_redis_client.return_value = mock_redis
138+
_, supplier = validate_extended_attributes_file_key(file_key)
134139
self.assertEqual(
135-
validate_extended_attributes_file_key(file_key),
140+
supplier,
136141
expected_result,
137142
)
138143

0 commit comments

Comments
 (0)