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/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', ' '), ('<', '&lt;'), ('>', '&gt;'), ('[', '\\['), (']', '\\]'))
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