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
This commit is contained in:
amoussa1229 2026-07-15 23:21:36 +00:00 • committed by Adam Moussa
parent 11209fe455
commit 5b39f1dad1
No known key found for this signature in database
4 changed files with 91 additions and 36 deletions

View file

@ -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(

View file

@ -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

View file

@ -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 <token>). 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 <token>). 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):

View file

@ -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 <token>). 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):