Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/policy_engine/attachment_registry.py: 63%

154 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Attachment Registry - Manages policy attachments from YAML config. 

3 

4Attachments define WHERE policies apply, separate from the policy definitions. 

5This allows the same policy to be attached to multiple scopes. 

6""" 

7 

8from collections.abc import Callable 

9from datetime import datetime, timezone 

10from types import MappingProxyType 

11from typing import TYPE_CHECKING, Any, Final, TypedDict 

12 

13from litellm._logging import verbose_proxy_logger 

14from litellm.repositories.table_repositories import PolicyAttachmentRepository 

15from litellm.types.proxy.policy_engine import ( 

16 PolicyAttachment, 

17 PolicyAttachmentCreateRequest, 

18 PolicyAttachmentDBResponse, 

19 PolicyMatchContext, 

20) 

21 

22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true

23 from collections.abc import Sequence 

24 

25 from prisma.models import LiteLLM_PolicyAttachmentTable 

26 

27 from litellm.proxy.utils import PrismaClient 

28 

29 

30class PolicyAttachmentMatch(TypedDict): 

31 policy_name: str 

32 matched_via: str 

33 

34 

35def _attachment_specificity(attachment: PolicyAttachment) -> tuple[int, int]: 

36 if attachment.is_global(): 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true

37 return (0, 0) 

38 

39 dims: Final = tuple( 

40 specificity 

41 for values, specificity in ( 

42 (attachment.teams, 1), 

43 (attachment.keys, 2), 

44 (attachment.tags, 3), 

45 (attachment.models, 4), 

46 ) 

47 if values 

48 ) 

49 return (max(dims, default=0), len(dims)) 

50 

51 

52def _attachment_sort_key(attachment: PolicyAttachment) -> tuple[int, int, int, int]: 

53 specificity: Final = _attachment_specificity(attachment) 

54 if attachment.priority is not None: 

55 return (0, attachment.priority, *specificity) 

56 return (1, 0, *specificity) 

57 

58 

59class AttachmentRegistry: 

60 """ 

61 In-memory registry for storing and managing policy attachments. 

62 

63 Attachments define the relationship between policies and their scopes. 

64 A single policy can have multiple attachments (applied to different scopes). 

65 

66 Example YAML: 

67 ```yaml 

68 attachments: 

69 - policy: global-baseline 

70 scope: "*" 

71 - policy: healthcare-compliance 

72 teams: [healthcare-team] 

73 - policy: dev-safety 

74 keys: ["dev-key-*"] 

75 ``` 

76 """ 

77 

78 def __init__(self) -> None: 

79 self._attachments: list[PolicyAttachment] = [] 

80 self._config_attachments: tuple[PolicyAttachment, ...] = () 

81 self._initialized: bool = False 

82 

83 def load_attachments(self, attachments_config: list[dict[str, Any]]) -> None: 

84 """ 

85 Load attachments from a configuration list. 

86 

87 Args: 

88 attachments_config: List of attachment dictionaries from YAML. 

89 """ 

90 self._attachments = [] 

91 

92 for attachment_data in attachments_config: 

93 try: 

94 attachment = self._parse_attachment(attachment_data) 

95 self._attachments.append(attachment) 

96 verbose_proxy_logger.debug("Loaded attachment for policy: %s", attachment.policy) 

97 except Exception as e: 

98 verbose_proxy_logger.error("Error loading attachment: %s", e) 

99 raise ValueError(f"Invalid attachment: {e}") from e 

100 

101 self._config_attachments = tuple(self._attachments) 

102 self._initialized = True 

103 verbose_proxy_logger.info("Loaded %s policy attachments", len(self._attachments)) 

104 

105 def _parse_attachment(self, attachment_data: dict[str, Any]) -> PolicyAttachment: 

106 """ 

107 Parse an attachment from raw configuration data. 

108 

109 Args: 

110 attachment_data: Raw attachment configuration 

111 

112 Returns: 

113 Parsed PolicyAttachment object 

