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

130 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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-18T02:11:36Z 

57# Generated from Git commit: b8faa9689385cea16155a285a7f70cf6d488e512 

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: object) -> 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 ``event`` is typed ``object`` because every layer of the path is 

116 shape-checked here, the event itself included; the helper makes no 

117 assumption about what API Gateway (or a test) hands it. 

118 """ 

119 if not isinstance(event, dict): 

120 return {} 

121 request_context = event.get("requestContext") 

122 if not isinstance(request_context, dict): 

123 return {} 

124 authorizer = request_context.get("authorizer") 

125 if not isinstance(authorizer, dict): 

126 return {} 

127 claims = authorizer.get("claims") 

128 if not isinstance(claims, dict): 

129 return {} 

130 return claims 

131 

132 

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

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

135 

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

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

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

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

140 range EFS expects. 

141 

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

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

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

145 """ 

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

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

148 uid = (raw % _POSIX_ID_MODULUS) + _POSIX_ID_OFFSET 

149 return uid, uid 

150 

151 

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

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

154 return { 

155 "statusCode": 200, 

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

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

158 } 

159 

160 

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

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

163 

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

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

166 """ 

167 return { 

168 "statusCode": 202, 

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

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

171 } 

172 

173 

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

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

176 

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

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

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

180 before returning. 

181 """ 

182 return { 

183 "statusCode": status, 

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

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

186 } 

187 

188 

189# ========================================================================== 

190# Effectful helpers (wrap boto3 calls) 

191# ========================================================================== 

192 

193 

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

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

196 

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

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

199 """ 

200 next_token: str | None = None 

201 while True: 

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

203 if next_token is not None: 

204 kwargs["NextToken"] = next_token 

205 response = sagemaker.list_domains(**kwargs) 

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

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

208 domain_id = domain.get("DomainId") 

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

210 next_token = response.get("NextToken") 

211 if not next_token: 

212 return None 

213 

214 

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

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

217 

218 Returns the profile status: 

219 

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

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

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

223 

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

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

226 """ 

227 try: 

228 resp = sagemaker.describe_user_profile( 

229 DomainId=domain_id, 

230 UserProfileName=username, 

231 ) 

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

233 

234 if status == "InService": 

235 return "InService" 

236 

237 if status == "Failed": 

238 logger.warning( 

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

240 username, 

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

242 ) 

243 try: 

244 sagemaker.delete_user_profile( 

245 DomainId=domain_id, 

246 UserProfileName=username, 

247 ) 

248 except ClientError as del_exc: 

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

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

251 # is caught below). 

252 _create_user_profile(domain_id, username, efs_id) 

253 return "Provisioning" 

254 

255 # Pending / Updating / any other transient state. 

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

257 return "Provisioning" 

258 

259 except ClientError as exc: 

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

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

262 raise 

263 

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

265 _create_user_profile(domain_id, username, efs_id) 

266 return "Provisioning" 

267 

268 

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

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

271 try: 

272 sagemaker.create_user_profile( 

273 DomainId=domain_id, 

274 UserProfileName=username, 

275 UserSettings={ 

276 "ExecutionRole": SAGEMAKER_EXECUTION_ROLE_ARN, 

277 "CustomFileSystemConfigs": [ 

278 { 

279 "EFSFileSystemConfig": { 

280 "FileSystemId": efs_id, 

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

282 } 

283 } 

284 ], 

285 }, 

286 ) 

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

288 except ClientError as exc: 

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

290 if code != "ResourceInUse": 

291 raise 

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

293 

294 

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

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

297 

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

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

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

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

302 

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

304 the user profile if needed. 

305 """ 

306 uid, gid = _derive_posix_ids(username) 

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

308 

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

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

311 # request for typical deployments. 

312 next_token: str | None = None 

313 while True: 

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

315 if next_token is not None: 

316 kwargs["NextToken"] = next_token 

317 response = efs.describe_access_points(**kwargs) 

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

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

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

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

322 next_token = response.get("NextToken") 

323 if not next_token: 

324 break 

325 

326 created = efs.create_access_point( 

327 FileSystemId=efs_id, 

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

329 RootDirectory={ 

330 "Path": target_path, 

331 "CreationInfo": { 

332 "OwnerUid": uid, 

333 "OwnerGid": gid, 

334 "Permissions": "0700", 

335 }, 

336 }, 

337 Tags=[ 

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

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

340 ], 

341 ) 

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

343 

344 

345# ========================================================================== 

346# Entry point 

347# ========================================================================== 

348 

349 

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

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

352 

353 Returns: 

354 

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

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

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

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

359 few seconds. 

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

361 

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

363 is responsible for polling until it receives HTTP 200. 

364 """ 

365 try: 

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

367 claims = _parse_claims(event) 

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

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

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

371 return _format_error(401, _ERR_MISSING_CLAIM) 

372 

373 # Step 2: resolve the Studio DomainId. 

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

375 if not domain_id: 

376 logger.error( 

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

378 STUDIO_DOMAIN_ID, 

379 ) 

380 return _format_error(404, _ERR_DOMAIN_NOT_FOUND) 

381 

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

383 profile_status = _ensure_user_profile(domain_id, username, STUDIO_EFS_ID) 

384 if profile_status != "InService": 

385 return _format_provisioning() 

386 

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

388 _ensure_access_point(username, STUDIO_EFS_ID) 

389 

390 # Step 5: mint the presigned URL. 

391 response = sagemaker.create_presigned_domain_url( 

392 DomainId=domain_id, 

393 UserProfileName=username, 

394 SessionExpirationDurationInSeconds=SESSION_EXPIRES_SECONDS, 

395 ExpiresInSeconds=URL_EXPIRES_SECONDS, 

396 ) 

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

398 return _format_success(url, URL_EXPIRES_SECONDS) 

399 

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

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

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

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

404 return _format_error(500, _ERR_GENERIC)