from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass from fnmatch import fnmatchcase from typing import Any from govoplan_core.core.policy import PolicyDecision, PolicySourceStep, normalize_policy_scope_type _ALLOW_KEYS = ("allow", "allowlist", "whitelist") _DENY_KEYS = ("deny", "denylist", "blacklist") _FIELD_ALIASES = { "connectors": ("connectors", "connector_ids", "connector_id"), "credentials": ("credentials", "credential_ids", "credential_id"), "providers": ("providers", "provider"), "external_ids": ("external_ids", "external_id"), "external_paths": ("external_paths", "paths", "path_prefixes", "external_path"), "external_urls": ("external_urls", "urls", "external_url"), } CONNECTOR_POLICY_FIELDS = tuple(_FIELD_ALIASES) class ConnectorPolicyDenied(RuntimeError): def __init__(self, decision: PolicyDecision) -> None: super().__init__(decision.reason or "Connector policy denied") self.decision = decision @dataclass(frozen=True, slots=True) class ConnectorAccessRequest: connector_id: str | None = None credential_id: str | None = None provider: str | None = None external_id: str | None = None external_path: str | None = None external_url: str | None = None operation: str = "access" @classmethod def from_provenance(cls, value: Mapping[str, Any] | None, *, operation: str = "access") -> "ConnectorAccessRequest": payload = value or {} return cls( connector_id=_clean(payload.get("connector_id")), credential_id=_clean(payload.get("credential_id") or payload.get("connector_credential_id")), provider=_clean(payload.get("provider") or payload.get("source_type")), external_id=_clean(payload.get("external_id")), external_path=_clean(payload.get("external_path")), external_url=_clean(payload.get("external_url")), operation=_clean(operation) or "access", ) def to_dict(self) -> dict[str, str | None]: return { "connector_id": self.connector_id, "credential_id": self.credential_id, "provider": self.provider, "external_id": self.external_id, "external_path": self.external_path, "external_url": self.external_url, "operation": self.operation, } @dataclass(frozen=True, slots=True) class ConnectorPolicySource: scope_type: str label: str scope_id: str | None = None policy: Mapping[str, Any] | None = None def source_step(self, applied_fields: Iterable[str]) -> PolicySourceStep: return PolicySourceStep( scope_type=normalize_policy_scope_type(self.scope_type), scope_id=self.scope_id, label=self.label, applied_fields=tuple(dict.fromkeys(applied_fields)), policy=dict(self.policy or {}), ) def connector_policy_sources_from_payload(value: object) -> list[ConnectorPolicySource]: if value is None or value == "": return [] if isinstance(value, Mapping): raw_sources = value.get("sources") if raw_sources is None: raw_sources = [value] elif isinstance(value, list): raw_sources = value else: raise ValueError("connector_policy must be a JSON object or list") if not isinstance(raw_sources, list): raise ValueError("connector_policy.sources must be a list") return [_source_from_mapping(item) for item in raw_sources if isinstance(item, Mapping)] def connector_policy_applied_fields(policy: Mapping[str, Any] | None) -> tuple[str, ...]: return _applied_fields(_policy_mapping(policy)) def filtered_connector_policy_source(source: ConnectorPolicySource, fields: Iterable[str]) -> ConnectorPolicySource: allowed_fields = {field for field in fields if field in _FIELD_ALIASES} if not allowed_fields: return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy={}) policy = _policy_mapping(source.policy) filtered: dict[str, Any] = {} for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)): rules = _merged_rule(policy, keys) selected = {field: _string_list(rules.get(field)) for field in allowed_fields} selected = {field: values for field, values in selected.items() if values} if selected: filtered[prefix] = selected return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy=filtered) def connector_policy_sources_for_fields( sources: Iterable[ConnectorPolicySource], fields: Iterable[str], ) -> list[ConnectorPolicySource]: return [filtered_connector_policy_source(source, fields) for source in sources] def connector_policy_decision( request: ConnectorAccessRequest, sources: Iterable[ConnectorPolicySource], ) -> PolicyDecision: source_list = list(sources) source_steps: list[PolicySourceStep] = [] deny_matches: list[dict[str, Any]] = [] allow_misses: list[dict[str, Any]] = [] for source in source_list: policy = _policy_mapping(source.policy) applied_fields = _applied_fields(policy) if applied_fields: source_steps.append(source.source_step(applied_fields)) deny_policy = _merged_rule(policy, _DENY_KEYS) for field, patterns in _rules_by_field(deny_policy).items(): if _matches_field(request, field, patterns): deny_matches.append({ "scope": source.source_step((f"deny.{field}",)).to_dict(), "field": field, "patterns": patterns, }) allow_policy = _merged_rule(policy, _ALLOW_KEYS) for field, patterns in _rules_by_field(allow_policy).items(): if not _matches_field(request, field, patterns): allow_misses.append({ "scope": source.source_step((f"allow.{field}",)).to_dict(), "field": field, "patterns": patterns, }) details = { "request": request.to_dict(), "deny_matches": deny_matches, "allow_misses": allow_misses, } if deny_matches: return PolicyDecision( allowed=False, reason="Connector access is denied by a blacklist rule.", source_path=tuple(source_steps), requirements=("connector_policy_denylist",), details=details, ) if allow_misses: return PolicyDecision( allowed=False, reason="Connector access is not allowed by the configured whitelist.", source_path=tuple(source_steps), requirements=("connector_policy_allowlist",), details=details, ) return PolicyDecision( allowed=True, reason=None, source_path=tuple(source_steps), requirements=(), details=details, ) def ensure_connector_policy_allows( request: ConnectorAccessRequest, sources: Iterable[ConnectorPolicySource], ) -> PolicyDecision: decision = connector_policy_decision(request, sources) if not decision.allowed: raise ConnectorPolicyDenied(decision) return decision def _source_from_mapping(value: Mapping[str, Any]) -> ConnectorPolicySource: scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system")) scope_id = _clean(value.get("scope_id")) if scope_type == "system": scope_id = None elif not scope_id: raise ValueError(f"{scope_type} connector policy sources require scope_id") label = _clean(value.get("label")) or scope_type.capitalize() policy = value.get("policy") if policy is None: policy = {key: value[key] for key in (*_ALLOW_KEYS, *_DENY_KEYS) if key in value} if policy is not None and not isinstance(policy, Mapping): raise ValueError("connector policy source policy must be a JSON object") return ConnectorPolicySource(scope_type=scope_type, scope_id=scope_id, label=label, policy=policy or {}) def _policy_mapping(value: Mapping[str, Any] | None) -> Mapping[str, Any]: return value if isinstance(value, Mapping) else {} def _merged_rule(policy: Mapping[str, Any], keys: Iterable[str]) -> dict[str, Any]: merged: dict[str, Any] = {} for key in keys: raw = policy.get(key) if isinstance(raw, Mapping): merged.update(raw) return merged def _rules_by_field(value: Mapping[str, Any]) -> dict[str, list[str]]: result: dict[str, list[str]] = {} for field, aliases in _FIELD_ALIASES.items(): items: list[str] = [] for alias in aliases: items.extend(_string_list(value.get(alias))) if items: result[field] = list(dict.fromkeys(items)) return result def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]: fields: list[str] = [] for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)): rules = _merged_rule(policy, keys) fields.extend(f"{prefix}.{field}" for field in _rules_by_field(rules)) return tuple(dict.fromkeys(fields)) def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool: if field == "connectors": return _matches_reference(request.connector_id, patterns) if field == "credentials": return _matches_reference(request.credential_id, patterns) if field == "providers": return _matches_reference(request.provider, patterns) if field == "external_ids": return _matches_glob(request.external_id, patterns) if field == "external_paths": return _matches_path(request.external_path, patterns) if field == "external_urls": return _matches_glob(request.external_url, patterns) return False def _matches_reference(value: str | None, patterns: list[str]) -> bool: if value is None: return False clean = value.casefold() return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns) def _matches_glob(value: str | None, patterns: list[str]) -> bool: if value is None: return False clean = value.casefold() return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns) def _matches_path(value: str | None, patterns: list[str]) -> bool: if value is None: return False clean = value.replace("\\", "/").strip() for pattern in patterns: normalized = pattern.replace("\\", "/").strip() if normalized == "*": return True if any(token in normalized for token in "*?[]"): if fnmatchcase(clean.casefold(), normalized.casefold()): return True continue if clean.casefold() == normalized.casefold() or clean.casefold().startswith(normalized.rstrip("/").casefold() + "/"): return True return False def _string_list(value: object) -> list[str]: if value is None: return [] if isinstance(value, str): values = [value] elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)): values = [str(item) for item in value] else: values = [str(value)] return [item.strip() for item in values if item.strip()] def _clean(value: object) -> str | None: if value is None: return None text = str(value).strip() return text or None