114 """ 

115 return PolicyAttachment( 

116 policy=attachment_data.get("policy", ""), 

117 scope=attachment_data.get("scope"), 

118 teams=attachment_data.get("teams"), 

119 keys=attachment_data.get("keys"), 

120 models=attachment_data.get("models"), 

121 tags=attachment_data.get("tags"), 

122 priority=attachment_data.get("priority"), 

123 default=attachment_data.get("default", False), 

124 ) 

125 

126 def get_attached_policies( 

127 self, 

128 context: PolicyMatchContext, 

129 policy_applies: Callable[[str], bool] | None = None, 

130 ) -> list[str]: 

131 """ 

132 Get list of policy names attached to the given context. 

133 

134 Args: 

135 context: The request context to match against 

136 policy_applies: Optional predicate; attachments whose policy does not apply are ignored 

137 

138 Returns: 

139 List of policy names that are attached to matching scopes 

140 """ 

141 return [r["policy_name"] for r in self.get_attached_policies_with_reasons(context, policy_applies)] 

142 

143 def get_attached_policies_with_reasons( 

144 self, 

145 context: PolicyMatchContext, 

146 policy_applies: Callable[[str], bool] | None = None, 

147 ) -> list[PolicyAttachmentMatch]: 

148 """ 

149 Get list of policy names and match reasons for the given context. 

150 

151 Returns a list of dicts with 'policy_name' and 'matched_via' keys. 

152 The 'matched_via' describes which dimension caused the match. 

153 Attachments whose policy fails `policy_applies` are dropped before defaults are considered. 

154 """ 

155 from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher 

156 

157 in_scope: Final = tuple( 

158 attachment 

159 for attachment in self._attachments 

160 if PolicyMatcher.scope_matches(scope=attachment.to_policy_scope(), context=context) 

161 and (policy_applies is None or policy_applies(attachment.policy)) 

162 ) 

163 non_default: Final = tuple(attachment for attachment in in_scope if not attachment.default) 

164 matching_attachments: Final = sorted( 

165 non_default or tuple(attachment for attachment in in_scope if attachment.default), 

166 key=_attachment_sort_key, 

167 ) 

168 broadest_attachment_by_policy: Final = MappingProxyType( 

169 {attachment.policy: attachment for attachment in reversed(matching_attachments)} 

170 ) 

171 unique_attachments: Final = tuple( 

172 broadest_attachment_by_policy[policy_name] 

173 for policy_name in dict.fromkeys(attachment.policy for attachment in matching_attachments) 

174 ) 

175 

176 return [ 

177 { 

178 "policy_name": attachment.policy, 

179 "matched_via": self._describe_match_reason(attachment, context), 

180 } 

181 for attachment in unique_attachments 

182 ] 

183 

184 @staticmethod 

185 def _describe_match_reason(attachment: PolicyAttachment, context: PolicyMatchContext) -> str: 

186 """Describe why an attachment matched the context.""" 

187 reason: Final = AttachmentRegistry._describe_scope_match(attachment, context) 

188 return f"default:{reason}" if attachment.default else reason 

189 

190 @staticmethod 

191 def _describe_scope_match(attachment: PolicyAttachment, context: PolicyMatchContext) -> str: 

192 from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher 

193 

194 if attachment.is_global(): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 return "scope:*" 

196 

197 reasons: Final = [] 

198 if attachment.tags and context.tags: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 matching_tags: Final = [t for t in context.tags if PolicyMatcher.matches_pattern(t, attachment.tags)] 

200 if matching_tags: 

201 reasons.append(f"tag:{matching_tags[0]}") 

202 if attachment.teams and context.team_alias: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 reasons.append(f"team:{context.team_alias}") 

204 if attachment.keys and context.key_alias: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 reasons.append(f"key:{context.key_alias}") 

206 if attachment.models and context.model: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 reasons.append(f"model:{context.model}") 

208 

209 return "+".join(reasons) if reasons else "scope:default" 

210 

211 def is_policy_attached(self, policy_name: str, context: PolicyMatchContext) -> bool: 

212 """ 

213 Check if a specific policy is attached to the given context. 

214 

215 Args: 

216 policy_name: Name of the policy to check 

