Coverage for lambda / analytics-presigned-url / handler.py: 100.00%

130 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Presigned-URL Lambda for SageMaker Studio (analytics environment). 

2 

3Exchanges a Cognito-authorized API Gateway event for a time-limited 

4``sagemaker:CreatePresignedDomainUrl`` link. 

5 

6Flow (happy path): 

7 

81. Extract ``claims = event["requestContext"]["authorizer"]["claims"]`` and 

9 read ``cognito:username`` (falling back to ``username`` if the token 

10 shape doesn't namespace Cognito claims). 

112. Resolve the Studio ``DomainId`` from the ``STUDIO_DOMAIN_ID`` env var 

12 (preferred) or by calling ``sagemaker:ListDomains`` as a fallback. 

133. ``sagemaker:DescribeUserProfile`` -- if the profile doesn't exist yet 

14 (``ValidationException`` / ``ResourceNotFound``), create it via 

15 ``sagemaker:CreateUserProfile``. If the profile is still provisioning 

16 (``Pending`` / ``Updating``), return HTTP 202 so the CLI can poll. 

17 If the profile is ``Failed``, delete it and recreate. 

184. Once the profile is ``InService``, ensure the per-user EFS access 

19 point exists (lazy creation). 

205. ``sagemaker:CreatePresignedDomainUrl`` and return the URL (HTTP 200). 

21 

22The Lambda never blocks waiting for profile provisioning. Instead it 

23returns HTTP 202 ``{"status": "provisioning"}`` and the CLI retries 

24every few seconds until it receives HTTP 200 with the presigned URL. 

25This avoids hitting the API Gateway 29-second integration timeout. 

26 

27All failures funnel through the outer ``try/except`` in 

28:func:`lambda_handler`; the response body is always JSON and never 

29leaks an exception string. 

30 

31Environment variables (set by ``GCOAnalyticsStack._create_presigned_url_lambda``): 

32 

33- ``STUDIO_DOMAIN_ID`` -- the Studio domain ID (e.g. ``d-abc123xyz``). 

34- ``SAGEMAKER_EXECUTION_ROLE_ARN`` -- passed on ``CreateUserProfile``. 

35- ``STUDIO_EFS_ID`` -- used by ``_ensure_access_point``. 

36- ``URL_EXPIRES_SECONDS`` -- default ``300`` (5 minutes). 

37- ``SESSION_EXPIRES_SECONDS`` -- default ``43200`` (12 hours). 

38 

39The module-level boto3 clients (``sagemaker`` and ``efs``) are created 

40once at cold start so repeat invocations inside a warm container reuse 

41the same HTTP connection pools. 

42""" 

43 

44from __future__ import annotations 

45 

46import hashlib 

47import json 

48import logging 

49import os 

50from typing import Any 

51 

52import boto3 

53from botocore.exceptions import ClientError 

54 

55# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

56# Generated at (UTC): 2026-09-01T14:42:56Z 

57# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

58# Flowchart(s) generated from this file: 

59# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/analytics-presigned-url/handler.lambda_handler.html`` 

60# (PNG: ``diagrams/code_diagrams/lambda/analytics-presigned-url/handler.lambda_handler.png``) 

61# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

62# <pyflowchart-code-diagram> END 

63 

64 

65# --------------------------------------------------------------------------- 

66# Module-level logger + boto3 clients 

67# --------------------------------------------------------------------------- 

68# Created once per cold start. boto3 clients are thread-safe for the 

69# method calls this Lambda makes (list/describe/create presigned URL). 

70 

71logger = logging.getLogger(__name__) 

72logger.setLevel(logging.INFO) 

73 

74sagemaker = boto3.client("sagemaker") 

75efs = boto3.client("efs") 

76 

77# --------------------------------------------------------------------------- 

78# Environment variables (read at module import, i.e. cold start) 

79# --------------------------------------------------------------------------- 

80 

81STUDIO_DOMAIN_ID = os.environ.get("STUDIO_DOMAIN_ID", "") 

82SAGEMAKER_EXECUTION_ROLE_ARN = os.environ.get("SAGEMAKER_EXECUTION_ROLE_ARN", "") 

83STUDIO_EFS_ID = os.environ.get("STUDIO_EFS_ID", "") 

84URL_EXPIRES_SECONDS = int(os.environ.get("URL_EXPIRES_SECONDS", "300")) 

85SESSION_EXPIRES_SECONDS = int(os.environ.get("SESSION_EXPIRES_SECONDS", "43200")) 

86 

87# --------------------------------------------------------------------------- 

88# Error tokens -- opaque strings returned in the ``error`` body key. 

89# --------------------------------------------------------------------------- 

90# Keep these short, stable, and free of implementation details so clients 

91# can switch on them without parsing exception messages. 

92 

93_ERR_MISSING_CLAIM = "MissingCognitoClaim" 

94_ERR_DOMAIN_NOT_FOUND = "SagemakerDomainNotFound" 

95_ERR_GENERIC = "PresignedUrlGenerationFailed" 

96 

97# POSIX id derivation constants. 2**31 - 19 keeps the result comfortably 

98# within the 32-bit signed-int range EFS accepts; the 100000 offset pushes 

99# the uid/gid out of the system-user range reserved for the base image. 

100_POSIX_ID_MODULUS = 2147483629 

101_POSIX_ID_OFFSET = 100000 

102 

103# ========================================================================== 

104# Pure helpers (unit-testable without mocking) 

105# ========================================================================== 

106 

107 

108def _parse_claims(event: dict[str, Any]) -> dict[str, Any]: 

109 """Extract the Cognito claims dict from an API Gateway proxy event. 

110 

111 Returns an empty dict if ``event["requestContext"]["authorizer"]["claims"]`` 

112 is not present or not a dict. The caller decides whether an empty 

113 result warrants a 401 -- see :func:`lambda_handler`. 

114 """ 

115 if not isinstance(event, dict): 

116 return {} 

117 request_context = event.get("requestContext") 

118 if not isinstance(request_context, dict): 

119 return {} 

120 authorizer = request_context.get("authorizer") 

121 if not isinstance(authorizer, dict): 

122 return {} 

123 claims = authorizer.get("claims") 

124 if not isinstance(claims, dict): 

125 return {} 

126 return claims 

127 

128 

129def _derive_posix_ids(username: str) -> tuple[int, int]: 

130 """Derive a deterministic POSIX ``(uid, gid)`` pair from a username. 

131 

132 Uses SHA-256 over the UTF-8 encoded username; the first four bytes 

133 are interpreted as a big-endian unsigned int, reduced modulo 

134 ``_POSIX_ID_MODULUS``, and shifted by ``_POSIX_ID_OFFSET`` so the 

135 result is always ``>= 100000`` and fits within the 32-bit signed 

136 range EFS expects. 

137 

138 The gid is always equal to the uid -- per-user home directories own 

139 a single-user group, matching the ``0700`` permissions on 

140 ``/home/<username>`` access points. 

141 """ 

142 digest = hashlib.sha256(username.encode("utf-8")).digest() 

143 raw = int.from_bytes(digest[:4], byteorder="big", signed=False) 

144 uid = (raw % _POSIX_ID_MODULUS) + _POSIX_ID_OFFSET 

145 return uid, uid 

146 

147 

148def _format_success(url: str, expires: int) -> dict[str, Any]: 

149 """Format an HTTP 200 API Gateway proxy response with the presigned URL.""" 

150 return { 

151 "statusCode": 200, 

152 "headers": {"Content-Type": "application/json"}, 

153 "body": json.dumps({"url": url, "expires_in": expires}), 

154 } 

155 

156 

157def _format_provisioning() -> dict[str, Any]: 

158 """Format an HTTP 202 response indicating the profile is still provisioning. 

159 

160 The CLI polls on this status code until the profile reaches InService 

161 and the Lambda returns HTTP 200 with the presigned URL. 

162 """ 

163 return { 

164 "statusCode": 202, 

165 "headers": {"Content-Type": "application/json"}, 

166 "body": json.dumps({"status": "provisioning"}), 

167 } 

168 

169 

170def _format_error(status: int, token: str) -> dict[str, Any]: 

171 """Format an HTTP error API Gateway proxy response. 

172 

173 ``token`` is one of the module-level ``_ERR_*`` constants; ``status`` 

174 is the HTTP status code. The body never contains an exception 

175 message -- callers log the underlying exception via :data:`logger` 

176 before returning. 

177 """ 

178 return { 

179 "statusCode": status, 

180 "headers": {"Content-Type": "application/json"}, 

181 "body": json.dumps({"error": token}), 

182 } 

183 

184 

185# ========================================================================== 

186# Effectful helpers (wrap boto3 calls) 

187# ========================================================================== 

188 

189 

190def _resolve_domain_id(domain_name: str) -> str | None: 

191 """Return the Studio ``DomainId`` for ``domain_name`` or ``None``. 

192 

193 Paginates ``sagemaker:ListDomains`` by following ``NextToken``. A 

194 ``None`` return signals the caller to emit HTTP 404 ``SagemakerDomainNotFound``. 

195 """ 

196 next_token: str | None = None 

197 while True: 

198 kwargs: dict[str, Any] = {} 

199 if next_token is not None: 

200 kwargs["NextToken"] = next_token 

201 response = sagemaker.list_domains(**kwargs) 

202 for domain in response.get("Domains", []): 

203 if domain.get("DomainName") == domain_name: 

204 domain_id = domain.get("DomainId") 

205 return str(domain_id) if domain_id is not None else None 

206 next_token = response.get("NextToken") 

207 if not next_token: 

208 return None 

209 

210 

211def _ensure_user_profile(domain_id: str, username: str, efs_id: str) -> str: 

212 """Ensure a ``sagemaker:UserProfile`` exists for ``username``. 

213 

214 Returns the profile status: 

215 

216 * ``"InService"`` -- profile is ready; caller can mint a presigned URL. 

217 * ``"Provisioning"`` -- profile was just created or is still starting; 

218 caller should return HTTP 202 so the CLI can poll. 

219 

220 If the profile is in ``Failed`` state, it is deleted and recreated 

221 so the next poll attempt finds a fresh ``Pending`` profile. 

222 """ 

223 try: 

224 resp = sagemaker.describe_user_profile( 

225 DomainId=domain_id, 

226 UserProfileName=username, 

227 ) 

228 status = resp.get("Status", "") 

229 

230 if status == "InService": 

231 return "InService" 

232 

233 if status == "Failed": 

234 logger.warning( 

235 "User profile %s is Failed (%s) -- deleting and recreating", 

236 username, 

237 resp.get("FailureReason", "unknown"), 

238 ) 

239 try: 

240 sagemaker.delete_user_profile( 

241 DomainId=domain_id, 

242 UserProfileName=username, 

243 ) 

244 except ClientError as del_exc: 

245 logger.warning("Could not delete failed profile %s: %s", username, del_exc) 

246 # Create a fresh profile (may race with the delete; ResourceInUse 

247 # is caught below). 

248 _create_user_profile(domain_id, username, efs_id) 

249 return "Provisioning" 

250 

251 # Pending / Updating / any other transient state. 

252 logger.info("User profile %s status=%s, still provisioning", username, status) 

253 return "Provisioning" 

254 

255 except ClientError as exc: 

256 code = exc.response.get("Error", {}).get("Code", "") 

257 if code not in {"ValidationException", "ResourceNotFound"}: 

258 raise 

259 

260 # Profile doesn't exist -- create it. 

261 _create_user_profile(domain_id, username, efs_id) 

262 return "Provisioning" 

263 

264 

265def _create_user_profile(domain_id: str, username: str, efs_id: str) -> None: 

266 """Create a SageMaker user profile. Silently ignores ResourceInUse.""" 

267 try: 

268 sagemaker.create_user_profile( 

269 DomainId=domain_id, 

270 UserProfileName=username, 

271 UserSettings={ 

272 "ExecutionRole": SAGEMAKER_EXECUTION_ROLE_ARN, 

273 "CustomFileSystemConfigs": [ 

274 { 

275 "EFSFileSystemConfig": { 

276 "FileSystemId": efs_id, 

277 "FileSystemPath": f"/home/{username}", 

278 } 

279 } 

280 ], 

281 }, 

282 ) 

283 logger.info("Created user profile %s in domain %s", username, domain_id) 

284 except ClientError as exc: 

285 code = exc.response.get("Error", {}).get("Code", "") 

286 if code != "ResourceInUse": 

287 raise 

288 logger.info("User profile %s already exists (ResourceInUse)", username) 

289 

290 

291def _ensure_access_point(username: str, efs_id: str) -> str: 

292 """Ensure a per-user EFS access point at ``/home/<username>`` exists. 

293 

294 Searches existing access points on ``efs_id`` for one whose 

295 ``RootDirectory.Path`` equals ``/home/<username>``; creates one if 

296 absent with a POSIX ``(uid, gid)`` derived from 

297 :func:`_derive_posix_ids` and ``0700`` permissions. 

298 

299 Returns the ``AccessPointArn`` so the caller can associate it with 

300 the user profile if needed. 

301 """ 

302 uid, gid = _derive_posix_ids(username) 

303 target_path = f"/home/{username}" 

304 

305 # Paginate describe_access_points. EFS returns at most 100 APs per 

306 # page by default; we explicitly cap at 1000 to stay inside a single 

307 # request for typical deployments. 

308 next_token: str | None = None 

309 while True: 

310 kwargs: dict[str, Any] = {"FileSystemId": efs_id, "MaxResults": 1000} 

311 if next_token is not None: 

312 kwargs["NextToken"] = next_token 

313 response = efs.describe_access_points(**kwargs) 

314 for ap in response.get("AccessPoints", []): 

315 root_dir = ap.get("RootDirectory", {}) 

316 if root_dir.get("Path") == target_path: 

317 return str(ap.get("AccessPointArn", "")) 

318 next_token = response.get("NextToken") 

319 if not next_token: 

320 break 

321 

322 created = efs.create_access_point( 

323 FileSystemId=efs_id, 

324 PosixUser={"Uid": uid, "Gid": gid}, 

325 RootDirectory={ 

326 "Path": target_path, 

327 "CreationInfo": { 

328 "OwnerUid": uid, 

329 "OwnerGid": gid, 

330 "Permissions": "0700", 

331 }, 

332 }, 

333 Tags=[ 

334 {"Key": "gco:analytics:user", "Value": username}, 

335 {"Key": "gco:analytics:managed", "Value": "true"}, 

336 ], 

337 ) 

338 return str(created.get("AccessPointArn", "")) 

339 

340 

341# ========================================================================== 

342# Entry point 

343# ========================================================================== 

344 

345 

346def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: 

347 """Exchange a Cognito-authorized event for a presigned Studio URL. 

348 

349 Returns: 

350 

351 * **HTTP 200** with ``{"url": "...", "expires_in": N}`` when the 

352 user profile is ``InService`` and the presigned URL is ready. 

353 * **HTTP 202** with ``{"status": "provisioning"}`` when the user 

354 profile is still being created. The CLI should retry after a 

355 few seconds. 

356 * **HTTP 4xx / 5xx** with ``{"error": "<token>"}`` on failure. 

357 

358 The Lambda never blocks waiting for profile provisioning. The CLI 

359 is responsible for polling until it receives HTTP 200. 

360 """ 

361 try: 

362 # Step 1: extract + validate the Cognito username claim. 

363 claims = _parse_claims(event) 

364 username = claims.get("cognito:username") or claims.get("username") 

365 if not isinstance(username, str) or not username: 

366 logger.warning("Request missing Cognito username claim") 

367 return _format_error(401, _ERR_MISSING_CLAIM) 

368 

369 # Step 2: resolve the Studio DomainId. 

370 domain_id = STUDIO_DOMAIN_ID if STUDIO_DOMAIN_ID else _resolve_domain_id("") 

371 if not domain_id: 

372 logger.error( 

373 "SageMaker domain not found (STUDIO_DOMAIN_ID=%r)", 

374 STUDIO_DOMAIN_ID, 

375 ) 

376 return _format_error(404, _ERR_DOMAIN_NOT_FOUND) 

377 

378 # Step 3: describe-or-create the user profile. 

379 profile_status = _ensure_user_profile(domain_id, username, STUDIO_EFS_ID) 

380 if profile_status != "InService": 

381 return _format_provisioning() 

382 

383 # Step 4: lazy per-user EFS access point. 

384 _ensure_access_point(username, STUDIO_EFS_ID) 

385 

386 # Step 5: mint the presigned URL. 

387 response = sagemaker.create_presigned_domain_url( 

388 DomainId=domain_id, 

389 UserProfileName=username, 

390 SessionExpirationDurationInSeconds=SESSION_EXPIRES_SECONDS, 

391 ExpiresInSeconds=URL_EXPIRES_SECONDS, 

392 ) 

393 url = response.get("AuthorizedUrl", "") 

394 return _format_success(url, URL_EXPIRES_SECONDS) 

395 

396 except Exception as exc: # noqa: BLE001 -- outer catch-all so every failure returns an opaque error token 

397 # Log with exception info so CloudWatch captures the stack trace, 

398 # but never leak the message to the HTTP response body. 

399 logger.error("Presigned URL generation failed: %s", exc, exc_info=True) 

400 return _format_error(500, _ERR_GENERIC)