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):