reachdiff 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reachdiff/__init__.py +0 -0
- reachdiff/__main__.py +3 -0
- reachdiff/apply.py +52 -0
- reachdiff/cli.py +88 -0
- reachdiff/collect/__init__.py +0 -0
- reachdiff/collect/live.py +463 -0
- reachdiff/collect/offline.py +32 -0
- reachdiff/config.py +135 -0
- reachdiff/diff.py +234 -0
- reachdiff/report.py +215 -0
- reachdiff/resolve.py +104 -0
- reachdiff/rules.py +197 -0
- reachdiff/scope.py +21 -0
- reachdiff/state.py +89 -0
- reachdiff/tfplan.py +352 -0
- reachdiff-0.9.0.dist-info/METADATA +372 -0
- reachdiff-0.9.0.dist-info/RECORD +21 -0
- reachdiff-0.9.0.dist-info/WHEEL +5 -0
- reachdiff-0.9.0.dist-info/entry_points.txt +2 -0
- reachdiff-0.9.0.dist-info/licenses/LICENSE +202 -0
- reachdiff-0.9.0.dist-info/top_level.txt +1 -0
reachdiff/config.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Load and validate reachdiff TOML. Unknown keys are errors, so a typo cannot disable a rule."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from reachdiff.resolve import LEVELS
|
|
10
|
+
|
|
11
|
+
BUILTIN_RULES = (
|
|
12
|
+
'broad_principal',
|
|
13
|
+
'broad_privilege',
|
|
14
|
+
'grant_resource_conflict',
|
|
15
|
+
'individual_owner',
|
|
16
|
+
'sensitive_access',
|
|
17
|
+
'unverified',
|
|
18
|
+
)
|
|
19
|
+
BUILTIN_DEFAULTS = {'grant_resource_conflict': 'BLOCK'} # every other built-in rule defaults to WARN
|
|
20
|
+
SEVERITIES = ('WARN', 'BLOCK')
|
|
21
|
+
PRINCIPAL_KINDS = ('user', 'service_principal', 'group', 'unknown')
|
|
22
|
+
DEFAULT_TAGS = ('pii*', 'sensitive*', 'class.*')
|
|
23
|
+
RULE_KEYS = ('id', 'severity', 'message', 'when', 'levels', 'objects', 'principal_kinds', 'sensitive', 'unless_via')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Rule:
|
|
28
|
+
id: str
|
|
29
|
+
severity: str
|
|
30
|
+
message: str
|
|
31
|
+
when: str = 'gain'
|
|
32
|
+
levels: frozenset[str] = frozenset()
|
|
33
|
+
objects: tuple[str, ...] = ()
|
|
34
|
+
principal_kinds: frozenset[str] = frozenset()
|
|
35
|
+
sensitive: bool | None = None
|
|
36
|
+
unless_via: frozenset[str] = frozenset()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def builtin_defaults() -> dict[str, str]:
|
|
40
|
+
return {rule: BUILTIN_DEFAULTS.get(rule, 'WARN') for rule in BUILTIN_RULES}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Config:
|
|
45
|
+
fail_on: str = 'block'
|
|
46
|
+
sensitive_tags: tuple[str, ...] = DEFAULT_TAGS
|
|
47
|
+
builtin: dict[str, str] = field(default_factory=builtin_defaults)
|
|
48
|
+
limit_objects: int = 500
|
|
49
|
+
limit_members: int = 5000
|
|
50
|
+
limit_deep_children: int = 1000
|
|
51
|
+
rules: tuple[Rule, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _keys(table: object, allowed: tuple[str, ...], where: str) -> dict:
|
|
55
|
+
if not isinstance(table, dict):
|
|
56
|
+
raise ValueError(f'{where} must be a table')
|
|
57
|
+
unknown = sorted(set(table) - set(allowed))
|
|
58
|
+
if unknown:
|
|
59
|
+
raise ValueError(f'unknown config key in {where}: {", ".join(unknown)}')
|
|
60
|
+
return table
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strings(value: object, where: str) -> tuple[str, ...]:
|
|
64
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
|
|
65
|
+
raise ValueError(f'{where} must be a list of nonempty strings')
|
|
66
|
+
return tuple(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _choice(value: object, choices: tuple[str, ...], where: str) -> str:
|
|
70
|
+
if value not in choices:
|
|
71
|
+
raise ValueError(f'{where} must be one of {", ".join(choices)}')
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rule(raw: object, index: int) -> Rule:
|
|
76
|
+
where = f'rule[{index}]'
|
|
77
|
+
raw = _keys(raw, RULE_KEYS, where)
|
|
78
|
+
for key in ('id', 'message'):
|
|
79
|
+
if not isinstance(raw.get(key), str) or not raw[key]:
|
|
80
|
+
raise ValueError(f'{where}.{key} is required')
|
|
81
|
+
sensitive = raw.get('sensitive')
|
|
82
|
+
if sensitive is not None and type(sensitive) is not bool:
|
|
83
|
+
raise ValueError(f'{where}.sensitive must be true or false')
|
|
84
|
+
levels = _strings(raw.get('levels', []), f'{where}.levels')
|
|
85
|
+
kinds = _strings(raw.get('principal_kinds', []), f'{where}.principal_kinds')
|
|
86
|
+
for value in levels:
|
|
87
|
+
_choice(value, LEVELS, f'{where}.levels')
|
|
88
|
+
for value in kinds:
|
|
89
|
+
_choice(value, PRINCIPAL_KINDS, f'{where}.principal_kinds')
|
|
90
|
+
return Rule(
|
|
91
|
+
raw['id'],
|
|
92
|
+
_choice(raw.get('severity'), SEVERITIES, f'{where}.severity'),
|
|
93
|
+
raw['message'],
|
|
94
|
+
_choice(raw.get('when', 'gain'), ('gain', 'lose', 'any'), f'{where}.when'),
|
|
95
|
+
frozenset(levels),
|
|
96
|
+
_strings(raw.get('objects', []), f'{where}.objects'),
|
|
97
|
+
frozenset(kinds),
|
|
98
|
+
sensitive,
|
|
99
|
+
frozenset(_strings(raw.get('unless_via', []), f'{where}.unless_via')),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_config(data: dict) -> Config:
|
|
104
|
+
_keys(data, ('fail_on', 'sensitive', 'builtin', 'limits', 'rule'), 'config')
|
|
105
|
+
fail_on = _choice(data.get('fail_on', 'block'), ('block', 'warn', 'never'), 'fail_on')
|
|
106
|
+
sensitive = _keys(data.get('sensitive', {}), ('tags',), '[sensitive]')
|
|
107
|
+
tags = _strings(sensitive.get('tags', list(DEFAULT_TAGS)), 'sensitive.tags')
|
|
108
|
+
builtin = builtin_defaults()
|
|
109
|
+
for name, value in _keys(data.get('builtin', {}), BUILTIN_RULES, '[builtin]').items():
|
|
110
|
+
allowed = SEVERITIES if name == 'unverified' else SEVERITIES + ('off',)
|
|
111
|
+
builtin[name] = _choice(value, allowed, f'builtin.{name}')
|
|
112
|
+
limits = _keys(data.get('limits', {}), ('objects', 'members', 'deep_children'), '[limits]')
|
|
113
|
+
values = {}
|
|
114
|
+
for key, default in (('objects', 500), ('members', 5000), ('deep_children', 1000)):
|
|
115
|
+
value = limits.get(key, default)
|
|
116
|
+
if type(value) is not int or value < 1:
|
|
117
|
+
raise ValueError(f'limits.{key} must be a positive integer')
|
|
118
|
+
values[key] = value
|
|
119
|
+
raw_rules = data.get('rule', [])
|
|
120
|
+
if not isinstance(raw_rules, list):
|
|
121
|
+
raise ValueError('rule must be an array of tables ([[rule]])')
|
|
122
|
+
rules = tuple(_rule(raw, i) for i, raw in enumerate(raw_rules))
|
|
123
|
+
ids = [r.id for r in rules]
|
|
124
|
+
if len(set(ids)) != len(ids) or set(ids) & set(BUILTIN_RULES):
|
|
125
|
+
raise ValueError('rule ids must be unique and must not reuse built-in rule names')
|
|
126
|
+
return Config(fail_on, tags, builtin, values['objects'], values['members'], values['deep_children'], rules)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_config(path: Path | None) -> Config:
|
|
130
|
+
if path is None:
|
|
131
|
+
return Config()
|
|
132
|
+
try:
|
|
133
|
+
return parse_config(tomllib.loads(path.read_text(encoding='utf-8')))
|
|
134
|
+
except tomllib.TOMLDecodeError as exc:
|
|
135
|
+
raise ValueError(f'invalid config TOML: {exc}') from None
|
reachdiff/diff.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Compare access before and after a plan, and shape the rows a reviewer sees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from reachdiff.resolve import Entry, Route, groups_of, resolve
|
|
9
|
+
from reachdiff.scope import changed_objects, with_ancestors
|
|
10
|
+
from reachdiff.state import BUILTIN_GROUPS, Gap, State, ancestors, kind_of
|
|
11
|
+
from reachdiff.tfplan import Plan
|
|
12
|
+
|
|
13
|
+
USAGE = frozenset({'USE_CATALOG', 'USE_SCHEMA', 'ALL_PRIVILEGES'})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MemberCount:
|
|
18
|
+
expanded: bool
|
|
19
|
+
total: int = 0
|
|
20
|
+
changed: int = 0 # members who newly gain (+) or lose (-) the entry
|
|
21
|
+
unchanged: int = 0 # members who already had it (+) or keep it another way (-)
|
|
22
|
+
names: tuple[str, ...] = ()
|
|
23
|
+
unread: bool = False # membership could not be read
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Change:
|
|
28
|
+
direction: str # '+' gained, '-' lost
|
|
29
|
+
principal: str
|
|
30
|
+
kind: str
|
|
31
|
+
level: str
|
|
32
|
+
securable: str
|
|
33
|
+
routes: tuple[Route, ...]
|
|
34
|
+
members: MemberCount | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AccessDiff:
|
|
39
|
+
rows: tuple[Change, ...] # what a reviewer sees: child rows collapse into parent rows
|
|
40
|
+
all_rows: tuple[Change, ...] # the same rows before collapsing; configured rules match these
|
|
41
|
+
gained: frozenset
|
|
42
|
+
lost: frozenset
|
|
43
|
+
route_changes: tuple
|
|
44
|
+
gaps: tuple[Gap, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Members = dict[str, frozenset[str]] # group -> transitive members, computed once per state
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _direct(state: State) -> dict[str, list[str]]:
|
|
51
|
+
children: dict[str, list[str]] = {}
|
|
52
|
+
for link in state.memberships:
|
|
53
|
+
children.setdefault(link.group, []).append(link.member)
|
|
54
|
+
return children
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _walk(children: dict[str, list[str]], group: str) -> set[str]:
|
|
58
|
+
found: set[str] = set()
|
|
59
|
+
queue = deque([group])
|
|
60
|
+
while queue:
|
|
61
|
+
for member in children.get(queue.popleft(), ()):
|
|
62
|
+
if member not in found and member != group:
|
|
63
|
+
found.add(member)
|
|
64
|
+
queue.append(member)
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def members_of(state: State, group: str) -> set[str]:
|
|
69
|
+
"""Transitive members through explicit memberships (built-in groups have none listed)."""
|
|
70
|
+
return _walk(_direct(state), group)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _member_map(state: State) -> Members:
|
|
74
|
+
children = _direct(state)
|
|
75
|
+
return {group: frozenset(_walk(children, group)) for group in children}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _touched(before: State, after: State, plan: Plan) -> tuple[set[str], set[str]]:
|
|
79
|
+
ids = {p.scim_id: p.name for s in (before, after) for p in s.principals.values() if p.scim_id}
|
|
80
|
+
ids.update({scim_id: p.name for scim_id, p in plan.identities.items()})
|
|
81
|
+
touched: set[str] = set()
|
|
82
|
+
member_groups: set[str] = set()
|
|
83
|
+
for resource in plan.changed_grants():
|
|
84
|
+
touched |= set(resource.before or {}) | set(resource.after or {})
|
|
85
|
+
for resource in plan.changed_owners():
|
|
86
|
+
touched |= {x for x in (resource.before, resource.after) if x}
|
|
87
|
+
for edge in plan.changed_memberships():
|
|
88
|
+
for scim_id, bucket in ((edge.group_id, member_groups), (edge.member_id, touched)):
|
|
89
|
+
if scim_id in ids:
|
|
90
|
+
bucket.add(ids[scim_id])
|
|
91
|
+
return touched | member_groups, member_groups
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _count(
|
|
95
|
+
state: State, members_map: Members, group: str, entry: Entry, entries: frozenset, expanded: set[str]
|
|
96
|
+
) -> MemberCount:
|
|
97
|
+
if group in state.unread_groups:
|
|
98
|
+
return MemberCount(False, unread=True)
|
|
99
|
+
if group in BUILTIN_GROUPS or group not in expanded:
|
|
100
|
+
return MemberCount(False)
|
|
101
|
+
members = sorted(m for m in members_map.get(group, ()) if state.kind(m) != 'group')
|
|
102
|
+
changed = tuple(m for m in members if (m,) + entry[1:] in entries)
|
|
103
|
+
return MemberCount(True, len(members), len(changed), len(members) - len(changed), changed)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _explained(routes: tuple[Route, ...], group_rows: dict, level: str, securable: str) -> bool:
|
|
107
|
+
"""Hidden only when every route passes through a group shown in its own row for the same level and object."""
|
|
108
|
+
shown = {
|
|
109
|
+
group for group, row_level, row_securable in group_rows if (row_level, row_securable) == (level, securable)
|
|
110
|
+
}
|
|
111
|
+
return all(shown & set(route.main.chain) for route in routes)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rows(
|
|
115
|
+
direction: str, entries: frozenset, state: State, members_map: Members, routes: dict, expanded: set[str]
|
|
116
|
+
) -> list[Change]:
|
|
117
|
+
group_rows = {}
|
|
118
|
+
for entry in sorted(entries):
|
|
119
|
+
principal, level, securable = entry
|
|
120
|
+
if state.kind(principal) == 'group':
|
|
121
|
+
group_rows[entry] = Change(
|
|
122
|
+
direction,
|
|
123
|
+
principal,
|
|
124
|
+
'group',
|
|
125
|
+
level,
|
|
126
|
+
securable,
|
|
127
|
+
routes[entry],
|
|
128
|
+
_count(state, members_map, principal, entry, entries, expanded),
|
|
129
|
+
)
|
|
130
|
+
rows = list(group_rows.values())
|
|
131
|
+
for entry in sorted(entries):
|
|
132
|
+
principal, level, securable = entry
|
|
133
|
+
if entry not in group_rows and not _explained(routes[entry], group_rows, level, securable):
|
|
134
|
+
rows.append(Change(direction, principal, state.kind(principal), level, securable, routes[entry]))
|
|
135
|
+
return rows
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sorted(rows: list[Change]) -> tuple[Change, ...]:
|
|
139
|
+
return tuple(sorted(rows, key=lambda r: (r.direction, r.principal, r.level, r.securable)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _collapse(rows: list[Change]) -> tuple[Change, ...]:
|
|
143
|
+
keys = {(r.direction, r.principal, r.level, r.securable) for r in rows}
|
|
144
|
+
return _sorted(
|
|
145
|
+
[r for r in rows if not any((r.direction, r.principal, r.level, a) in keys for a in ancestors(r.securable)[1:])]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _usage(state: State, securable: str) -> dict[str, frozenset[str]]:
|
|
150
|
+
held: dict[str, frozenset[str]] = {}
|
|
151
|
+
for grant in state.grants:
|
|
152
|
+
if grant.securable == securable:
|
|
153
|
+
held[grant.principal] = held.get(grant.principal, frozenset()) | (grant.privileges & USAGE)
|
|
154
|
+
return held
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _latent(before: State, after: State, plan: Plan) -> list[Gap]:
|
|
158
|
+
"""Usage privileges gained or lost on a catalog or schema can change grants below it that were not read.
|
|
159
|
+
|
|
160
|
+
Compared on the states, like the rows: a resource's plan `before` misses grants it replaces, such as the live
|
|
161
|
+
grants on an object a `databricks_grants` create takes over.
|
|
162
|
+
"""
|
|
163
|
+
gaps = []
|
|
164
|
+
for securable in sorted({r.securable for r in plan.changed_grants() if kind_of(r.securable) != 'table'}):
|
|
165
|
+
old, new = _usage(before, securable), _usage(after, securable)
|
|
166
|
+
for principal in sorted(set(old) | set(new)):
|
|
167
|
+
had, has = old.get(principal, frozenset()), new.get(principal, frozenset())
|
|
168
|
+
if gained := has - had:
|
|
169
|
+
gaps.append(
|
|
170
|
+
Gap(
|
|
171
|
+
'latent_grants',
|
|
172
|
+
f'{principal} gains {", ".join(sorted(gained))} on '
|
|
173
|
+
f'{securable}; this may activate existing table-level grants '
|
|
174
|
+
'that were not enumerated (use --deep)',
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
if lost := had - has:
|
|
178
|
+
gaps.append(
|
|
179
|
+
Gap(
|
|
180
|
+
'deactivated_grants',
|
|
181
|
+
f'{principal} loses {", ".join(sorted(lost))} on '
|
|
182
|
+
f'{securable}; grants below it that relied on this may stop working '
|
|
183
|
+
'and were not enumerated (use --deep)',
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return gaps
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def compute_diff(before: State, after: State, plan: Plan, *, members_limit: int, deep: bool) -> AccessDiff:
|
|
190
|
+
gaps: list[Gap] = []
|
|
191
|
+
touched, member_groups = _touched(before, after, plan)
|
|
192
|
+
holders = {h for g in member_groups for s in (before, after) for h in groups_of(s, g)}
|
|
193
|
+
objects = set(changed_objects(plan))
|
|
194
|
+
for s in (before, after):
|
|
195
|
+
objects |= {g.securable for g in s.grants if g.principal in holders}
|
|
196
|
+
objects |= {x.name for x in s.securables.values() if x.owner in holders}
|
|
197
|
+
if deep:
|
|
198
|
+
objects |= set(s.securables)
|
|
199
|
+
objects = with_ancestors(objects)
|
|
200
|
+
if deep:
|
|
201
|
+
# A usage change can activate or deactivate anyone's grants below it, so evaluate every holder in scope.
|
|
202
|
+
for s in (before, after):
|
|
203
|
+
touched |= {g.principal for g in s.grants if g.securable in objects}
|
|
204
|
+
touched |= {x.owner for x in s.securables.values() if x.owner and x.name in objects}
|
|
205
|
+
groups = {p for p in touched if 'group' in (before.kind(p), after.kind(p))}
|
|
206
|
+
members_before, members_after = _member_map(before), _member_map(after)
|
|
207
|
+
|
|
208
|
+
def members(group: str) -> frozenset[str]:
|
|
209
|
+
return members_before.get(group, frozenset()) | members_after.get(group, frozenset())
|
|
210
|
+
|
|
211
|
+
subjects = set(touched)
|
|
212
|
+
# Decided once: member counts are shown only for groups whose members were all evaluated.
|
|
213
|
+
expanded: set[str] = set()
|
|
214
|
+
skipped: list[str] = []
|
|
215
|
+
for group in sorted(groups - set(BUILTIN_GROUPS)):
|
|
216
|
+
if len(subjects | members(group)) > members_limit:
|
|
217
|
+
skipped.append(group)
|
|
218
|
+
continue
|
|
219
|
+
subjects |= members(group)
|
|
220
|
+
expanded.add(group)
|
|
221
|
+
# A group nested in an expanded group comes along when every one of its members was evaluated.
|
|
222
|
+
nested = {m for g in expanded for m in members(g) if 'group' in (before.kind(m), after.kind(m))} - set(skipped)
|
|
223
|
+
expanded |= {g for g in nested if members(g) <= subjects}
|
|
224
|
+
if skipped:
|
|
225
|
+
gaps.append(Gap('limit_reached', f'members of {", ".join(skipped)} were not expanded beyond {members_limit}'))
|
|
226
|
+
old, new = resolve(before, subjects, objects), resolve(after, subjects, objects)
|
|
227
|
+
gained, lost = frozenset(new.keys() - old.keys()), frozenset(old.keys() - new.keys())
|
|
228
|
+
route_changes = tuple((k, old[k], new[k]) for k in sorted(old.keys() & new.keys()) if old[k] != new[k])
|
|
229
|
+
rows = _rows('+', gained, after, members_after, new, expanded) + _rows(
|
|
230
|
+
'-', lost, before, members_before, old, expanded
|
|
231
|
+
)
|
|
232
|
+
if not deep:
|
|
233
|
+
gaps += _latent(before, after, plan)
|
|
234
|
+
return AccessDiff(_collapse(rows), _sorted(rows), gained, lost, route_changes, tuple(sorted(set(gaps))))
|
reachdiff/report.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Report dict and renderers. Reports name principals and objects: treat them as sensitive metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
|
|
8
|
+
from reachdiff.diff import AccessDiff, Change
|
|
9
|
+
from reachdiff.resolve import Route
|
|
10
|
+
from reachdiff.scope import has_access_changes
|
|
11
|
+
from reachdiff.state import BUILTIN_GROUPS, State, kind_of
|
|
12
|
+
from reachdiff.tfplan import Plan
|
|
13
|
+
|
|
14
|
+
STANDING_LIMITATIONS = (
|
|
15
|
+
'metastore and workspace admin powers',
|
|
16
|
+
'ABAC policies, row filters and column masks',
|
|
17
|
+
'workspace-level ACLs',
|
|
18
|
+
'account and identity-provider membership completeness',
|
|
19
|
+
'runtime behavior',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def route_text(route: Route) -> str:
|
|
24
|
+
main = f'{route.main.privilege} on {route.main.securable}'
|
|
25
|
+
if route.main.chain:
|
|
26
|
+
main += f' (via {" → ".join(route.main.chain)})'
|
|
27
|
+
parts, shown = [main], {(route.main.privilege, route.main.securable)}
|
|
28
|
+
for hop in route.prerequisites: # ownership can meet several requirements; show each hop once
|
|
29
|
+
if (hop.privilege, hop.securable) not in shown:
|
|
30
|
+
shown.add((hop.privilege, hop.securable))
|
|
31
|
+
parts.append(f'{hop.privilege} on {hop.securable}')
|
|
32
|
+
return ' + '.join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _row(change: Change) -> dict:
|
|
36
|
+
members = None
|
|
37
|
+
if change.members is not None:
|
|
38
|
+
members = {**asdict(change.members), 'names': list(change.members.names)}
|
|
39
|
+
return {
|
|
40
|
+
'direction': change.direction,
|
|
41
|
+
'principal': change.principal,
|
|
42
|
+
'kind': change.kind,
|
|
43
|
+
'level': change.level,
|
|
44
|
+
'securable': change.securable,
|
|
45
|
+
'via': [route_text(r) for r in change.routes],
|
|
46
|
+
'members': members,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_report(plan: Plan, before: State, after: State, diff: AccessDiff, findings: tuple, status: str) -> dict:
|
|
51
|
+
gaps = sorted(set(plan.gaps) | set(before.gaps) | set(after.gaps) | set(diff.gaps))
|
|
52
|
+
return {
|
|
53
|
+
'schema_version': 1,
|
|
54
|
+
'tool': 'reachdiff',
|
|
55
|
+
'status': status,
|
|
56
|
+
'mode': before.source,
|
|
57
|
+
'synthetic': plan.synthetic,
|
|
58
|
+
'access_changes': has_access_changes(plan) or bool(plan.not_evaluated),
|
|
59
|
+
'summary': {
|
|
60
|
+
'gained': sum(r.direction == '+' for r in diff.rows),
|
|
61
|
+
'lost': sum(r.direction == '-' for r in diff.rows),
|
|
62
|
+
'findings': len(findings),
|
|
63
|
+
},
|
|
64
|
+
'changes': [_row(r) for r in diff.rows],
|
|
65
|
+
'findings': [asdict(f) for f in findings],
|
|
66
|
+
'not_evaluated': list(plan.not_evaluated),
|
|
67
|
+
'gaps': [asdict(g) for g in gaps],
|
|
68
|
+
'route_changes': [
|
|
69
|
+
{
|
|
70
|
+
'principal': k[0],
|
|
71
|
+
'level': k[1],
|
|
72
|
+
'securable': k[2],
|
|
73
|
+
'before': [route_text(r) for r in old],
|
|
74
|
+
'after': [route_text(r) for r in new],
|
|
75
|
+
}
|
|
76
|
+
for k, old, new in diff.route_changes
|
|
77
|
+
],
|
|
78
|
+
'notes': sorted(set(before.notes) | set(after.notes)),
|
|
79
|
+
'standing_limitations': list(STANDING_LIMITATIONS),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _who(row: dict) -> str:
|
|
84
|
+
name, members = row['principal'], row['members']
|
|
85
|
+
if name in BUILTIN_GROUPS:
|
|
86
|
+
return f'{name} ({BUILTIN_GROUPS[name]})'
|
|
87
|
+
if members is None:
|
|
88
|
+
return name
|
|
89
|
+
if members.get('unread'):
|
|
90
|
+
return f'{name} (members not read)'
|
|
91
|
+
if not members['expanded']:
|
|
92
|
+
return f'{name} (members not expanded)'
|
|
93
|
+
total = f'{members["total"]} member' + ('' if members['total'] == 1 else 's')
|
|
94
|
+
if row['direction'] == '+':
|
|
95
|
+
return f'{name} ({total}, {members["changed"]} new, {members["unchanged"]} already had it)'
|
|
96
|
+
return f'{name} ({total}, {members["changed"]} lose it, {members["unchanged"]} keep it another way)'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _where(securable: str) -> str:
|
|
100
|
+
return securable + {'catalog': ' (all schemas)', 'schema': ' (all tables)', 'table': ''}[kind_of(securable)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
INLINE = (('\\', '\\\\'), ('\r', ' '), ('\n', ' '), ('<', '<'), ('>', '>'), ('[', '\\['), (']', '\\]'))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _escape(text: str, pairs) -> str:
|
|
107
|
+
for old, new in pairs:
|
|
108
|
+
text = text.replace(old, new)
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _inline(text: str) -> str:
|
|
113
|
+
"""Untrusted text on one Markdown line: no line breaks, HTML tags, code spans or links."""
|
|
114
|
+
return _escape(text, INLINE + (('`', "'"),))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cell(text: str) -> str:
|
|
118
|
+
return _escape(text, INLINE + (('|', '\\|'),))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _no_changes(report: dict) -> str:
|
|
122
|
+
return 'No evaluated access changes; see findings.' if report['access_changes'] else 'No access changes.'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _details(report: dict) -> list[str]:
|
|
126
|
+
lines = [
|
|
127
|
+
'- ' + _inline(f'{r["principal"]} {r["level"]} {r["securable"]}: {", ".join(r["members"]["names"])}')
|
|
128
|
+
for r in report['changes']
|
|
129
|
+
if r['members'] and r['members']['names']
|
|
130
|
+
]
|
|
131
|
+
lines += [
|
|
132
|
+
'- route changed: ' + _inline(f'{c["principal"]} {c["level"]} {c["securable"]}')
|
|
133
|
+
for c in report['route_changes']
|
|
134
|
+
]
|
|
135
|
+
lines += [f'- gap `{_inline(g["category"])}`: {_inline(g["detail"])}' for g in report['gaps']]
|
|
136
|
+
lines += [f'- note: {_inline(n)}' for n in report['notes']]
|
|
137
|
+
return lines or ['- nothing further']
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_markdown(report: dict) -> str:
|
|
141
|
+
label = 'SYNTHETIC FIXTURE · ' if report['synthetic'] else ''
|
|
142
|
+
summary = report['summary']
|
|
143
|
+
lines = [
|
|
144
|
+
f'## reachdiff: {report["status"]}',
|
|
145
|
+
'',
|
|
146
|
+
f'_{label}{report["mode"]} mode_',
|
|
147
|
+
'',
|
|
148
|
+
f'**{summary["gained"]} gained · {summary["lost"]} lost · {summary["findings"]} findings**',
|
|
149
|
+
'',
|
|
150
|
+
]
|
|
151
|
+
if report['changes']:
|
|
152
|
+
lines += ['| | Principal | Access | On | Via |', '|---|---|---|---|---|']
|
|
153
|
+
lines += [
|
|
154
|
+
f'| {r["direction"]} | {_cell(_who(r))} | {r["level"]} | {_cell(_where(r["securable"]))} | '
|
|
155
|
+
f'{_cell("; ".join(r["via"]))} |'
|
|
156
|
+
for r in report['changes']
|
|
157
|
+
]
|
|
158
|
+
else:
|
|
159
|
+
lines.append(_no_changes(report))
|
|
160
|
+
if report['findings']:
|
|
161
|
+
lines += ['', '### Findings', '']
|
|
162
|
+
lines += [
|
|
163
|
+
f'- **{f["severity"]}** `{_inline(f["rule"])}` {_inline(f["subject"])}: {_inline(f["message"])}'
|
|
164
|
+
for f in report['findings']
|
|
165
|
+
]
|
|
166
|
+
if report['not_evaluated']:
|
|
167
|
+
lines += ['', f'### Not evaluated ({len(report["not_evaluated"])})', '']
|
|
168
|
+
lines += [f'- {_inline(item)}' for item in report['not_evaluated']]
|
|
169
|
+
lines += ['', '<details><summary>Details</summary>', ''] + _details(report)
|
|
170
|
+
lines += [
|
|
171
|
+
'',
|
|
172
|
+
'</details>',
|
|
173
|
+
'',
|
|
174
|
+
f'<sub>Not evaluated in any run: {"; ".join(report["standing_limitations"])}.</sub>',
|
|
175
|
+
]
|
|
176
|
+
return '\n'.join(lines) + '\n'
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render_text(report: dict) -> str:
|
|
180
|
+
label = ', SYNTHETIC FIXTURE' if report['synthetic'] else ''
|
|
181
|
+
summary = report['summary']
|
|
182
|
+
lines = [
|
|
183
|
+
f'reachdiff: {report["status"]} ({report["mode"]} mode{label})',
|
|
184
|
+
f'{summary["gained"]} gained, {summary["lost"]} lost, {summary["findings"]} findings',
|
|
185
|
+
'',
|
|
186
|
+
]
|
|
187
|
+
for r in report['changes']:
|
|
188
|
+
lines.append(f'{r["direction"]} {_who(r)} {r["level"]} {_where(r["securable"])}')
|
|
189
|
+
lines += [f' via {v}' for v in r['via']]
|
|
190
|
+
if not report['changes']:
|
|
191
|
+
lines.append(_no_changes(report))
|
|
192
|
+
if report['findings']:
|
|
193
|
+
lines += ['', 'Findings:'] + [
|
|
194
|
+
f' {f["severity"]} {f["rule"]} {f["subject"]}: {f["message"]}' for f in report['findings']
|
|
195
|
+
]
|
|
196
|
+
if report['not_evaluated']:
|
|
197
|
+
lines += ['', 'Not evaluated:'] + [f' {item}' for item in report['not_evaluated']]
|
|
198
|
+
if report['gaps']:
|
|
199
|
+
lines += ['', 'Gaps:'] + [f' {g["category"]}: {g["detail"]}' for g in report['gaps']]
|
|
200
|
+
if report['route_changes']:
|
|
201
|
+
lines += ['', 'Route changes:'] + [
|
|
202
|
+
f' {c["principal"]} {c["level"]} {c["securable"]}' for c in report['route_changes']
|
|
203
|
+
]
|
|
204
|
+
if report['notes']:
|
|
205
|
+
lines += ['', 'Notes:'] + [f' {n}' for n in report['notes']]
|
|
206
|
+
lines += ['', 'Not evaluated in any run: ' + '; '.join(report['standing_limitations']) + '.']
|
|
207
|
+
# Every entry is one line: a line break inside a name must not forge report lines.
|
|
208
|
+
return '\n'.join(line.replace('\r', ' ').replace('\n', ' ') for line in lines) + '\n'
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_json(report: dict) -> str:
|
|
212
|
+
return json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False) + '\n'
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
RENDERERS = {'text': render_text, 'json': render_json, 'md': render_markdown}
|
reachdiff/resolve.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Effective access per principal and object, with evidence routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from reachdiff.state import Grant, State, ancestors
|
|
10
|
+
|
|
11
|
+
LEVELS = ('READ', 'WRITE', 'MANAGE')
|
|
12
|
+
Entry = tuple[str, str, str] # (principal, level, securable)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, order=True)
|
|
16
|
+
class Hop:
|
|
17
|
+
chain: tuple[str, ...] # groups from the subject to the holder; () when the subject holds it
|
|
18
|
+
privilege: str
|
|
19
|
+
securable: str
|
|
20
|
+
evidence: str = field(compare=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, order=True)
|
|
24
|
+
class Route:
|
|
25
|
+
main: Hop
|
|
26
|
+
prerequisites: tuple[Hop, ...] = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def groups_of(state: State, subject: str) -> dict[str, tuple[str, ...]]:
|
|
30
|
+
"""The subject, every group it belongs to (shortest chain), and implicit `account users`."""
|
|
31
|
+
chains: dict[str, tuple[str, ...]] = {subject: ()}
|
|
32
|
+
if subject != 'account users':
|
|
33
|
+
chains['account users'] = ('account users',)
|
|
34
|
+
links = sorted(state.memberships, key=lambda m: (m.member, m.group))
|
|
35
|
+
queue = deque([subject])
|
|
36
|
+
while queue:
|
|
37
|
+
current = queue.popleft()
|
|
38
|
+
for link in links:
|
|
39
|
+
if link.member == current and link.group not in chains:
|
|
40
|
+
chains[link.group] = chains[current] + (link.group,)
|
|
41
|
+
queue.append(link.group)
|
|
42
|
+
return chains
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _by_securable(state: State) -> dict[str, list[Grant]]:
|
|
46
|
+
index: dict[str, list[Grant]] = {}
|
|
47
|
+
for grant in state.grants:
|
|
48
|
+
index.setdefault(grant.securable, []).append(grant)
|
|
49
|
+
return index
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _hops(grants: dict[str, list[Grant]], chains: dict, scope: tuple[str, ...], privileges: set[str]) -> list[Hop]:
|
|
53
|
+
found = []
|
|
54
|
+
for securable in scope:
|
|
55
|
+
for grant in grants.get(securable, ()):
|
|
56
|
+
if grant.principal in chains:
|
|
57
|
+
for privilege in sorted(grant.privileges & privileges):
|
|
58
|
+
found.append(Hop(chains[grant.principal], privilege, grant.securable, grant.evidence))
|
|
59
|
+
return sorted(found)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ownership(state: State, chains: dict, scope: tuple[str, ...]) -> list[Hop]:
|
|
63
|
+
found = []
|
|
64
|
+
for name in scope:
|
|
65
|
+
securable = state.securables.get(name)
|
|
66
|
+
if securable and securable.owner in chains:
|
|
67
|
+
found.append(Hop(chains[securable.owner], 'OWNER', name, f'owner of {name}'))
|
|
68
|
+
return sorted(found)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _levels(state: State, grants: dict[str, list[Grant]], chains: dict, obj: str) -> dict[str, list[Route]]:
|
|
72
|
+
chain = ancestors(obj)
|
|
73
|
+
catalog = chain[-1]
|
|
74
|
+
schema_scope = chain[-2:] # (schema, catalog) for tables and schemas; (catalog,) for catalogs
|
|
75
|
+
# Owners hold all privileges on the owned object and its children (Databricks docs), and MANAGE.
|
|
76
|
+
owned = _ownership(state, chains, chain)
|
|
77
|
+
|
|
78
|
+
def held(scope: tuple[str, ...], privileges: set[str]) -> list[Hop]:
|
|
79
|
+
return sorted(_hops(grants, chains, scope, privileges) + [h for h in owned if h.securable in scope])
|
|
80
|
+
|
|
81
|
+
use_catalog = held((catalog,), {'USE_CATALOG', 'ALL_PRIVILEGES'})
|
|
82
|
+
use_schema = held(schema_scope, {'USE_SCHEMA', 'ALL_PRIVILEGES'})
|
|
83
|
+
select = held(chain, {'SELECT', 'ALL_PRIVILEGES'})
|
|
84
|
+
modify = held(chain, {'MODIFY', 'ALL_PRIVILEGES'})
|
|
85
|
+
manage = held(chain, {'MANAGE'})
|
|
86
|
+
levels = {'READ': [], 'WRITE': [], 'MANAGE': [Route(h) for h in manage]}
|
|
87
|
+
if use_catalog and use_schema:
|
|
88
|
+
usage = (use_catalog[0], use_schema[0])
|
|
89
|
+
levels['READ'] = [Route(h, usage) for h in select]
|
|
90
|
+
if select:
|
|
91
|
+
levels['WRITE'] = [Route(h, usage + (select[0],)) for h in modify]
|
|
92
|
+
return levels
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve(state: State, subjects: Iterable[str], objects: Iterable[str]) -> dict[Entry, tuple[Route, ...]]:
|
|
96
|
+
result: dict[Entry, tuple[Route, ...]] = {}
|
|
97
|
+
grants = _by_securable(state)
|
|
98
|
+
for subject in sorted(set(subjects)):
|
|
99
|
+
chains = groups_of(state, subject)
|
|
100
|
+
for obj in sorted(set(objects)):
|
|
101
|
+
for level, routes in _levels(state, grants, chains, obj).items():
|
|
102
|
+
if routes:
|
|
103
|
+
result[(subject, level, obj)] = tuple(sorted(set(routes)))
|
|
104
|
+
return result
|