311 lines
11 KiB
Python
311 lines
11 KiB
Python
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
|