217 context: The request context to match against 

218 

219 Returns: 

220 True if the policy is attached to a matching scope 

221 """ 

222 attached: Final = self.get_attached_policies(context) 

223 return policy_name in attached 

224 

225 def get_all_attachments(self) -> list[PolicyAttachment]: 

226 """ 

227 Get all loaded attachments. 

228 

229 Returns: 

230 List of all PolicyAttachment objects 

231 """ 

232 return self._attachments.copy() 

233 

234 def get_config_attachments(self) -> tuple[PolicyAttachment, ...]: 

235 """ 

236 Get the attachments loaded from config.yaml. 

237 

238 Returns: 

239 Tuple of config-defined PolicyAttachment objects 

240 """ 

241 return self._config_attachments 

242 

243 def get_attachments_for_policy(self, policy_name: str) -> list[PolicyAttachment]: 

244 """ 

245 Get all attachments for a specific policy. 

246 

247 Args: 

248 policy_name: Name of the policy 

249 

250 Returns: 

251 List of attachments for the policy 

252 """ 

253 return [a for a in self._attachments if a.policy == policy_name] 

254 

255 def is_initialized(self) -> bool: 

256 """ 

257 Check if the registry has been initialized with attachments. 

258 

259 Returns: 

260 True if attachments have been loaded, False otherwise 

261 """ 

262 return self._initialized 

263 

264 def clear(self) -> None: 

265 """ 

266 Clear all attachments from the registry. 

267 """ 

268 self._attachments = [] 

269 self._config_attachments = () 

270 self._initialized = False 

271 

272 def add_attachment(self, attachment: PolicyAttachment) -> None: 

273 """ 

274 Add a single attachment. 

275 

276 Args: 

277 attachment: PolicyAttachment object to add 

278 """ 

279 self._attachments.append(attachment) 

280 self._initialized = True 

281 verbose_proxy_logger.debug("Added attachment for policy: %s", attachment.policy) 

282 

283 def remove_attachments_for_policy(self, policy_name: str) -> int: 

284 """ 

285 Remove all attachments for a specific policy. 

286 

287 Args: 

288 policy_name: Name of the policy 

289 

290 Returns: 

291 Number of attachments removed 

292 """ 

293 original_count: Final = len(self._attachments) 

294 self._attachments = [a for a in self._attachments if a.policy != policy_name] 

295 removed_count: Final = original_count - len(self._attachments) 

296 if removed_count > 0: 

297 verbose_proxy_logger.debug("Removed %s attachment(s) for policy: %s", removed_count, policy_name) 

298 return removed_count 

299 

300 def remove_attachment_by_id(self, attachment_id: str) -> bool: 

301 """ 

302 Remove an attachment by its ID (for DB-synced attachments). 

303 

304 Args: 

305 attachment_id: The ID of the attachment to remove 

306 

307 Returns: 

308 True if removed, False if not found 

309 """ 

310 # Note: In-memory attachments don't have IDs, so this is primarily 

311 # for consistency after DB operations 

312 return False 

313 

314 # ───────────────────────────────────────────────────────────────────────── 

315 # Database CRUD Methods 

316 # ───────────────────────────────────────────────────────────────────────── 

317 

318 async def add_attachment_to_db( 

319 self, 

320 attachment_request: PolicyAttachmentCreateRequest, 

321 prisma_client: "PrismaClient", 

322 created_by: str | None = None, 

323 ) -> PolicyAttachmentDBResponse: 

324 """ 

325 Add a policy attachment to the database. 

326 

327 Args: 

328 attachment_request: The attachment creation request 

329 prisma_client: The Prisma client instance 

330 created_by: User who created the attachment 

331 

332 Returns: 

333 PolicyAttachmentDBResponse with the created attachment 

