mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-20 16:19:55 +00:00
[Feat] Policies - Allow connecting Policies to Tags, Simulating Policies, Viewing how many keys, teams it applies on (#20904)
* init schema with TAGS * ui: add policy test * resolvePoliciesCall * add_policy_sources_to_metadata + headers * types Policy * preview Impact * def _describe_match_reason( * match based on TAGs * TestTagBasedAttachments * test fixes * add policy_resolve_router * add_guardrails_from_policy_engine * TestMatchAttribution * refactor * fix * fix: address Greptile review feedback on policy resolve endpoints - Track unnamed keys/teams as separate counts instead of inflating affected_keys_count with duplicate "(unnamed key)" placeholders. Added unnamed_keys_count and unnamed_teams_count to response. - Push alias pattern matching to DB via _build_alias_where() which converts exact patterns to Prisma "in" and suffix wildcards to "startsWith" filters. - Gate sync_policies_from_db/sync_attachments_from_db behind force_sync query param (default false) to avoid 2 DB round-trips on every /policies/resolve request. - Remove worktree-only conftest.py that cleared sys.modules at import time — no longer needed since code moved to main repo. - Rename MAX_ESTIMATE_IMPACT_ROWS → MAX_POLICY_ESTIMATE_IMPACT_ROWS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: eliminate duplicate DB queries and fix header delimiter ambiguity - Fetch teams table once in estimate_attachment_impact and reuse for both tag-based and alias-based lookups (was querying teams twice when both tag_patterns and team_patterns were provided). - Convert tag/team filter functions from async DB queries to sync filters that operate on pre-fetched data (_filter_keys_by_tags, _filter_teams_by_tags). - Fix comma ambiguity in x-litellm-policy-sources header: use '; ' as entry delimiter since matched_via values can contain commas. - Use '+' as the within-value separator in matched_via reason strings (e.g. "tag:healthcare+team:health-team") to avoid conflict with header delimiters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update litellm/proxy/policy_engine/policy_resolve_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
parent
b7993b14cf
commit
f83620157e
@@ -914,6 +914,7 @@ model LiteLLM_PolicyAttachmentTable {
|
||||
teams String[] @default([]) // Team aliases or patterns
|
||||
keys String[] @default([]) // Key aliases or patterns
|
||||
models String[] @default([]) // Model names or patterns
|
||||
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
|
||||
created_at DateTime @default(now())
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -1310,6 +1310,9 @@ DEFAULT_SLACK_ALERTING_THRESHOLD = int(
|
||||
os.getenv("DEFAULT_SLACK_ALERTING_THRESHOLD", 300)
|
||||
)
|
||||
MAX_TEAM_LIST_LIMIT = int(os.getenv("MAX_TEAM_LIST_LIMIT", 20))
|
||||
MAX_POLICY_ESTIMATE_IMPACT_ROWS = int(
|
||||
os.getenv("MAX_POLICY_ESTIMATE_IMPACT_ROWS", 1000)
|
||||
)
|
||||
DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD = float(
|
||||
os.getenv("DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD", 0.7)
|
||||
)
|
||||
|
||||
@@ -394,6 +394,14 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
|
||||
_metadata["applied_policies"]
|
||||
)
|
||||
|
||||
if "policy_sources" in _metadata:
|
||||
sources = _metadata["policy_sources"]
|
||||
if isinstance(sources, dict) and sources:
|
||||
# Use ';' as delimiter — matched_via reasons may contain commas
|
||||
headers["x-litellm-policy-sources"] = "; ".join(
|
||||
f"{name}={reason}" for name, reason in sources.items()
|
||||
)
|
||||
|
||||
if "semantic-similarity" in _metadata:
|
||||
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])
|
||||
|
||||
@@ -441,6 +449,27 @@ def add_policy_to_applied_policies_header(
|
||||
request_data["metadata"] = _metadata
|
||||
|
||||
|
||||
def add_policy_sources_to_metadata(
|
||||
request_data: Dict, policy_sources: Dict[str, str]
|
||||
):
|
||||
"""
|
||||
Store policy match reasons in metadata for x-litellm-policy-sources header.
|
||||
|
||||
Args:
|
||||
request_data: The request data dict
|
||||
policy_sources: Map of policy_name -> matched_via reason
|
||||
"""
|
||||
if not policy_sources:
|
||||
return
|
||||
_metadata = request_data.get("metadata", None) or {}
|
||||
existing = _metadata.get("policy_sources", {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing.update(policy_sources)
|
||||
_metadata["policy_sources"] = existing
|
||||
request_data["metadata"] = _metadata
|
||||
|
||||
|
||||
def add_guardrail_response_to_standard_logging_object(
|
||||
litellm_logging_obj: Optional["LiteLLMLogging"],
|
||||
guardrail_response: StandardLoggingGuardrailInformation,
|
||||
|
||||
@@ -1539,8 +1539,15 @@ def add_guardrails_from_policy_engine(
|
||||
"""
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
add_policy_sources_to_metadata,
|
||||
add_policy_to_applied_policies_header,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
get_tags_from_request_body,
|
||||
)
|
||||
from litellm.proxy.policy_engine.attachment_registry import (
|
||||
get_attachment_registry,
|
||||
)
|
||||
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
|
||||
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
|
||||
from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
|
||||
@@ -1561,20 +1568,31 @@ def add_guardrails_from_policy_engine(
|
||||
)
|
||||
return
|
||||
|
||||
# Build context from request
|
||||
# Extract tags using the shared helper (handles metadata / litellm_metadata,
|
||||
# top-level tags, deduplication, and type filtering).
|
||||
|
||||
all_tags = get_tags_from_request_body(data) or None
|
||||
|
||||
context = PolicyMatchContext(
|
||||
team_alias=user_api_key_dict.team_alias,
|
||||
key_alias=user_api_key_dict.key_alias,
|
||||
model=data.get("model"),
|
||||
tags=all_tags,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Policy engine: matching policies for context team_alias={context.team_alias}, "
|
||||
f"key_alias={context.key_alias}, model={context.model}"
|
||||
f"key_alias={context.key_alias}, model={context.model}, tags={context.tags}"
|
||||
)
|
||||
|
||||
# Get matching policies via attachments
|
||||
matching_policy_names = PolicyMatcher.get_matching_policies(context=context)
|
||||
# Get matching policies via attachments (with match reasons for attribution)
|
||||
attachment_registry = get_attachment_registry()
|
||||
matches_with_reasons = attachment_registry.get_attached_policies_with_reasons(
|
||||
context
|
||||
)
|
||||
matching_policy_names = [m["policy_name"] for m in matches_with_reasons]
|
||||
# Build reasons map: {"hipaa-policy": "tag:healthcare", ...}
|
||||
policy_reasons = {m["policy_name"]: m["matched_via"] for m in matches_with_reasons}
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Policy engine: matched policies via attachments: {matching_policy_names}"
|
||||
@@ -1607,6 +1625,16 @@ def add_guardrails_from_policy_engine(
|
||||
request_data=data, policy_name=policy_name
|
||||
)
|
||||
|
||||
# Track policy attribution sources for x-litellm-policy-sources header
|
||||
applied_reasons = {
|
||||
name: policy_reasons[name]
|
||||
for name in applied_policy_names
|
||||
if name in policy_reasons
|
||||
}
|
||||
add_policy_sources_to_metadata(
|
||||
request_data=data, policy_sources=applied_reasons
|
||||
)
|
||||
|
||||
# Resolve guardrails from matching policies
|
||||
resolved_guardrails = PolicyResolver.resolve_guardrails_for_context(context=context)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class AttachmentRegistry:
|
||||
teams=attachment_data.get("teams"),
|
||||
keys=attachment_data.get("keys"),
|
||||
models=attachment_data.get("models"),
|
||||
tags=attachment_data.get("tags"),
|
||||
)
|
||||
|
||||
def get_attached_policies(self, context: PolicyMatchContext) -> List[str]:
|
||||
@@ -96,21 +97,68 @@ class AttachmentRegistry:
|
||||
Returns:
|
||||
List of policy names that are attached to matching scopes
|
||||
"""
|
||||
return [r["policy_name"] for r in self.get_attached_policies_with_reasons(context)]
|
||||
|
||||
def get_attached_policies_with_reasons(
|
||||
self, context: PolicyMatchContext
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of policy names and match reasons for the given context.
|
||||
|
||||
Returns a list of dicts with 'policy_name' and 'matched_via' keys.
|
||||
The 'matched_via' describes which dimension caused the match.
|
||||
"""
|
||||
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
|
||||
|
||||
attached_policies: List[str] = []
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen_policies: set = set()
|
||||
|
||||
for attachment in self._attachments:
|
||||
scope = attachment.to_policy_scope()
|
||||
if PolicyMatcher.scope_matches(scope=scope, context=context):
|
||||
if attachment.policy not in attached_policies:
|
||||
attached_policies.append(attachment.policy)
|
||||
if attachment.policy not in seen_policies:
|
||||
seen_policies.add(attachment.policy)
|
||||
matched_via = self._describe_match_reason(attachment, context)
|
||||
results.append(
|
||||
{
|
||||
"policy_name": attachment.policy,
|
||||
"matched_via": matched_via,
|
||||
}
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
f"Attachment matched: policy={attachment.policy}, "
|
||||
f"matched_via={matched_via}, "
|
||||
f"context=(team={context.team_alias}, key={context.key_alias}, model={context.model})"
|
||||
)
|
||||
|
||||
return attached_policies
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _describe_match_reason(
|
||||
attachment: PolicyAttachment, context: PolicyMatchContext
|
||||
) -> str:
|
||||
"""Describe why an attachment matched the context."""
|
||||
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
|
||||
|
||||
if attachment.is_global():
|
||||
return "scope:*"
|
||||
|
||||
reasons = []
|
||||
if attachment.tags and context.tags:
|
||||
matching_tags = [
|
||||
t for t in context.tags
|
||||
if PolicyMatcher.matches_pattern(t, attachment.tags)
|
||||
]
|
||||
if matching_tags:
|
||||
reasons.append(f"tag:{matching_tags[0]}")
|
||||
if attachment.teams and context.team_alias:
|
||||
reasons.append(f"team:{context.team_alias}")
|
||||
if attachment.keys and context.key_alias:
|
||||
reasons.append(f"key:{context.key_alias}")
|
||||
if attachment.models and context.model:
|
||||
reasons.append(f"model:{context.model}")
|
||||
|
||||
return "+".join(reasons) if reasons else "scope:default"
|
||||
|
||||
def is_policy_attached(
|
||||
self, policy_name: str, context: PolicyMatchContext
|
||||
@@ -238,6 +286,7 @@ class AttachmentRegistry:
|
||||
"teams": attachment_request.teams or [],
|
||||
"keys": attachment_request.keys or [],
|
||||
"models": attachment_request.models or [],
|
||||
"tags": attachment_request.tags or [],
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
"created_by": created_by,
|
||||
@@ -253,6 +302,7 @@ class AttachmentRegistry:
|
||||
teams=attachment_request.teams,
|
||||
keys=attachment_request.keys,
|
||||
models=attachment_request.models,
|
||||
tags=attachment_request.tags,
|
||||
)
|
||||
self.add_attachment(attachment)
|
||||
|
||||
@@ -263,6 +313,7 @@ class AttachmentRegistry:
|
||||
teams=created_attachment.teams or [],
|
||||
keys=created_attachment.keys or [],
|
||||
models=created_attachment.models or [],
|
||||
tags=created_attachment.tags or [],
|
||||
created_at=created_attachment.created_at,
|
||||
updated_at=created_attachment.updated_at,
|
||||
created_by=created_attachment.created_by,
|
||||
@@ -344,6 +395,7 @@ class AttachmentRegistry:
|
||||
teams=attachment.teams or [],
|
||||
keys=attachment.keys or [],
|
||||
models=attachment.models or [],
|
||||
tags=attachment.tags or [],
|
||||
created_at=attachment.created_at,
|
||||
updated_at=attachment.updated_at,
|
||||
created_by=attachment.created_by,
|
||||
@@ -381,6 +433,7 @@ class AttachmentRegistry:
|
||||
teams=a.teams or [],
|
||||
keys=a.keys or [],
|
||||
models=a.models or [],
|
||||
tags=a.tags or [],
|
||||
created_at=a.created_at,
|
||||
updated_at=a.updated_at,
|
||||
created_by=a.created_by,
|
||||
@@ -415,6 +468,7 @@ class AttachmentRegistry:
|
||||
teams=attachment_response.teams if attachment_response.teams else None,
|
||||
keys=attachment_response.keys if attachment_response.keys else None,
|
||||
models=attachment_response.models if attachment_response.models else None,
|
||||
tags=attachment_response.tags if attachment_response.tags else None,
|
||||
)
|
||||
self._attachments.append(attachment)
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ from litellm.types.proxy.policy_engine import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Get singleton instances
|
||||
POLICY_REGISTRY = get_policy_registry()
|
||||
ATTACHMENT_REGISTRY = get_attachment_registry()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Policy CRUD Endpoints
|
||||
@@ -75,7 +71,7 @@ async def list_policies():
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
policies = await POLICY_REGISTRY.get_all_policies_from_db(prisma_client)
|
||||
policies = await get_policy_registry().get_all_policies_from_db(prisma_client)
|
||||
return PolicyListDBResponse(policies=policies, total_count=len(policies))
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error listing policies: {e}")
|
||||
@@ -130,7 +126,7 @@ async def create_policy(
|
||||
|
||||
try:
|
||||
created_by = user_api_key_dict.user_id
|
||||
result = await POLICY_REGISTRY.add_policy_to_db(
|
||||
result = await get_policy_registry().add_policy_to_db(
|
||||
policy_request=request,
|
||||
prisma_client=prisma_client,
|
||||
created_by=created_by,
|
||||
@@ -168,7 +164,7 @@ async def get_policy(policy_id: str):
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
result = await POLICY_REGISTRY.get_policy_by_id_from_db(
|
||||
result = await get_policy_registry().get_policy_by_id_from_db(
|
||||
policy_id=policy_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -216,7 +212,7 @@ async def update_policy(
|
||||
|
||||
try:
|
||||
# Check if policy exists
|
||||
existing = await POLICY_REGISTRY.get_policy_by_id_from_db(
|
||||
existing = await get_policy_registry().get_policy_by_id_from_db(
|
||||
policy_id=policy_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -226,7 +222,7 @@ async def update_policy(
|
||||
)
|
||||
|
||||
updated_by = user_api_key_dict.user_id
|
||||
result = await POLICY_REGISTRY.update_policy_in_db(
|
||||
result = await get_policy_registry().update_policy_in_db(
|
||||
policy_id=policy_id,
|
||||
policy_request=request,
|
||||
prisma_client=prisma_client,
|
||||
@@ -269,7 +265,7 @@ async def delete_policy(policy_id: str):
|
||||
|
||||
try:
|
||||
# Check if policy exists
|
||||
existing = await POLICY_REGISTRY.get_policy_by_id_from_db(
|
||||
existing = await get_policy_registry().get_policy_by_id_from_db(
|
||||
policy_id=policy_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -278,7 +274,7 @@ async def delete_policy(policy_id: str):
|
||||
status_code=404, detail=f"Policy with ID {policy_id} not found"
|
||||
)
|
||||
|
||||
result = await POLICY_REGISTRY.delete_policy_from_db(
|
||||
result = await get_policy_registry().delete_policy_from_db(
|
||||
policy_id=policy_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -324,7 +320,7 @@ async def get_resolved_guardrails(policy_id: str):
|
||||
|
||||
try:
|
||||
# Get the policy
|
||||
policy = await POLICY_REGISTRY.get_policy_by_id_from_db(
|
||||
policy = await get_policy_registry().get_policy_by_id_from_db(
|
||||
policy_id=policy_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -334,7 +330,7 @@ async def get_resolved_guardrails(policy_id: str):
|
||||
)
|
||||
|
||||
# Resolve guardrails
|
||||
resolved = await POLICY_REGISTRY.resolve_guardrails_from_db(
|
||||
resolved = await get_policy_registry().resolve_guardrails_from_db(
|
||||
policy_name=policy.policy_name,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -399,7 +395,7 @@ async def list_policy_attachments():
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
attachments = await ATTACHMENT_REGISTRY.get_all_attachments_from_db(
|
||||
attachments = await get_attachment_registry().get_all_attachments_from_db(
|
||||
prisma_client
|
||||
)
|
||||
return PolicyAttachmentListResponse(
|
||||
@@ -466,7 +462,7 @@ async def create_policy_attachment(
|
||||
|
||||
try:
|
||||
# Verify the policy exists
|
||||
policy = await POLICY_REGISTRY.get_all_policies_from_db(prisma_client)
|
||||
policy = await get_policy_registry().get_all_policies_from_db(prisma_client)
|
||||
policy_names = [p.policy_name for p in policy]
|
||||
if request.policy_name not in policy_names:
|
||||
raise HTTPException(
|
||||
@@ -475,7 +471,7 @@ async def create_policy_attachment(
|
||||
)
|
||||
|
||||
created_by = user_api_key_dict.user_id
|
||||
result = await ATTACHMENT_REGISTRY.add_attachment_to_db(
|
||||
result = await get_attachment_registry().add_attachment_to_db(
|
||||
attachment_request=request,
|
||||
prisma_client=prisma_client,
|
||||
created_by=created_by,
|
||||
@@ -510,7 +506,7 @@ async def get_policy_attachment(attachment_id: str):
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
result = await ATTACHMENT_REGISTRY.get_attachment_by_id_from_db(
|
||||
result = await get_attachment_registry().get_attachment_by_id_from_db(
|
||||
attachment_id=attachment_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -556,7 +552,7 @@ async def delete_policy_attachment(attachment_id: str):
|
||||
|
||||
try:
|
||||
# Check if attachment exists
|
||||
existing = await ATTACHMENT_REGISTRY.get_attachment_by_id_from_db(
|
||||
existing = await get_attachment_registry().get_attachment_by_id_from_db(
|
||||
attachment_id=attachment_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
@@ -566,7 +562,7 @@ async def delete_policy_attachment(attachment_id: str):
|
||||
detail=f"Attachment with ID {attachment_id} not found",
|
||||
)
|
||||
|
||||
result = await ATTACHMENT_REGISTRY.delete_attachment_from_db(
|
||||
result = await get_attachment_registry().delete_attachment_from_db(
|
||||
attachment_id=attachment_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
@@ -81,6 +81,19 @@ class PolicyMatcher:
|
||||
if not PolicyMatcher.matches_pattern(context.model, scope.get_models()):
|
||||
return False
|
||||
|
||||
# Check tags (only if scope specifies tags)
|
||||
# Unlike teams/keys/models, empty tags means "do not check" rather than "match all"
|
||||
scope_tags = scope.get_tags()
|
||||
if scope_tags:
|
||||
if not context.tags:
|
||||
return False
|
||||
# Match if ANY context tag matches ANY scope tag pattern
|
||||
if not any(
|
||||
PolicyMatcher.matches_pattern(tag, scope_tags)
|
||||
for tag in context.tags
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -484,6 +484,7 @@ class PolicyRegistry:
|
||||
)
|
||||
self.add_policy(policy_response.policy_name, policy)
|
||||
|
||||
self._initialized = True
|
||||
verbose_proxy_logger.info(
|
||||
f"Synced {len(policies)} policies from DB to in-memory registry"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Policy resolve and attachment impact estimation endpoints.
|
||||
|
||||
- /policies/resolve — debug which guardrails apply for a given context
|
||||
- /policies/attachments/estimate-impact — preview blast radius before creating an attachment
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import MAX_POLICY_ESTIMATE_IMPACT_ROWS
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry
|
||||
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
|
||||
from litellm.types.proxy.policy_engine import (
|
||||
AttachmentImpactResponse,
|
||||
PolicyAttachmentCreateRequest,
|
||||
PolicyMatchContext,
|
||||
PolicyMatchDetail,
|
||||
PolicyResolveRequest,
|
||||
PolicyResolveResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_alias_where(field: str, patterns: list) -> dict:
|
||||
"""Build a Prisma ``where`` clause for alias patterns.
|
||||
|
||||
Supports exact matches and suffix wildcards (``prefix*``).
|
||||
Returns something like:
|
||||
{"OR": [{"field": {"in": ["a","b"]}}, {"field": {"startsWith": "dev-"}}]}
|
||||
"""
|
||||
exact: list = []
|
||||
prefix_conditions: list = []
|
||||
for pat in patterns:
|
||||
if pat.endswith("*"):
|
||||
prefix_conditions.append({field: {"startsWith": pat[:-1]}})
|
||||
else:
|
||||
exact.append(pat)
|
||||
|
||||
conditions: list = []
|
||||
if exact:
|
||||
conditions.append({field: {"in": exact}})
|
||||
conditions.extend(prefix_conditions)
|
||||
|
||||
if not conditions:
|
||||
return {field: {"not": None}}
|
||||
if len(conditions) == 1:
|
||||
return conditions[0]
|
||||
return {"OR": conditions}
|
||||
|
||||
|
||||
def _parse_metadata(raw_metadata: object) -> dict:
|
||||
"""Parse metadata that may be a dict, JSON string, or None."""
|
||||
if raw_metadata is None:
|
||||
return {}
|
||||
if isinstance(raw_metadata, str):
|
||||
try:
|
||||
return json.loads(raw_metadata)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return raw_metadata if isinstance(raw_metadata, dict) else {}
|
||||
|
||||
|
||||
def _get_tags_from_metadata(metadata: object, json_metadata: object = None) -> list:
|
||||
"""Extract tags list from a metadata field (or metadata_json fallback)."""
|
||||
raw = json_metadata if json_metadata is not None else metadata
|
||||
parsed = _parse_metadata(raw)
|
||||
return parsed.get("tags", []) or []
|
||||
|
||||
|
||||
async def _fetch_all_teams(prisma_client: object) -> list:
|
||||
"""Fetch teams from DB once. Reuse the result across tag and alias lookups."""
|
||||
return await prisma_client.db.litellm_teamtable.find_many( # type: ignore
|
||||
where={}, order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS,
|
||||
)
|
||||
|
||||
|
||||
def _filter_keys_by_tags(keys: list, tag_patterns: list) -> tuple:
|
||||
"""Filter key rows whose metadata.tags match any of the given patterns.
|
||||
|
||||
Returns (named_aliases, unnamed_count).
|
||||
"""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
affected: list = []
|
||||
unnamed_count = 0
|
||||
for key in keys:
|
||||
key_alias = key.key_alias or ""
|
||||
key_tags = _get_tags_from_metadata(
|
||||
key.metadata, getattr(key, "metadata_json", None)
|
||||
)
|
||||
if key_tags and any(
|
||||
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
|
||||
for tag in key_tags
|
||||
for pat in tag_patterns
|
||||
):
|
||||
if key_alias:
|
||||
affected.append(key_alias)
|
||||
else:
|
||||
unnamed_count += 1
|
||||
return affected, unnamed_count
|
||||
|
||||
|
||||
def _filter_teams_by_tags(teams: list, tag_patterns: list) -> tuple:
|
||||
"""Filter pre-fetched team rows whose metadata.tags match any patterns.
|
||||
|
||||
Returns (named_aliases, unnamed_count).
|
||||
"""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
affected: list = []
|
||||
unnamed_count = 0
|
||||
for team in teams:
|
||||
team_alias = team.team_alias or ""
|
||||
team_tags = _get_tags_from_metadata(team.metadata)
|
||||
if team_tags and any(
|
||||
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
|
||||
for tag in team_tags
|
||||
for pat in tag_patterns
|
||||
):
|
||||
if team_alias:
|
||||
affected.append(team_alias)
|
||||
else:
|
||||
unnamed_count += 1
|
||||
return affected, unnamed_count
|
||||
|
||||
|
||||
async def _find_affected_by_team_patterns(
|
||||
prisma_client: object,
|
||||
all_teams: list,
|
||||
team_patterns: list,
|
||||
existing_teams: list,
|
||||
existing_keys: list,
|
||||
) -> tuple:
|
||||
"""Filter pre-fetched teams by alias patterns, then fetch their keys.
|
||||
|
||||
Returns (new_teams, new_keys, unnamed_keys_count).
|
||||
"""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
new_teams: list = []
|
||||
matched_team_ids: list = []
|
||||
|
||||
for team in all_teams:
|
||||
team_alias = team.team_alias or ""
|
||||
if team_alias and any(
|
||||
RouteChecks._route_matches_wildcard_pattern(route=team_alias, pattern=pat)
|
||||
for pat in team_patterns
|
||||
):
|
||||
if team_alias not in existing_teams:
|
||||
new_teams.append(team_alias)
|
||||
matched_team_ids.append(str(team.team_id))
|
||||
|
||||
new_keys: list = []
|
||||
unnamed_keys_count = 0
|
||||
if matched_team_ids:
|
||||
keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore
|
||||
where={"team_id": {"in": matched_team_ids}},
|
||||
order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS,
|
||||
)
|
||||
for key in keys:
|
||||
key_alias = key.key_alias or ""
|
||||
if key_alias:
|
||||
if key_alias not in existing_keys:
|
||||
new_keys.append(key_alias)
|
||||
else:
|
||||
unnamed_keys_count += 1
|
||||
|
||||
return new_teams, new_keys, unnamed_keys_count
|
||||
|
||||
|
||||
async def _find_affected_keys_by_alias(
|
||||
prisma_client: object, key_patterns: list, existing_keys: list
|
||||
) -> list:
|
||||
"""Find keys whose alias matches the given patterns."""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
affected: list = []
|
||||
|
||||
keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore
|
||||
where=_build_alias_where("key_alias", key_patterns),
|
||||
order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS,
|
||||
)
|
||||
for key in keys:
|
||||
key_alias = key.key_alias or ""
|
||||
if key_alias and any(
|
||||
RouteChecks._route_matches_wildcard_pattern(route=key_alias, pattern=pat)
|
||||
for pat in key_patterns
|
||||
):
|
||||
if key_alias not in existing_keys:
|
||||
affected.append(key_alias)
|
||||
return affected
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Policy Resolve Endpoint
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/policies/resolve",
|
||||
tags=["Policies"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=PolicyResolveResponse,
|
||||
)
|
||||
async def resolve_policies_for_context(
|
||||
request: PolicyResolveRequest,
|
||||
force_sync: bool = Query(
|
||||
default=False,
|
||||
description="Force a DB sync before resolving. Default uses in-memory cache.",
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Resolve which policies and guardrails apply for a given context.
|
||||
|
||||
Use this endpoint to debug "what guardrails would apply to a request
|
||||
with this team/key/model/tags combination?"
|
||||
|
||||
Example Request:
|
||||
```bash
|
||||
curl -X POST "http://localhost:4000/policies/resolve" \\
|
||||
-H "Authorization: Bearer <your_api_key>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tags": ["healthcare"],
|
||||
"model": "gpt-4"
|
||||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
|
||||
from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# Only sync from DB when explicitly requested; otherwise use in-memory cache
|
||||
if force_sync:
|
||||
await get_policy_registry().sync_policies_from_db(prisma_client)
|
||||
await get_attachment_registry().sync_attachments_from_db(prisma_client)
|
||||
|
||||
# Build context from request
|
||||
context = PolicyMatchContext(
|
||||
team_alias=request.team_alias,
|
||||
key_alias=request.key_alias,
|
||||
model=request.model,
|
||||
tags=request.tags,
|
||||
)
|
||||
|
||||
# Get matching policies with reasons
|
||||
match_results = get_attachment_registry().get_attached_policies_with_reasons(
|
||||
context=context
|
||||
)
|
||||
|
||||
if not match_results:
|
||||
return PolicyResolveResponse(
|
||||
effective_guardrails=[],
|
||||
matched_policies=[],
|
||||
)
|
||||
|
||||
# Filter by conditions
|
||||
policy_names = [r["policy_name"] for r in match_results]
|
||||
applied_policy_names = PolicyMatcher.get_policies_with_matching_conditions(
|
||||
policy_names=policy_names,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Resolve guardrails for each applied policy
|
||||
matched_policies = []
|
||||
all_guardrails: set = set()
|
||||
for result in match_results:
|
||||
pname = result["policy_name"]
|
||||
if pname not in applied_policy_names:
|
||||
continue
|
||||
resolved = PolicyResolver.resolve_policy_guardrails(
|
||||
policy_name=pname,
|
||||
policies=get_policy_registry().get_all_policies(),
|
||||
context=context,
|
||||
)
|
||||
guardrails = resolved.guardrails if resolved else []
|
||||
all_guardrails.update(guardrails)
|
||||
matched_policies.append(
|
||||
PolicyMatchDetail(
|
||||
policy_name=pname,
|
||||
matched_via=result["matched_via"],
|
||||
guardrails_added=guardrails,
|
||||
)
|
||||
)
|
||||
|
||||
return PolicyResolveResponse(
|
||||
effective_guardrails=sorted(all_guardrails),
|
||||
matched_policies=matched_policies,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error resolving policies: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Attachment Impact Estimation Endpoint
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/policies/attachments/estimate-impact",
|
||||
tags=["Policies"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=AttachmentImpactResponse,
|
||||
)
|
||||
async def estimate_attachment_impact(
|
||||
request: PolicyAttachmentCreateRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Estimate how many keys and teams would be affected by a policy attachment.
|
||||
|
||||
Use this before creating an attachment to preview the blast radius.
|
||||
|
||||
Example Request:
|
||||
```bash
|
||||
curl -X POST "http://localhost:4000/policies/attachments/estimate-impact" \\
|
||||
-H "Authorization: Bearer <your_api_key>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"policy_name": "hipaa-compliance",
|
||||
"tags": ["healthcare", "health-*"]
|
||||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# If global scope, everything is affected — not useful to enumerate
|
||||
if request.scope == "*":
|
||||
return AttachmentImpactResponse(
|
||||
affected_keys_count=-1,
|
||||
affected_teams_count=-1,
|
||||
sample_keys=["(global scope — affects all keys)"],
|
||||
sample_teams=["(global scope — affects all teams)"],
|
||||
)
|
||||
|
||||
affected_keys: list = []
|
||||
affected_teams: list = []
|
||||
unnamed_keys = 0
|
||||
unnamed_teams = 0
|
||||
|
||||
tag_patterns = request.tags or []
|
||||
team_patterns = request.teams or []
|
||||
|
||||
# Fetch teams once — reused by both tag-based and alias-based lookups
|
||||
all_teams: list = []
|
||||
if tag_patterns or team_patterns:
|
||||
all_teams = await _fetch_all_teams(prisma_client)
|
||||
|
||||
# Tag-based impact
|
||||
if tag_patterns:
|
||||
keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore
|
||||
where={}, order={"created_at": "desc"},
|
||||
take=MAX_POLICY_ESTIMATE_IMPACT_ROWS,
|
||||
)
|
||||
affected_keys, unnamed_keys = _filter_keys_by_tags(keys, tag_patterns)
|
||||
affected_teams, unnamed_teams = _filter_teams_by_tags(
|
||||
all_teams, tag_patterns,
|
||||
)
|
||||
|
||||
# Team-based impact (alias matching + keys belonging to those teams)
|
||||
if team_patterns:
|
||||
new_teams, new_keys, new_unnamed = await _find_affected_by_team_patterns(
|
||||
prisma_client, all_teams, team_patterns,
|
||||
affected_teams, affected_keys,
|
||||
)
|
||||
affected_teams.extend(new_teams)
|
||||
affected_keys.extend(new_keys)
|
||||
unnamed_keys += new_unnamed
|
||||
|
||||
# Key-based impact (direct alias matching)
|
||||
key_patterns = request.keys or []
|
||||
if key_patterns:
|
||||
new_keys = await _find_affected_keys_by_alias(
|
||||
prisma_client, key_patterns, affected_keys,
|
||||
)
|
||||
affected_keys.extend(new_keys)
|
||||
|
||||
return AttachmentImpactResponse(
|
||||
affected_keys_count=len(affected_keys) + unnamed_keys,
|
||||
affected_teams_count=len(affected_teams) + unnamed_teams,
|
||||
unnamed_keys_count=unnamed_keys,
|
||||
unnamed_teams_count=unnamed_teams,
|
||||
sample_keys=affected_keys[:10],
|
||||
sample_teams=affected_teams[:10],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error estimating attachment impact: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -427,6 +427,9 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
router as pass_through_router,
|
||||
)
|
||||
from litellm.proxy.policy_engine.policy_endpoints import router as policy_crud_router
|
||||
from litellm.proxy.policy_engine.policy_resolve_endpoints import (
|
||||
router as policy_resolve_router,
|
||||
)
|
||||
from litellm.proxy.prompts.prompt_endpoints import router as prompts_router
|
||||
from litellm.proxy.public_endpoints import router as public_endpoints_router
|
||||
from litellm.proxy.rag_endpoints.endpoints import router as rag_router
|
||||
@@ -11746,6 +11749,7 @@ app.include_router(analytics_router)
|
||||
app.include_router(guardrails_router)
|
||||
app.include_router(policy_router)
|
||||
app.include_router(policy_crud_router)
|
||||
app.include_router(policy_resolve_router)
|
||||
app.include_router(search_tool_management_router)
|
||||
app.include_router(prompts_router)
|
||||
app.include_router(callback_management_endpoints_router)
|
||||
|
||||
@@ -911,6 +911,7 @@ model LiteLLM_PolicyAttachmentTable {
|
||||
teams String[] @default([]) // Team aliases or patterns
|
||||
keys String[] @default([]) // Key aliases or patterns
|
||||
models String[] @default([]) // Model names or patterns
|
||||
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
|
||||
created_at DateTime @default(now())
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -19,6 +19,7 @@ from litellm.types.proxy.policy_engine.policy_types import (
|
||||
PolicyScope,
|
||||
)
|
||||
from litellm.types.proxy.policy_engine.resolver_types import (
|
||||
AttachmentImpactResponse,
|
||||
PolicyAttachmentCreateRequest,
|
||||
PolicyAttachmentDBResponse,
|
||||
PolicyAttachmentListResponse,
|
||||
@@ -30,6 +31,9 @@ from litellm.types.proxy.policy_engine.resolver_types import (
|
||||
PolicyListDBResponse,
|
||||
PolicyListResponse,
|
||||
PolicyMatchContext,
|
||||
PolicyMatchDetail,
|
||||
PolicyResolveRequest,
|
||||
PolicyResolveResponse,
|
||||
PolicyScopeResponse,
|
||||
PolicySummaryItem,
|
||||
PolicyTestResponse,
|
||||
@@ -75,4 +79,9 @@ __all__ = [
|
||||
"PolicyAttachmentCreateRequest",
|
||||
"PolicyAttachmentDBResponse",
|
||||
"PolicyAttachmentListResponse",
|
||||
# Resolve types
|
||||
"PolicyResolveRequest",
|
||||
"PolicyResolveResponse",
|
||||
"PolicyMatchDetail",
|
||||
"AttachmentImpactResponse",
|
||||
]
|
||||
|
||||
@@ -73,13 +73,15 @@ class PolicyScope(BaseModel):
|
||||
Used internally by PolicyAttachment to define WHERE a policy applies.
|
||||
|
||||
Scope Fields:
|
||||
| Field | What it matches | Wildcard support |
|
||||
|--------|-----------------|----------------------|
|
||||
| teams | Team aliases | *, healthcare-* |
|
||||
| keys | Key aliases | *, dev-key-* |
|
||||
| models | Model names | *, bedrock/*, gpt-* |
|
||||
| Field | What it matches | Wildcard support | Default behavior |
|
||||
|--------|-----------------|----------------------|---------------------|
|
||||
| teams | Team aliases | *, healthcare-* | None → matches all |
|
||||
| keys | Key aliases | *, dev-key-* | None → matches all |
|
||||
| models | Model names | *, bedrock/*, gpt-* | None → matches all |
|
||||
| tags | Key/team tags | *, health-*, prod-* | None → not checked |
|
||||
|
||||
If a field is None or empty, it defaults to matching everything (["*"]).
|
||||
If teams/keys/models is None or empty, it defaults to matching everything (["*"]).
|
||||
If tags is None or empty, the tag dimension is NOT checked (matches all).
|
||||
A request must match ALL specified scope fields for the attachment to apply.
|
||||
"""
|
||||
|
||||
@@ -95,6 +97,10 @@ class PolicyScope(BaseModel):
|
||||
default=None,
|
||||
description="Model names or wildcard patterns. Use '*' for all models.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Tag patterns to match against key/team tags. Supports wildcards (e.g., health-*).",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -110,6 +116,14 @@ class PolicyScope(BaseModel):
|
||||
"""Returns models list, defaulting to ['*'] if not specified."""
|
||||
return self.models if self.models else ["*"]
|
||||
|
||||
def get_tags(self) -> List[str]:
|
||||
"""Returns tags list, defaulting to empty list if not specified.
|
||||
|
||||
Unlike teams/keys/models, empty tags means 'do not check tags'
|
||||
rather than 'match all'. This is because tags are opt-in scoping.
|
||||
"""
|
||||
return self.tags if self.tags else []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Policy Guardrails
|
||||
@@ -266,6 +280,10 @@ class PolicyAttachment(BaseModel):
|
||||
default=None,
|
||||
description="Model names or patterns this attachment applies to.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Tag patterns this attachment applies to. Supports wildcards (e.g., health-*).",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -281,6 +299,7 @@ class PolicyAttachment(BaseModel):
|
||||
teams=self.teams,
|
||||
keys=self.keys,
|
||||
models=self.models,
|
||||
tags=self.tags,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class PolicyMatchContext(BaseModel):
|
||||
default=None,
|
||||
description="Model name from the request.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Tags from key/team metadata.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -65,6 +69,7 @@ class PolicyScopeResponse(BaseModel):
|
||||
teams: List[str] = Field(default_factory=list)
|
||||
keys: List[str] = Field(default_factory=list)
|
||||
models: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyGuardrailsResponse(BaseModel):
|
||||
@@ -242,6 +247,10 @@ class PolicyAttachmentCreateRequest(BaseModel):
|
||||
default=None,
|
||||
description="Model names or patterns this attachment applies to.",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Tag patterns this attachment applies to. Supports wildcards (e.g., health-*).",
|
||||
)
|
||||
|
||||
|
||||
class PolicyAttachmentDBResponse(BaseModel):
|
||||
@@ -253,6 +262,7 @@ class PolicyAttachmentDBResponse(BaseModel):
|
||||
teams: List[str] = Field(default_factory=list, description="Team patterns.")
|
||||
keys: List[str] = Field(default_factory=list, description="Key patterns.")
|
||||
models: List[str] = Field(default_factory=list, description="Model patterns.")
|
||||
tags: List[str] = Field(default_factory=list, description="Tag patterns.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None, description="When the attachment was created."
|
||||
)
|
||||
@@ -274,3 +284,81 @@ class PolicyAttachmentListResponse(BaseModel):
|
||||
default_factory=list, description="List of policy attachments."
|
||||
)
|
||||
total_count: int = Field(default=0, description="Total number of attachments.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Policy Resolve Types
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PolicyResolveRequest(BaseModel):
|
||||
"""Request body for resolving effective policies/guardrails for a context."""
|
||||
|
||||
team_alias: Optional[str] = Field(
|
||||
default=None, description="Team alias to resolve for."
|
||||
)
|
||||
key_alias: Optional[str] = Field(
|
||||
default=None, description="Key alias to resolve for."
|
||||
)
|
||||
model: Optional[str] = Field(
|
||||
default=None, description="Model name to resolve for."
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default=None, description="Tags to resolve for."
|
||||
)
|
||||
|
||||
|
||||
class PolicyMatchDetail(BaseModel):
|
||||
"""Details about why a specific policy matched."""
|
||||
|
||||
policy_name: str = Field(description="Name of the matched policy.")
|
||||
matched_via: str = Field(
|
||||
description="How the policy was matched (e.g., 'tag:healthcare', 'team:health-team', 'scope:*')."
|
||||
)
|
||||
guardrails_added: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Guardrails this policy contributes.",
|
||||
)
|
||||
|
||||
|
||||
class PolicyResolveResponse(BaseModel):
|
||||
"""Response for resolving effective policies/guardrails for a context."""
|
||||
|
||||
effective_guardrails: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Final list of guardrails that would be applied.",
|
||||
)
|
||||
matched_policies: List[PolicyMatchDetail] = Field(
|
||||
default_factory=list,
|
||||
description="Details about each matched policy and why it matched.",
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Attachment Impact Estimation Types
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AttachmentImpactResponse(BaseModel):
|
||||
"""Response for estimating the impact of a policy attachment."""
|
||||
|
||||
affected_keys_count: int = Field(
|
||||
default=0, description="Number of keys that would be affected (named + unnamed)."
|
||||
)
|
||||
affected_teams_count: int = Field(
|
||||
default=0, description="Number of teams that would be affected (named + unnamed)."
|
||||
)
|
||||
unnamed_keys_count: int = Field(
|
||||
default=0, description="Number of affected keys without an alias."
|
||||
)
|
||||
unnamed_teams_count: int = Field(
|
||||
default=0, description="Number of affected teams without an alias."
|
||||
)
|
||||
sample_keys: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Sample of affected key aliases (up to 10).",
|
||||
)
|
||||
sample_teams: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Sample of affected team aliases (up to 10).",
|
||||
)
|
||||
|
||||
@@ -913,6 +913,7 @@ model LiteLLM_PolicyAttachmentTable {
|
||||
teams String[] @default([]) // Team aliases or patterns
|
||||
keys String[] @default([]) // Key aliases or patterns
|
||||
models String[] @default([]) // Model names or patterns
|
||||
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
|
||||
created_at DateTime @default(now())
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -192,6 +192,139 @@ class TestGetAttachedPolicies:
|
||||
assert "strict-policy" not in registry.get_attached_policies(context_wrong_team)
|
||||
|
||||
|
||||
class TestTagBasedAttachments:
|
||||
"""Test tag-based policy attachment matching."""
|
||||
|
||||
def test_tag_matching_and_wildcards(self):
|
||||
"""Test tag matching: exact match, wildcard match, and no-match cases."""
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments([
|
||||
{"policy": "hipaa-policy", "tags": ["healthcare"]},
|
||||
{"policy": "health-policy", "tags": ["health-*"]},
|
||||
])
|
||||
|
||||
# Exact tag match
|
||||
context = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
attached = registry.get_attached_policies(context)
|
||||
assert "hipaa-policy" in attached
|
||||
assert "health-policy" not in attached # "healthcare" doesn't match "health-*"
|
||||
|
||||
# Wildcard tag match
|
||||
context_wildcard = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["health-prod"],
|
||||
)
|
||||
attached_wildcard = registry.get_attached_policies(context_wildcard)
|
||||
assert "health-policy" in attached_wildcard
|
||||
assert "hipaa-policy" not in attached_wildcard
|
||||
|
||||
# No match — wrong tag
|
||||
context_no_match = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["finance"],
|
||||
)
|
||||
assert registry.get_attached_policies(context_no_match) == []
|
||||
|
||||
# No match — no tags on context
|
||||
context_no_tags = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=None,
|
||||
)
|
||||
assert registry.get_attached_policies(context_no_tags) == []
|
||||
|
||||
def test_tag_combined_with_team(self):
|
||||
"""Test attachment with both tags and teams requires BOTH to match (AND logic)."""
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments([
|
||||
{"policy": "strict-policy", "teams": ["team-a"], "tags": ["healthcare"]},
|
||||
])
|
||||
|
||||
# Match — both team and tag match
|
||||
context = PolicyMatchContext(
|
||||
team_alias="team-a", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
assert "strict-policy" in registry.get_attached_policies(context)
|
||||
|
||||
# No match — tag matches but team doesn't
|
||||
context_wrong_team = PolicyMatchContext(
|
||||
team_alias="team-b", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
assert "strict-policy" not in registry.get_attached_policies(context_wrong_team)
|
||||
|
||||
# No match — team matches but tag doesn't
|
||||
context_wrong_tag = PolicyMatchContext(
|
||||
team_alias="team-a", key_alias="key", model="gpt-4",
|
||||
tags=["finance"],
|
||||
)
|
||||
assert "strict-policy" not in registry.get_attached_policies(context_wrong_tag)
|
||||
|
||||
|
||||
class TestMatchAttribution:
|
||||
"""Test get_attached_policies_with_reasons — the attribution logic that
|
||||
powers response headers and the Policy Simulator UI."""
|
||||
|
||||
def test_reasons_for_global_tag_team_attachments(self):
|
||||
"""Test that match reasons correctly describe WHY each policy matched."""
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments([
|
||||
{"policy": "global-baseline", "scope": "*"},
|
||||
{"policy": "hipaa-policy", "tags": ["healthcare"]},
|
||||
{"policy": "team-policy", "teams": ["health-team"]},
|
||||
])
|
||||
|
||||
context = PolicyMatchContext(
|
||||
team_alias="health-team", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
results = registry.get_attached_policies_with_reasons(context)
|
||||
reasons = {r["policy_name"]: r["matched_via"] for r in results}
|
||||
|
||||
assert reasons["global-baseline"] == "scope:*"
|
||||
assert "tag:healthcare" in reasons["hipaa-policy"]
|
||||
assert "team:health-team" in reasons["team-policy"]
|
||||
|
||||
def test_tags_only_attachment_matches_any_team_key_model(self):
|
||||
"""Test the primary use case: tags-only attachment with no team/key/model
|
||||
constraint matches any request that carries the tag."""
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments([
|
||||
{"policy": "hipaa-guardrails", "tags": ["healthcare"]},
|
||||
])
|
||||
|
||||
# Should match regardless of team/key/model
|
||||
context = PolicyMatchContext(
|
||||
team_alias="random-team", key_alias="random-key", model="claude-3",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
attached = registry.get_attached_policies(context)
|
||||
assert "hipaa-guardrails" in attached
|
||||
|
||||
# Should not match without the tag
|
||||
context_no_tag = PolicyMatchContext(
|
||||
team_alias="random-team", key_alias="random-key", model="claude-3",
|
||||
)
|
||||
assert registry.get_attached_policies(context_no_tag) == []
|
||||
|
||||
def test_attachment_with_no_scope_matches_everything(self):
|
||||
"""Test that an attachment with no scope/teams/keys/models/tags
|
||||
matches everything because teams/keys/models default to ['*']."""
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments([
|
||||
{"policy": "catch-all"},
|
||||
])
|
||||
|
||||
context = PolicyMatchContext(
|
||||
team_alias="any-team", key_alias="any-key", model="gpt-4",
|
||||
)
|
||||
attached = registry.get_attached_policies(context)
|
||||
assert "catch-all" in attached
|
||||
|
||||
|
||||
class TestAttachmentRegistrySingleton:
|
||||
"""Test global singleton behavior."""
|
||||
|
||||
|
||||
@@ -64,6 +64,70 @@ class TestPolicyMatcherScopeMatching:
|
||||
assert PolicyMatcher.scope_matches(scope, context) is True
|
||||
|
||||
|
||||
class TestPolicyMatcherScopeMatchingWithTags:
|
||||
"""Test scope matching with tag patterns."""
|
||||
|
||||
def test_scope_tag_matching(self):
|
||||
"""Test scope tag matching: exact, wildcard, no-match, and empty context tags."""
|
||||
# Exact match
|
||||
scope = PolicyScope(teams=["*"], keys=["*"], models=["*"], tags=["healthcare"])
|
||||
context = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare", "internal"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context) is True
|
||||
|
||||
# Wildcard match
|
||||
scope_wc = PolicyScope(teams=["*"], keys=["*"], models=["*"], tags=["health-*"])
|
||||
context_wc = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["health-prod"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope_wc, context_wc) is True
|
||||
|
||||
# No match — wrong tag
|
||||
context_wrong = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4",
|
||||
tags=["finance"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context_wrong) is False
|
||||
|
||||
# No match — context has no tags
|
||||
context_none = PolicyMatchContext(
|
||||
team_alias="team", key_alias="key", model="gpt-4", tags=None,
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context_none) is False
|
||||
|
||||
# Scope without tags matches any context (opt-in semantics)
|
||||
scope_no_tags = PolicyScope(teams=["*"], keys=["*"], models=["*"])
|
||||
assert PolicyMatcher.scope_matches(scope_no_tags, context) is True
|
||||
|
||||
def test_scope_tags_and_team_combined(self):
|
||||
"""Test scope with both tags and team — both must match (AND logic)."""
|
||||
scope = PolicyScope(teams=["team-a"], keys=["*"], models=["*"], tags=["healthcare"])
|
||||
|
||||
# Both match
|
||||
context_both = PolicyMatchContext(
|
||||
team_alias="team-a", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context_both) is True
|
||||
|
||||
# Tag matches, team doesn't
|
||||
context_wrong_team = PolicyMatchContext(
|
||||
team_alias="team-b", key_alias="key", model="gpt-4",
|
||||
tags=["healthcare"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context_wrong_team) is False
|
||||
|
||||
# Team matches, tag doesn't
|
||||
context_wrong_tag = PolicyMatchContext(
|
||||
team_alias="team-a", key_alias="key", model="gpt-4",
|
||||
tags=["finance"],
|
||||
)
|
||||
assert PolicyMatcher.scope_matches(scope, context_wrong_tag) is False
|
||||
|
||||
|
||||
class TestPolicyMatcherWithAttachments:
|
||||
"""Test getting matching policies via attachments."""
|
||||
|
||||
|
||||
@@ -5654,6 +5654,68 @@ export const getResolvedGuardrails = async (accessToken: string, policyId: strin
|
||||
}
|
||||
};
|
||||
|
||||
export const resolvePoliciesCall = async (
|
||||
accessToken: string,
|
||||
context: { team_alias?: string; key_alias?: string; model?: string; tags?: string[] }
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policies/resolve`
|
||||
: `/policies/resolve`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(context),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve policies:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const estimateAttachmentImpactCall = async (
|
||||
accessToken: string,
|
||||
attachmentData: any
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policies/attachments/estimate-impact`
|
||||
: `/policies/attachments/estimate-impact`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(attachmentData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to estimate attachment impact:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPromptsList = async (accessToken: string): Promise<ListPromptsResponse> => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts/list` : `/prompts/list`;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Select, Radio, Divider, Typography } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Policy, PolicyAttachmentCreateRequest } from "./types";
|
||||
import { teamListCall, keyInfoCall, modelAvailableCall } from "../networking";
|
||||
import { Policy } from "./types";
|
||||
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { buildAttachmentData } from "./build_attachment_data";
|
||||
import ImpactPreviewAlert from "./impact_preview_alert";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -34,6 +36,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
const [isLoadingTeams, setIsLoadingTeams] = useState(false);
|
||||
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [isEstimating, setIsEstimating] = useState(false);
|
||||
const [impactResult, setImpactResult] = useState<any>(null);
|
||||
const { userId, userRole } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,33 +50,30 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
const loadTeamsKeysAndModels = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
// Load teams
|
||||
// Load teams — teamListCall returns a plain array of team objects
|
||||
setIsLoadingTeams(true);
|
||||
try {
|
||||
// Pass null for organizationID since we're loading all teams the user has access to
|
||||
const teamsResponse = await teamListCall(accessToken, null, userId);
|
||||
if (teamsResponse?.data) {
|
||||
const teamAliases = teamsResponse.data
|
||||
.map((t: any) => t.team_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableTeams(teamAliases);
|
||||
}
|
||||
const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []);
|
||||
const teamAliases = teamsArray
|
||||
.map((t: any) => t.team_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableTeams(teamAliases);
|
||||
} catch (error) {
|
||||
console.error("Failed to load teams:", error);
|
||||
} finally {
|
||||
setIsLoadingTeams(false);
|
||||
}
|
||||
|
||||
// Load keys
|
||||
// Load keys — keyListCall returns {keys: [...], total_count, ...}
|
||||
setIsLoadingKeys(true);
|
||||
try {
|
||||
const keysResponse = await keyInfoCall(accessToken, []);
|
||||
if (keysResponse?.data) {
|
||||
const keyAliases = keysResponse.data
|
||||
.map((k: any) => k.key_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableKeys(keyAliases);
|
||||
}
|
||||
const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100);
|
||||
const keysArray = keysResponse?.keys || keysResponse?.data || [];
|
||||
const keyAliases = keysArray
|
||||
.map((k: any) => k.key_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableKeys(keyAliases);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keys:", error);
|
||||
} finally {
|
||||
@@ -83,12 +84,11 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || "");
|
||||
if (modelsResponse?.data) {
|
||||
const modelIds = modelsResponse.data
|
||||
.map((m: any) => m.id || m.model_name)
|
||||
.filter(Boolean);
|
||||
setAvailableModels(modelIds);
|
||||
}
|
||||
const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []);
|
||||
const modelIds = modelsArray
|
||||
.map((m: any) => m.id || m.model_name)
|
||||
.filter(Boolean);
|
||||
setAvailableModels(modelIds);
|
||||
} catch (error) {
|
||||
console.error("Failed to load models:", error);
|
||||
} finally {
|
||||
@@ -99,6 +99,28 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
const resetForm = () => {
|
||||
form.resetFields();
|
||||
setScopeType("global");
|
||||
setImpactResult(null);
|
||||
};
|
||||
|
||||
const getAttachmentData = () => buildAttachmentData(form.getFieldsValue(true), scopeType);
|
||||
|
||||
const handlePreviewImpact = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await form.validateFields(["policy_name"]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setIsEstimating(true);
|
||||
try {
|
||||
const data = getAttachmentData();
|
||||
const result = await estimateAttachmentImpactCall(accessToken, data);
|
||||
setImpactResult(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to estimate impact:", error);
|
||||
} finally {
|
||||
setIsEstimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -110,30 +132,12 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("No access token available");
|
||||
}
|
||||
|
||||
const data: PolicyAttachmentCreateRequest = {
|
||||
policy_name: values.policy_name,
|
||||
};
|
||||
|
||||
if (scopeType === "global") {
|
||||
data.scope = "*";
|
||||
} else {
|
||||
if (values.teams && values.teams.length > 0) {
|
||||
data.teams = values.teams;
|
||||
}
|
||||
if (values.keys && values.keys.length > 0) {
|
||||
data.keys = values.keys;
|
||||
}
|
||||
if (values.models && values.models.length > 0) {
|
||||
data.models = values.models;
|
||||
}
|
||||
}
|
||||
|
||||
const data = getAttachmentData();
|
||||
await createAttachment(accessToken, data);
|
||||
NotificationsManager.success("Attachment created successfully");
|
||||
|
||||
@@ -195,8 +199,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
value={scopeType}
|
||||
onChange={(e) => setScopeType(e.target.value)}
|
||||
>
|
||||
<Radio value="specific">Specific (teams, keys, models, or tags)</Radio>
|
||||
<Radio value="global">Global (applies to all requests)</Radio>
|
||||
<Radio value="specific">Specific (teams, keys, or models)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
@@ -267,13 +271,41 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="Tags"
|
||||
tooltip="Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix."
|
||||
extra={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body. Use <code>*</code> as a suffix wildcard (e.g., <code>prod-*</code> matches <code>prod-us</code>, <code>prod-eu</code>).
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
|
||||
tokenSeparators={[",", " "]}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
open={false}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{scopeType === "specific" && (
|
||||
<Button variant="secondary" onClick={handlePreviewImpact} loading={isEstimating}>
|
||||
Estimate Impact
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} loading={isSubmitting}>
|
||||
Create Attachment
|
||||
</Button>
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { PolicyAttachment } from "./types";
|
||||
import ImpactPopover from "./impact_popover";
|
||||
|
||||
interface AttachmentTableProps {
|
||||
attachments: PolicyAttachment[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
isAdmin: boolean;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
@@ -24,6 +26,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
||||
@@ -152,6 +155,31 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Tags",
|
||||
accessorKey: "tags",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const tags = attachment.tags || [];
|
||||
if (tags.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((t, i) => (
|
||||
<Tag key={i} color="orange" className="text-xs">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
{tags.length > 2 && (
|
||||
<Tooltip title={tags.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{tags.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
@@ -171,6 +199,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
const attachment = row.original;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ImpactPopover attachment={attachment} accessToken={accessToken} />
|
||||
{isAdmin && (
|
||||
<Tooltip title="Delete attachment">
|
||||
<Icon
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PolicyAttachmentCreateRequest } from "./types";
|
||||
|
||||
/**
|
||||
* Builds a PolicyAttachmentCreateRequest from form values.
|
||||
*
|
||||
* @param formValues - The raw form field values (from form.getFieldsValue)
|
||||
* @param scopeType - Whether the attachment is "global" or "specific"
|
||||
*/
|
||||
export function buildAttachmentData(
|
||||
formValues: Record<string, any>,
|
||||
scopeType: "global" | "specific"
|
||||
): PolicyAttachmentCreateRequest {
|
||||
const data: PolicyAttachmentCreateRequest = {
|
||||
policy_name: formValues.policy_name,
|
||||
};
|
||||
if (scopeType === "global") {
|
||||
data.scope = "*";
|
||||
} else {
|
||||
if (formValues.teams && formValues.teams.length > 0) data.teams = formValues.teams;
|
||||
if (formValues.keys && formValues.keys.length > 0) data.keys = formValues.keys;
|
||||
if (formValues.models && formValues.models.length > 0) data.models = formValues.models;
|
||||
if (formValues.tags && formValues.tags.length > 0) data.tags = formValues.tags;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from "react";
|
||||
import { Icon } from "@tremor/react";
|
||||
import { EyeIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip, Tag, Popover, Spin } from "antd";
|
||||
import { PolicyAttachment } from "./types";
|
||||
import { estimateAttachmentImpactCall } from "../networking";
|
||||
|
||||
const ImpactPopover: React.FC<{ attachment: PolicyAttachment; accessToken: string | null }> = ({
|
||||
attachment,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [impact, setImpact] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const loadImpact = async () => {
|
||||
if (loaded || loading || !accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await estimateAttachmentImpactCall(accessToken, {
|
||||
policy_name: attachment.policy_name,
|
||||
scope: attachment.scope,
|
||||
teams: attachment.teams,
|
||||
keys: attachment.keys,
|
||||
models: attachment.models,
|
||||
tags: attachment.tags,
|
||||
});
|
||||
setImpact(data);
|
||||
setLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load impact:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = loading ? (
|
||||
<div className="p-2 text-center"><Spin size="small" /> Loading...</div>
|
||||
) : impact ? (
|
||||
<div className="text-xs" style={{ maxWidth: 280 }}>
|
||||
{impact.affected_keys_count === -1 ? (
|
||||
<p className="font-medium text-amber-600">Global scope — affects all keys and teams</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1">
|
||||
<strong>{impact.affected_keys_count}</strong> key{impact.affected_keys_count !== 1 ? "s" : ""},{" "}
|
||||
<strong>{impact.affected_teams_count}</strong> team{impact.affected_teams_count !== 1 ? "s" : ""} affected
|
||||
</p>
|
||||
{impact.sample_keys.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<span className="text-gray-500">Keys: </span>
|
||||
{impact.sample_keys.map((k: string) => (
|
||||
<Tag key={k} style={{ fontSize: 10, margin: 1 }}>{k}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{impact.sample_teams.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Teams: </span>
|
||||
{impact.sample_teams.map((t: string) => (
|
||||
<Tag key={t} style={{ fontSize: 10, margin: 1 }}>{t}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{impact.affected_keys_count === 0 && impact.affected_teams_count === 0 && (
|
||||
<p className="text-gray-400">No keys or teams currently affected</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Click to load</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} title="Blast Radius" trigger="click" onOpenChange={(open) => { if (open) loadImpact(); }}>
|
||||
<Tooltip title="View blast radius">
|
||||
<Icon icon={EyeIcon} size="sm" className="cursor-pointer hover:text-blue-500" />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImpactPopover;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Alert, Tag, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ImpactResult {
|
||||
affected_keys_count: number;
|
||||
affected_teams_count: number;
|
||||
sample_keys: string[];
|
||||
sample_teams: string[];
|
||||
}
|
||||
|
||||
interface ImpactPreviewAlertProps {
|
||||
impactResult: ImpactResult;
|
||||
}
|
||||
|
||||
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
|
||||
return (
|
||||
<Alert
|
||||
type={impactResult.affected_keys_count === -1 ? "warning" : "info"}
|
||||
showIcon
|
||||
className="mb-4"
|
||||
message="Impact Preview"
|
||||
description={
|
||||
impactResult.affected_keys_count === -1 ? (
|
||||
<Text>Global scope — this will affect <strong>all keys and teams</strong>.</Text>
|
||||
) : (
|
||||
<div>
|
||||
<Text>
|
||||
This attachment would affect <strong>{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}</strong> and <strong>{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}</strong>.
|
||||
</Text>
|
||||
{impactResult.sample_keys.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Keys: </Text>
|
||||
{impactResult.sample_keys.slice(0, 5).map((k: string) => (
|
||||
<Tag key={k} style={{ fontSize: 11 }}>{k}</Tag>
|
||||
))}
|
||||
{impactResult.affected_keys_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_keys_count - 5} more...</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{impactResult.sample_teams.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Teams: </Text>
|
||||
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
|
||||
<Tag key={t} style={{ fontSize: 11 }}>{t}</Tag>
|
||||
))}
|
||||
{impactResult.affected_teams_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_teams_count - 5} more...</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImpactPreviewAlert;
|
||||
@@ -8,6 +8,7 @@ import PolicyInfoView from "./policy_info";
|
||||
import AddPolicyForm from "./add_policy_form";
|
||||
import AttachmentTable from "./attachment_table";
|
||||
import AddAttachmentForm from "./add_attachment_form";
|
||||
import PolicyTestPanel from "./policy_test_panel";
|
||||
import {
|
||||
getPoliciesList,
|
||||
deletePolicyCall,
|
||||
@@ -177,6 +178,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
<TabList className="mb-4">
|
||||
<Tab>Policies</Tab>
|
||||
<Tab>Attachments</Tab>
|
||||
<Tab>Policy Simulator</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
@@ -279,7 +281,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Policy attachments control where your policies apply. Policies don't do anything until you attach them to specific teams, keys, models, or globally.
|
||||
Policy attachments control where your policies apply. Policies don't do anything until you attach them to specific teams, keys, models, tags, or globally.
|
||||
</p>
|
||||
<p className="mb-2 font-semibold">Attachment Scopes:</p>
|
||||
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
|
||||
@@ -287,6 +289,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
<li><strong>Teams</strong> - Applies only to specific teams</li>
|
||||
<li><strong>Keys</strong> - Applies only to specific API keys (supports wildcards like dev-*)</li>
|
||||
<li><strong>Models</strong> - Applies only when specific models are used</li>
|
||||
<li><strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body (<code>metadata.tags</code>). Use this to enforce policies across groups, e.g. "all keys tagged <code>healthcare</code> get HIPAA guardrails." Supports wildcards (<code>prod-*</code>).</li>
|
||||
</ul>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
|
||||
@@ -319,6 +322,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
isLoading={isAttachmentsLoading}
|
||||
onDeleteClick={handleDeleteAttachment}
|
||||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
<AddAttachmentForm
|
||||
@@ -330,6 +334,10 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
createAttachment={createPolicyAttachmentCall}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<PolicyTestPanel accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Select, Alert, Tag, Empty, Typography } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "../networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface PolicyTestPanelProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface PolicyMatchDetail {
|
||||
policy_name: string;
|
||||
matched_via: string;
|
||||
guardrails_added: string[];
|
||||
}
|
||||
|
||||
interface ResolveResult {
|
||||
effective_guardrails: string[];
|
||||
matched_policies: PolicyMatchDetail[];
|
||||
}
|
||||
|
||||
const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [result, setResult] = useState<ResolveResult | null>(null);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [availableTeams, setAvailableTeams] = useState<string[]>([]);
|
||||
const [availableKeys, setAvailableKeys] = useState<string[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const { userId, userRole } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
loadOptions();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken]);
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const teamsResponse = await teamListCall(accessToken, null, userId);
|
||||
const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []);
|
||||
setAvailableTeams(
|
||||
teamsArray.map((t: any) => t.team_alias).filter(Boolean)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load teams:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100);
|
||||
const keysArray = keysResponse?.keys || keysResponse?.data || [];
|
||||
setAvailableKeys(
|
||||
keysArray.map((k: any) => k.key_alias).filter(Boolean)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keys:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || "");
|
||||
const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []);
|
||||
setAvailableModels(
|
||||
modelsArray.map((m: any) => m.id || m.model_name).filter(Boolean)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setHasSearched(true);
|
||||
try {
|
||||
const values = form.getFieldsValue(true);
|
||||
const context: any = {};
|
||||
if (values.team_alias) context.team_alias = values.team_alias;
|
||||
if (values.key_alias) context.key_alias = values.key_alias;
|
||||
if (values.model) context.model = values.model;
|
||||
if (values.tags && values.tags.length > 0) context.tags = values.tags;
|
||||
|
||||
const data = await resolvePoliciesCall(accessToken, context);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
console.error("Error resolving policies:", error);
|
||||
setResult(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
setResult(null);
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-base font-semibold mb-1">Policy Simulator</h3>
|
||||
<Text type="secondary">
|
||||
Simulate a request to see which policies and guardrails would apply. Select a team, key, model, or tags below and click "Simulate" to see the results.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item name="team_alias" label="Team Alias" className="mb-3">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select or type a team alias"
|
||||
options={availableTeams.map((t) => ({ label: t, value: t }))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="key_alias" label="Key Alias" className="mb-3">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select or type a key alias"
|
||||
options={availableKeys.map((k) => ({ label: k, value: k }))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="Model" className="mb-3">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select or type a model"
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="Tags" className="mb-3">
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Type a tag and press Enter"
|
||||
tokenSeparators={[",", " "]}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
open={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={handleTest} loading={isLoading} disabled={!accessToken}>
|
||||
Simulate
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{!hasSearched && (
|
||||
<div className="bg-white border rounded-lg p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">No simulation run yet</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Fill in one or more fields above and click "Simulate" to see which policies and guardrails would apply to that request.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && result && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
{result.matched_policies.length === 0 ? (
|
||||
<Empty description="No policies matched this context" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold mb-2">Effective Guardrails</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.effective_guardrails.length > 0 ? (
|
||||
result.effective_guardrails.map((g) => (
|
||||
<Tag key={g} color="green">{g}</Tag>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">None</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2">Matched Policies</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4">Policy</th>
|
||||
<th className="text-left py-2 pr-4">Matched Via</th>
|
||||
<th className="text-left py-2">Guardrails Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.matched_policies.map((p) => (
|
||||
<tr key={p.policy_name} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{p.policy_name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Tag color="blue">{p.matched_via}</Tag>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{p.guardrails_added.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.guardrails_added.map((g) => (
|
||||
<Tag key={g} color="green">{g}</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && !result && !isLoading && (
|
||||
<Alert
|
||||
message="Error"
|
||||
description="Failed to resolve policies. Check the proxy logs."
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyTestPanel;
|
||||
@@ -23,6 +23,7 @@ export interface PolicyAttachment {
|
||||
teams: string[];
|
||||
keys: string[];
|
||||
models: string[];
|
||||
tags: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
@@ -53,6 +54,7 @@ export interface PolicyAttachmentCreateRequest {
|
||||
teams?: string[];
|
||||
keys?: string[];
|
||||
models?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyListResponse {
|
||||
|
||||
Reference in New Issue
Block a user