From 5b39f1dad1c1e0e49bb5f4cc0b8bfe2d97d5ef0e Mon Sep 17 00:00:00 2001 From: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:21:36 +0000 Subject: [PATCH] fix: resolve web UI auth token from Secrets Manager at runtime Replace the plaintext SSM String parameter with a Secrets Manager secret referenced by ARN only. The token is fetched and cached at module level on first invocation, keeping shared secrets out of CloudFormation templates and Lambda environment variables. Refs: PR-97 --- cdk/po_stack.py | 29 +++++++++++++++++------------ cdk/wo_stack.py | 30 +++++++++++++++++------------- lambdas/po/web_ui/handler.py | 35 +++++++++++++++++++++++++++++------ lambdas/wo/web_ui/handler.py | 33 ++++++++++++++++++++++++++++----- 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/cdk/po_stack.py b/cdk/po_stack.py index 9913809..47d8536 100644 --- a/cdk/po_stack.py +++ b/cdk/po_stack.py @@ -296,6 +296,17 @@ class PoIngestStack(Stack): ], ) + # --- Web UI auth token secret --- + # Shared secret for the web UI auth gate, stored in Secrets Manager and + # resolved at runtime so the token never appears in CloudFormation templates + # or Lambda environment variables. Create this secret before deploying + # either stack; both PO and WO stacks reference it by name. + web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2( + self, + "WebUiAuthToken", + "procurement-ingest/web-ui-auth-token", + ) + # --- Web UI Lambda --- web_ui = lambda_.Function( self, @@ -311,22 +322,16 @@ class PoIngestStack(Stack): environment={ "PO_TABLE": "purchase-orders", # Defense-in-depth shared secret for the web UI handler. The - # handler fails closed if this is unset, so any future invocation - # path cannot re-expose the PO DB unauthenticated. Sourced from the - # SSM String parameter /procurement-ingest/web-ui-auth-token; create - # it before deploy. NOTE: a plaintext String (not SecureString) is - # used deliberately — value_for_string_parameter resolves String - # params into Lambda env vars at deploy via {{resolve:ssm:...}}; - # SecureString dynamic references are NOT resolved in Lambda env - # vars and would pass the token literally. This is a defense-in-depth - # floor for a currently-detached URL (INFRA-74), not a primary auth. - "WEB_UI_AUTH_TOKEN": ssm.StringParameter.value_for_string_parameter( - self, "/procurement-ingest/web-ui-auth-token" - ), + # handler fails closed if this ARN is unset or the secret is + # missing, so any future invocation path cannot re-expose the + # PO DB unauthenticated. The secret value is fetched at runtime + # from Secrets Manager (not embedded in env vars or template). + "WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn, }, ) po_table.grant_read_data(web_ui) + web_ui_auth_secret.grant_read(web_ui) # --- Throttles alarm: po-web-ui --- web_ui.metric_throttles( diff --git a/cdk/wo_stack.py b/cdk/wo_stack.py index 0b38009..dfb83c1 100644 --- a/cdk/wo_stack.py +++ b/cdk/wo_stack.py @@ -17,7 +17,6 @@ from aws_cdk import ( aws_secretsmanager as secretsmanager, aws_sns as sns, aws_sqs as sqs, - aws_ssm as ssm, ) from constructs import Construct @@ -303,6 +302,17 @@ class WorkorderIngestStack(Stack): ], ) + # --- Web UI auth token secret --- + # Shared secret for the web UI auth gate, stored in Secrets Manager and + # resolved at runtime so the token never appears in CloudFormation templates + # or Lambda environment variables. Create this secret before deploying + # either stack; both PO and WO stacks reference it by name. + web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2( + self, + "WebUiAuthToken", + "procurement-ingest/web-ui-auth-token", + ) + # --- Web UI Lambda --- web_ui = lambda_.Function( self, @@ -319,23 +329,17 @@ class WorkorderIngestStack(Stack): "WORK_ORDERS_TABLE": work_orders_table.table_name, "COMMENTS_TABLE": comments_table.table_name, # Defense-in-depth shared secret for the web UI handler. The - # handler fails closed if this is unset, so any future invocation - # path cannot re-expose the WO DB unauthenticated. Sourced from the - # SSM String parameter /procurement-ingest/web-ui-auth-token; create - # it before deploy. NOTE: a plaintext String (not SecureString) is - # used deliberately — value_for_string_parameter resolves String - # params into Lambda env vars at deploy via {{resolve:ssm:...}}; - # SecureString dynamic references are NOT resolved in Lambda env - # vars and would pass the token literally. This is a defense-in-depth - # floor for a currently-detached URL (INFRA-74), not a primary auth. - "WEB_UI_AUTH_TOKEN": ssm.StringParameter.value_for_string_parameter( - self, "/procurement-ingest/web-ui-auth-token" - ), + # handler fails closed if this ARN is unset or the secret is + # missing, so any future invocation path cannot re-expose the + # WO DB unauthenticated. The secret value is fetched at runtime + # from Secrets Manager (not embedded in env vars or template). + "WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn, }, ) work_orders_table.grant_read_data(web_ui) comments_table.grant_read_data(web_ui) + web_ui_auth_secret.grant_read(web_ui) # --- DynamoDB throttle + system-error alarms --- # ThrottledRequests / SystemErrors emit at TableName + Operation only diff --git a/lambdas/po/web_ui/handler.py b/lambdas/po/web_ui/handler.py index dc2cb72..714c79a 100644 --- a/lambdas/po/web_ui/handler.py +++ b/lambdas/po/web_ui/handler.py @@ -21,10 +21,32 @@ PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders") # the handler must still refuse unauthenticated requests so any future invocation # path (re-attached Function URL, API Gateway, etc.) does not re-expose the whole # PO DB. Callers must present the shared secret in the X-Auth-Token header (or -# Authorization: Bearer ). The secret is supplied via the WEB_UI_AUTH_TOKEN -# env var (wire to Secrets Manager / SSM at deploy time). If it is unset, the -# handler fails closed and denies every request. -WEB_UI_AUTH_TOKEN = os.environ.get("WEB_UI_AUTH_TOKEN") +# Authorization: Bearer ). The secret is fetched at runtime from Secrets +# Manager to keep it out of CloudFormation templates and Lambda env vars. If the +# ARN is unset or the secret is missing, the handler fails closed and denies every +# request. +_WEB_UI_AUTH_TOKEN_SECRET_ARN = os.environ.get("WEB_UI_AUTH_TOKEN_SECRET_ARN") +_auth_token_cache = None + + +def _get_auth_token() -> str | None: + """Fetch the shared web UI auth token from Secrets Manager. + + Cached at module level across warm invocations so we don't hit Secrets + Manager on every request. Returns None when not configured. + """ + global _auth_token_cache + if _auth_token_cache is not None: + return _auth_token_cache + if not _WEB_UI_AUTH_TOKEN_SECRET_ARN: + return None + secrets = boto3.client("secretsmanager") + try: + secret = secrets.get_secret_value(SecretId=_WEB_UI_AUTH_TOKEN_SECRET_ARN) + _auth_token_cache = secret["SecretString"] + return _auth_token_cache + except Exception: + return None def _header(event: dict, name: str) -> str: @@ -40,7 +62,8 @@ def _header(event: dict, name: str) -> str: def is_authenticated(event: dict) -> bool: """Constant-time check of the request's shared secret against the configured token. Fails closed when no token is configured.""" - if not WEB_UI_AUTH_TOKEN: + token = _get_auth_token() + if not token: return False presented = _header(event, "x-auth-token") if not presented: @@ -49,7 +72,7 @@ def is_authenticated(event: dict) -> bool: presented = auth[7:].strip() if not presented: return False - return hmac.compare_digest(presented, WEB_UI_AUTH_TOKEN) + return hmac.compare_digest(presented, token) def get_purchase_orders(limit=500): diff --git a/lambdas/wo/web_ui/handler.py b/lambdas/wo/web_ui/handler.py index 5f80abc..8bc603a 100644 --- a/lambdas/wo/web_ui/handler.py +++ b/lambdas/wo/web_ui/handler.py @@ -21,9 +21,31 @@ COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments") # the handler must still refuse unauthenticated requests so any future invocation # path does not re-expose the whole WO DB. Callers must present the shared secret # in the X-Auth-Token header (or Authorization: Bearer ). The secret is -# supplied via the WEB_UI_AUTH_TOKEN env var (wire to Secrets Manager / SSM at -# deploy time). If it is unset, the handler fails closed and denies every request. -WEB_UI_AUTH_TOKEN = os.environ.get("WEB_UI_AUTH_TOKEN") +# fetched at runtime from Secrets Manager to keep it out of CloudFormation +# templates and Lambda env vars. If the ARN is unset or the secret is missing, the +# handler fails closed and denies every request. +_WEB_UI_AUTH_TOKEN_SECRET_ARN = os.environ.get("WEB_UI_AUTH_TOKEN_SECRET_ARN") +_auth_token_cache = None + + +def _get_auth_token() -> str | None: + """Fetch the shared web UI auth token from Secrets Manager. + + Cached at module level across warm invocations so we don't hit Secrets + Manager on every request. Returns None when not configured. + """ + global _auth_token_cache + if _auth_token_cache is not None: + return _auth_token_cache + if not _WEB_UI_AUTH_TOKEN_SECRET_ARN: + return None + secrets = boto3.client("secretsmanager") + try: + secret = secrets.get_secret_value(SecretId=_WEB_UI_AUTH_TOKEN_SECRET_ARN) + _auth_token_cache = secret["SecretString"] + return _auth_token_cache + except Exception: + return None def _header(event: dict, name: str) -> str: @@ -39,7 +61,8 @@ def _header(event: dict, name: str) -> str: def is_authenticated(event: dict) -> bool: """Constant-time check of the request's shared secret against the configured token. Fails closed when no token is configured.""" - if not WEB_UI_AUTH_TOKEN: + token = _get_auth_token() + if not token: return False presented = _header(event, "x-auth-token") if not presented: @@ -48,7 +71,7 @@ def is_authenticated(event: dict) -> bool: presented = auth[7:].strip() if not presented: return False - return hmac.compare_digest(presented, WEB_UI_AUTH_TOKEN) + return hmac.compare_digest(presented, token) def get_work_orders(limit=500):