334 """ 

335 try: 

336 created_attachment: Final[LiteLLM_PolicyAttachmentTable] = await PolicyAttachmentRepository( 

337 prisma_client 

338 ).table.create( 

339 data={ 

340 "policy_name": attachment_request.policy_name, 

341 "scope": attachment_request.scope, 

342 "teams": attachment_request.teams or [], 

343 "keys": attachment_request.keys or [], 

344 "models": attachment_request.models or [], 

345 "tags": attachment_request.tags or [], 

346 "priority": attachment_request.priority, 

347 "is_default": attachment_request.default, 

348 "created_at": datetime.now(timezone.utc), 

349 "updated_at": datetime.now(timezone.utc), 

350 "created_by": created_by, 

351 "updated_by": created_by, 

352 } 

353 ) 

354 

355 # Also add to in-memory registry 

356 attachment: Final = PolicyAttachment( 

357 policy=attachment_request.policy_name, 

358 scope=attachment_request.scope, 

359 teams=attachment_request.teams, 

360 keys=attachment_request.keys, 

361 models=attachment_request.models, 

362 tags=attachment_request.tags, 

363 priority=attachment_request.priority, 

364 default=attachment_request.default, 

365 ) 

366 self.add_attachment(attachment) 

367 

368 return PolicyAttachmentDBResponse( 

369 attachment_id=created_attachment.attachment_id, 

370 policy_name=created_attachment.policy_name, 

371 scope=created_attachment.scope, 

372 teams=created_attachment.teams or [], 

373 keys=created_attachment.keys or [], 

374 models=created_attachment.models or [], 

375 tags=created_attachment.tags or [], 

376 priority=created_attachment.priority, 

377 default=created_attachment.is_default, 

378 created_at=created_attachment.created_at, 

379 updated_at=created_attachment.updated_at, 

380 created_by=created_attachment.created_by, 

381 updated_by=created_attachment.updated_by, 

382 ) 

383 except Exception as e: 

384 verbose_proxy_logger.exception("Error adding attachment to DB: %s", e) 

385 raise Exception(f"Error adding attachment to DB: {e}") 

386 

387 async def delete_attachment_from_db( 

388 self, 

389 attachment_id: str, 

390 prisma_client: "PrismaClient", 

391 ) -> dict[str, str]: 

392 """ 

393 Delete a policy attachment from the database. 

394 

395 Args: 

396 attachment_id: The ID of the attachment to delete 

397 prisma_client: The Prisma client instance 

398 

399 Returns: 

400 Dict with success message 

401 """ 

402 try: 

403 # Get attachment before deleting 

404 attachment: Final[LiteLLM_PolicyAttachmentTable | None] = await PolicyAttachmentRepository( 

405 prisma_client 

406 ).table.find_unique(where={"attachment_id": attachment_id}) 

407 

408 if attachment is None: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true

409 raise Exception(f"Attachment with ID {attachment_id} not found") 

410 

411 # Delete from DB 

412 await PolicyAttachmentRepository(prisma_client).table.delete(where={"attachment_id": attachment_id}) 

413 

414 # Note: In-memory attachments don't have IDs, so we need to sync from DB 

415 # to properly update in-memory state 

416 await self.sync_attachments_from_db(prisma_client) 

417 

418 return {"message": f"Attachment {attachment_id} deleted successfully"} 

419 except Exception as e: 

420 verbose_proxy_logger.exception("Error deleting attachment from DB: %s", e) 

421 raise Exception(f"Error deleting attachment from DB: {e}") 

422 

423 async def get_attachment_by_id_from_db( 

424 self, 

425 attachment_id: str, 

426 prisma_client: "PrismaClient", 

427 ) -> PolicyAttachmentDBResponse | None: 

428 """ 

429 Get a policy attachment by ID from the database. 

430 

431 Args: 

432 attachment_id: The ID of the attachment to retrieve 

433 prisma_client: The Prisma client instance 

434 

435 Returns: 

436 PolicyAttachmentDBResponse if found, None otherwise 

