File size: 12,463 Bytes
375880c 5a60197 91b8be2 375880c 5a60197 375880c 91b8be2 375880c 5a60197 375880c 91b8be2 375880c 5a60197 375880c 5a60197 375880c 5a60197 91b8be2 5a60197 91b8be2 5a60197 375880c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 | """PlatformContext: what the tenant looks like right now.
The Node proxy builds this from the database on every request (groups, rules, block lists,
connectors, active policies) and the copilot never reads the database itself. Loading is
deliberately forgiving: a malformed entry is dropped, never raised, because a bad context
must degrade answers, not crash the service.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from . import catalog
CONNECTOR_CHANNELS = ('email', 'slack', 'teams', 'drive', 'chat', 'm365files', 's3', 'gcs', 'azure', 'github')
@dataclass
class Group:
id: str
name: str
@dataclass
class Rule:
id: str
name: str
entities: list[str]
keywords: list[str]
fileTypes: list[str]
severity: str
builtin: bool
@dataclass
class BlockList:
id: str
name: str
kind: str # 'web' | 'app'
entries: list[str]
builtin: bool
@dataclass
class WebCategory:
name: str # as the admin named it in Web Filtering; policies store this name
domains: int # how many sites it holds (0 until it has been filled)
@dataclass
class Connector:
id: str
name: str
hasAdminToken: bool = False # slack: needed for the delete action
remediationEnabled: bool = False # email: needed for the remediate action
provider: str = '' # email: 'google' | 'm365'
@dataclass
class Detector:
"""An admin-made regex detector (Policies -> Detectors). Rules reference it by label."""
label: str # CUSTOMER_ID: what a detection rule stores in `entities`
name: str # "Customer ID": what the admin calls it
enabled: bool
@dataclass
class PolicySummary:
id: str
name: str
type: str
groupId: str
action: str
connectorIds: list[str]
provider: str = '' # email policies: 'google' | 'm365' (one saved before the split is Gmail)
active: bool = True
# Device and browser switches that are on ("agentControl.blockPrint"), for unblock requests.
controls: set[str] = field(default_factory=set)
# Sites and apps it blocks by name, and the block lists it uses: for "unblock chatgpt".
blockedDomains: set[str] = field(default_factory=set)
blockedApps: set[str] = field(default_factory=set)
webLists: list[str] = field(default_factory=list)
appLists: list[str] = field(default_factory=list)
@dataclass
class PlatformContext:
groups: list[Group] = field(default_factory=list)
rules: list[Rule] = field(default_factory=list)
blockLists: list[BlockList] = field(default_factory=list)
connectors: dict[str, list[Connector]] = field(default_factory=dict)
# Active policies only. The copilot uses them to explain how a new one will combine.
policies: list[PolicySummary] = field(default_factory=list)
# Every policy, on or off: what a request may add to instead of creating another one.
allPolicies: list[PolicySummary] = field(default_factory=list)
# Names of every policy, active or not, so a new one never repeats a name.
policyNames: set[str] = field(default_factory=set)
# True when the caller told us which connectors exist. When it did not, we cannot
# say "no connector", so connector checks are skipped rather than guessed.
connectorsKnown: bool = False
policiesKnown: bool = False
# Web Filtering categories (tenant-defined). Unknown when the caller sent none.
webCategories: list[WebCategory] = field(default_factory=list)
webCategoriesKnown: bool = False
# True when the caller sent each policy's switches (PolicySummary.controls).
controlsKnown: bool = False
# True when the caller sent what each policy blocks (sites, apps, lists).
blocksKnown: bool = False
# The tenant's own regex detectors, and the built-in ones an admin switched off.
detectors: list[Detector] = field(default_factory=list)
disabledDetectors: set[str] = field(default_factory=set)
def entity_keys(self) -> frozenset[str]:
"""Every key a rule may hold for this tenant: the platform's plus its custom detectors."""
if not self.detectors:
return catalog.entity_keys()
return catalog.entity_keys() | {x.label for x in self.detectors}
def entity_label(self, key: str) -> str:
own = next((x for x in self.detectors if x.label == key), None)
return own.name if own else catalog.entity_label(key)
def detector_off(self, key: str) -> bool:
"""Switched off on the Detectors page: a rule may list it, but it finds nothing."""
own = next((x for x in self.detectors if x.label == key), None)
return (not own.enabled) if own else key in self.disabledDetectors
def policy(self, pid: str) -> PolicySummary | None:
return next((p for p in self.allPolicies if p.id == pid), None)
def group(self, gid: str) -> Group | None:
return next((g for g in self.groups if g.id == gid), None)
def rule(self, rid: str) -> Rule | None:
return next((r for r in self.rules if r.id == rid), None)
def block_list(self, lid: str) -> BlockList | None:
return next((b for b in self.blockLists if b.id == lid), None)
def connectors_for(self, channel: str) -> list[Connector]:
return self.connectors.get(channel, [])
def web_category(self, name: str) -> WebCategory | None:
return next((w for w in self.webCategories if w.name.lower() == name.lower()), None)
_LABEL = re.compile(r'^[A-Z][A-Z0-9_]{1,63}$')
_CONTROL = re.compile(r'^(agentControl|extensionControl)\.[A-Za-z][A-Za-z0-9]{1,63}$')
def _s(v: Any, limit: int = 300) -> str:
return v.strip()[:limit] if isinstance(v, str) else ''
def _strs(v: Any, limit: int = 500) -> list[str]:
if not isinstance(v, list):
return []
out: list[str] = []
for x in v[:limit]:
s = _s(x)
if s and s not in out:
out.append(s)
return out
def _b(v: Any) -> bool:
return v is True
def _dicts(v: Any, limit: int = 2000) -> list[dict]:
return [x for x in v[:limit] if isinstance(x, dict)] if isinstance(v, list) else []
def load_context(raw: Any) -> PlatformContext:
"""Build a context from untrusted JSON. Never raises."""
raw = raw if isinstance(raw, dict) else {}
ctx = PlatformContext()
for g in _dicts(raw.get('groups')):
gid, name = _s(g.get('id'), 64), _s(g.get('name'), 120)
if gid and name and gid != 'default' and not ctx.group(gid):
ctx.groups.append(Group(gid, name))
seen: set[str] = set()
for r in _dicts(raw.get('detectionRules')):
rid, name = _s(r.get('id'), 64), _s(r.get('name'), 200)
if not rid or not name or rid in seen:
continue
sev = _s(r.get('severity')).upper()
ctx.rules.append(Rule(
id=rid, name=name,
entities=[e.upper() for e in _strs(r.get('entities'))],
keywords=[k.lower() for k in _strs(r.get('keywords'))],
fileTypes=[f.lower() for f in _strs(r.get('fileTypes'))],
severity=sev if sev in ('LOW', 'MEDIUM', 'HIGH') else 'HIGH',
builtin=_b(r.get('builtin')) or rid.startswith('builtin_'),
))
seen.add(rid)
# The server seeds the presets on every boot, so they always exist. Add any the caller left out.
for r in catalog.builtin_rules():
if r['id'] not in seen:
ctx.rules.append(Rule(r['id'], r['name'], list(r['entities']), list(r['keywords']),
list(r['fileTypes']), r['severity'], True))
seen = set()
for b in _dicts(raw.get('blockLists')):
lid, name, kind = _s(b.get('id'), 64), _s(b.get('name'), 200), _s(b.get('kind'))
if not lid or not name or kind not in ('web', 'app') or lid in seen:
continue
ctx.blockLists.append(BlockList(lid, name, kind, [e.lower() for e in _strs(b.get('entries'), 5000)],
_b(b.get('builtin')) or lid.startswith('builtin_')))
seen.add(lid)
for b in catalog.builtin_block_lists():
if b['id'] not in seen:
ctx.blockLists.append(BlockList(b['id'], b['name'], b['kind'], list(b['entries']), True))
if isinstance(raw.get('webCategories'), list):
ctx.webCategoriesKnown = True
for w in _dicts(raw.get('webCategories')):
name = _s(w.get('name'), 120)
if name and not ctx.web_category(name):
n = w.get('domains')
ctx.webCategories.append(WebCategory(name, n if isinstance(n, int) and n >= 0 else 0))
conns = raw.get('connectors')
if isinstance(conns, dict):
ctx.connectorsKnown = True
for ch in CONNECTOR_CHANNELS:
for c in _dicts(conns.get(ch)):
cid = _s(c.get('id'), 64)
if not cid:
continue
ctx.connectors.setdefault(ch, []).append(Connector(
id=cid, name=_s(c.get('name'), 200) or _s(c.get('teamName'), 200) or cid,
hasAdminToken=_b(c.get('hasAdminToken')),
remediationEnabled=_b(c.get('remediationEnabled')),
provider=_s(c.get('provider'), 16) if ch == 'email' else '',
))
builtin_keys = catalog.entity_keys()
for x in _dicts(raw.get('detectors'), 500):
label, name = _s(x.get('label'), 64).upper(), _s(x.get('name'), 120)
# A custom detector may not shadow a built-in key: the platform refuses that too.
if _LABEL.match(label) and label not in builtin_keys and not any(d.label == label for d in ctx.detectors):
ctx.detectors.append(Detector(label, name or label.replace('_', ' ').title(), x.get('enabled') is not False))
ctx.disabledDetectors = {k.upper() for k in _strs(raw.get('disabledDetectors'))}
if isinstance(raw.get('policies'), list):
ctx.policiesKnown = True
for p in _dicts(raw.get('policies')):
pid = _s(p.get('id'), 64)
if _s(p.get('name'), 200):
ctx.policyNames.add(_s(p.get('name'), 200).lower())
if not pid or any(x.id == pid for x in ctx.allPolicies):
continue
ptype = _s(p.get('type')) or _type_of(p)
if ptype not in catalog.POLICY_TYPES:
continue
summary = PolicySummary(
id=pid, name=_s(p.get('name'), 200) or pid, type=ptype,
groupId=_s(p.get('groupId'), 64) or 'default',
action=_s(p.get('action')) or 'warn',
connectorIds=_strs(p.get('connectorIds')),
provider=_email_provider(p) if ptype == 'email' else '',
active=p.get('active') is not False,
controls={c for c in _strs(p.get('controls'), 200) if _CONTROL.match(c)},
)
if isinstance(p.get('controls'), list):
ctx.controlsKnown = True
if isinstance(p.get('blockedDomains'), list):
ctx.blocksKnown = True
summary.blockedDomains = {x.lower() for x in _strs(p.get('blockedDomains'), 500)}
summary.blockedApps = {x.lower() for x in _strs(p.get('blockedApplications'), 500)}
summary.webLists = _strs(p.get('webBlockListIds'), 100)
summary.appLists = _strs(p.get('appBlockListIds'), 100)
ctx.allPolicies.append(summary)
if summary.active:
ctx.policies.append(summary)
return ctx
def _email_provider(p: dict) -> str:
"""As the email form's emailProviderOf(): 'm365' only when saved so, otherwise the Gmail policy."""
if p.get('provider') == 'm365':
return 'm365'
ch = p.get('channels')
em = ch.get('email') if isinstance(ch, dict) else None
return 'm365' if isinstance(em, dict) and em.get('provider') == 'm365' else 'google'
def _type_of(p: dict) -> str:
"""A full policy row (as GET /api/admin/policies returns it) has no type field: derive it."""
ch = p.get('channels')
unknown = False
if isinstance(ch, dict):
for k, v in ch.items():
if isinstance(v, dict) and v.get('enabled') is True:
if k in catalog.CHANNEL_TYPES:
return k
unknown = True
# A channel the copilot does not know yet (a connector added to the platform later):
# leave the policy out rather than mistake it for an endpoint policy.
return '' if unknown else 'endpoint'
|