437 """ 

438 try: 

439 attachment: Final[LiteLLM_PolicyAttachmentTable | None] = await PolicyAttachmentRepository( 

440 prisma_client 

441 ).table.find_unique(where={"attachment_id": attachment_id}) 

442 

443 if attachment is None: 

444 return None 

445 

446 return PolicyAttachmentDBResponse( 

447 attachment_id=attachment.attachment_id, 

448 policy_name=attachment.policy_name, 

449 scope=attachment.scope, 

450 teams=attachment.teams or [], 

451 keys=attachment.keys or [], 

452 models=attachment.models or [], 

453 tags=attachment.tags or [], 

454 priority=attachment.priority, 

455 default=attachment.is_default, 

456 created_at=attachment.created_at, 

457 updated_at=attachment.updated_at, 

458 created_by=attachment.created_by, 

459 updated_by=attachment.updated_by, 

460 ) 

461 except Exception as e: 

462 verbose_proxy_logger.exception("Error getting attachment from DB: %s", e) 

463 raise Exception(f"Error getting attachment from DB: {e}") 

464 

465 async def get_all_attachments_from_db( 

466 self, 

467 prisma_client: "PrismaClient", 

468 ) -> list[PolicyAttachmentDBResponse]: 

469 """ 

470 Get all policy attachments from the database. 

471 

472 Args: 

473 prisma_client: The Prisma client instance 

474 

475 Returns: 

476 List of PolicyAttachmentDBResponse objects 

477 """ 

478 try: 

479 attachments: Final[Sequence[LiteLLM_PolicyAttachmentTable]] = await PolicyAttachmentRepository( 

480 prisma_client 

481 ).table.find_many( 

482 order={"created_at": "desc"}, 

483 ) 

484 

485 return [ 

486 PolicyAttachmentDBResponse( 

487 attachment_id=a.attachment_id, 

488 policy_name=a.policy_name, 

489 scope=a.scope, 

490 teams=a.teams or [], 

491 keys=a.keys or [], 

492 models=a.models or [], 

493 tags=a.tags or [], 

494 priority=a.priority, 

495 default=a.is_default, 

496 created_at=a.created_at, 

497 updated_at=a.updated_at, 

498 created_by=a.created_by, 

499 updated_by=a.updated_by, 

500 ) 

501 for a in attachments 

502 ] 

503 except Exception as e: 

504 verbose_proxy_logger.exception("Error getting attachments from DB: %s", e) 

505 raise Exception(f"Error getting attachments from DB: {e}") 

506 

507 async def sync_attachments_from_db( 

508 self, 

509 prisma_client: "PrismaClient", 

510 ) -> None: 

511 """ 

512 Sync policy attachments from the database to in-memory registry. 

513 Config-loaded attachments are preserved. 

514 

515 Args: 

516 prisma_client: The Prisma client instance 

517 """ 

518 try: 

519 attachments: Final = await self.get_all_attachments_from_db(prisma_client) 

520 

521 db_attachments: Final = [ 

522 PolicyAttachment( 

523 policy=attachment_response.policy_name, 

524 scope=attachment_response.scope, 

525 teams=(attachment_response.teams if attachment_response.teams else None), 

526 keys=attachment_response.keys if attachment_response.keys else None, 

527 models=(attachment_response.models if attachment_response.models else None), 

528 tags=attachment_response.tags if attachment_response.tags else None, 

529 priority=attachment_response.priority, 

530 default=attachment_response.default, 

531 ) 

532 for attachment_response in attachments 

533 ] 

534 self._attachments = [*self._config_attachments, *db_attachments] 

535 

536 self._initialized = True 

537 verbose_proxy_logger.info( 

538 "Synced %s attachments from DB to in-memory registry (%s config-defined attachments preserved)", 

539 len(attachments), 

540 len(self._config_attachments), 

541 ) 

542 except Exception as e: 

543 verbose_proxy_logger.exception("Error syncing attachments from DB: %s", e) 

544 raise Exception(f"Error syncing attachments from DB: {e}") 

545 

546 

547# Global singleton instance 

548_attachment_registry: AttachmentRegistry | None = None 

549 

550 

551def get_attachment_registry() -> AttachmentRegistry: 

552 """ 

553 Get the global AttachmentRegistry singleton. 

554 

555 Returns: 

556 The global AttachmentRegistry instance 

557 """ 

558 global _attachment_registry 

559 if _attachment_registry is None: 

560 _attachment_registry = AttachmentRegistry() 

561 return _attachment_registry