bhshell 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {bhshell-0.1.0/bhshell.egg-info → bhshell-0.1.1}/PKG-INFO +8 -3
- {bhshell-0.1.0 → bhshell-0.1.1}/README.md +7 -2
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/__init__.py +1 -1
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/client.py +6 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/errors.py +2 -2
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/models/entities.py +25 -4
- bhshell-0.1.1/bhshell/models/results.py +180 -0
- bhshell-0.1.1/bhshell/queries/containers.py +437 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/cypher.py +78 -1
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/edges.py +10 -2
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/hunt.py +2 -2
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/service.py +59 -6
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/export.py +5 -2
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/views.py +11 -7
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/entities.py +48 -5
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/paths.py +4 -3
- {bhshell-0.1.0 → bhshell-0.1.1/bhshell.egg-info}/PKG-INFO +8 -3
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/SOURCES.txt +2 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/docs/USAGE.md +113 -8
- bhshell-0.1.1/tests/test_containers.py +307 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_shell.py +9 -0
- bhshell-0.1.0/bhshell/models/results.py +0 -83
- {bhshell-0.1.0 → bhshell-0.1.1}/LICENSE +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/MANIFEST.in +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/__main__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/cli.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/auth.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/config.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/asrep-roastable-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/dcsync-principals.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/domain-admins-members.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/domain-trusts.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/kerberoastable-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/owned-objects.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/owned-to-tier-zero.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/sid-history.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/tier-zero-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/unconstrained-delegation.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/models/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/domains.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/library.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/marking.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/paths.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/search.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/graph.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/layout.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/png.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/styles.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/svg.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/app.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/base.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/connection.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/cypher.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/domains.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/hunt.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/library.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/marking.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/search.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/dependency_links.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/entry_points.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/requires.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/top_level.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/pyproject.toml +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/setup.cfg +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/conftest.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_analysis.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_client.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_disabled_paths.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_graph.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_hunt.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_png.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_search.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_service.py +0 -0
- {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_svg.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bhshell
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Interactive shell for BloodHound
|
|
5
5
|
Author-email: kr0no <kr0no@ka0labs.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,8 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
# bhshell
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
[
|
|
39
|
+
[](https://pypi.org/project/bhshell/)
|
|
40
40
|
[](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
|
|
41
41
|
[](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
|
|
42
42
|
|
|
@@ -70,6 +70,10 @@ $ bhshell --url http://127.0.0.1:8080
|
|
|
70
70
|
|
|
71
71
|
[bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
|
|
72
72
|
|
|
73
|
+
[bh: shell] > ou corp.local -t
|
|
74
|
+
|
|
75
|
+
[bh: shell] > computer WEB01 -G
|
|
76
|
+
|
|
73
77
|
[bh: shell] > path --owned -g
|
|
74
78
|
```
|
|
75
79
|
|
|
@@ -80,6 +84,7 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
|
|
|
80
84
|
| Command | Shows |
|
|
81
85
|
|---|---|
|
|
82
86
|
| `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
|
|
87
|
+
| `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
|
|
83
88
|
| `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
|
|
84
89
|
| `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
|
|
85
90
|
| `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bhshell
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[
|
|
4
|
+
[](https://pypi.org/project/bhshell/)
|
|
5
5
|
[](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
|
|
6
6
|
[](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
|
|
7
7
|
|
|
@@ -35,6 +35,10 @@ $ bhshell --url http://127.0.0.1:8080
|
|
|
35
35
|
|
|
36
36
|
[bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
|
|
37
37
|
|
|
38
|
+
[bh: shell] > ou corp.local -t
|
|
39
|
+
|
|
40
|
+
[bh: shell] > computer WEB01 -G
|
|
41
|
+
|
|
38
42
|
[bh: shell] > path --owned -g
|
|
39
43
|
```
|
|
40
44
|
|
|
@@ -45,6 +49,7 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
|
|
|
45
49
|
| Command | Shows |
|
|
46
50
|
|---|---|
|
|
47
51
|
| `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
|
|
52
|
+
| `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
|
|
48
53
|
| `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
|
|
49
54
|
| `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
|
|
50
55
|
| `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
|
|
@@ -314,6 +314,12 @@ class BHClient:
|
|
|
314
314
|
data = resp.get("data") or {}
|
|
315
315
|
return data.get("props") or data
|
|
316
316
|
|
|
317
|
+
def entity_counts(self, kind: str, objectid: str) -> dict[str, int]:
|
|
318
|
+
"""Related-object counts the entity endpoint reports (`users`, `computers`, `gpos`, ...)."""
|
|
319
|
+
resp = self.get(f"api/v2/{_collection(kind)}/{objectid}", counts="true") or {}
|
|
320
|
+
data = resp.get("data") or {}
|
|
321
|
+
return {k: v for k, v in data.items() if isinstance(v, int) and not isinstance(v, bool)}
|
|
322
|
+
|
|
317
323
|
def entity_related(
|
|
318
324
|
self, kind: str, objectid: str, relation: str, *, limit: int | None = None
|
|
319
325
|
) -> list[dict[str, Any]]:
|
|
@@ -67,7 +67,7 @@ class UnsupportedFeature(BHError):
|
|
|
67
67
|
class AmbiguousMatchError(BHError):
|
|
68
68
|
hint = "Qualify the name: 'jdoe@corp.local', 'CORP\\jdoe', '--domain CORP.LOCAL' or use the SID."
|
|
69
69
|
|
|
70
|
-
def __init__(self, query: str, candidates: Sequence[Node]) -> None:
|
|
71
|
-
super().__init__(f"'{query}' matches {len(candidates)} objects")
|
|
70
|
+
def __init__(self, query: str, candidates: Sequence[Node], *, hint: str = "") -> None:
|
|
71
|
+
super().__init__(f"'{query}' matches {len(candidates)} objects", hint=hint)
|
|
72
72
|
self.query = query
|
|
73
73
|
self.candidates = list(candidates)
|
|
@@ -318,16 +318,21 @@ class ADObject:
|
|
|
318
318
|
|
|
319
319
|
@staticmethod
|
|
320
320
|
def wrap(node: Node) -> ADObject:
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
kinds = {"User": User, "Computer": Computer, "Group": Group,
|
|
322
|
+
"OU": DirectoryContainer, "Container": DirectoryContainer, "Domain": DirectoryContainer}
|
|
323
|
+
return kinds.get(node.kind, ADObject)(node)
|
|
323
324
|
|
|
324
325
|
def _common_head(self) -> list[SummaryField]:
|
|
325
|
-
|
|
326
|
+
oid = "Object ID (GUID)" if self.node.kind in ("OU", "Container", "GPO") else "Object ID (SID)"
|
|
327
|
+
fields = [
|
|
326
328
|
SummaryField("Name", self.node.name),
|
|
327
329
|
SummaryField("Type", self.node.kind),
|
|
328
|
-
SummaryField(
|
|
330
|
+
SummaryField(oid, self.node.objectid),
|
|
329
331
|
SummaryField("Domain", self.node.domain),
|
|
330
332
|
]
|
|
333
|
+
if self.props.get("distinguishedname"):
|
|
334
|
+
fields.append(SummaryField("Distinguished Name", self.props["distinguishedname"]))
|
|
335
|
+
return fields
|
|
331
336
|
|
|
332
337
|
def _common_tail(self) -> list[SummaryField]:
|
|
333
338
|
return [
|
|
@@ -400,3 +405,19 @@ class Group(ADObject):
|
|
|
400
405
|
SummaryField("AdminCount", admincount, "bool", _risk_if(admincount, "medium")),
|
|
401
406
|
*self._common_tail(),
|
|
402
407
|
]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class DirectoryContainer(ADObject):
|
|
411
|
+
"""An OU, a container or a domain: the nodes of the directory tree."""
|
|
412
|
+
|
|
413
|
+
def summary(self) -> list[SummaryField]:
|
|
414
|
+
p = self.props
|
|
415
|
+
fields = self._common_head()
|
|
416
|
+
if self.node.kind == "Domain":
|
|
417
|
+
fields.append(SummaryField("Functional Level", p.get("functionallevel") or ""))
|
|
418
|
+
if self.node.kind == "OU":
|
|
419
|
+
blocks = opt_bool(p, "blocksinheritance")
|
|
420
|
+
fields.append(SummaryField("Blocks GPO Inheritance", blocks, "bool", _risk_if(blocks, "info")))
|
|
421
|
+
if p.get("whencreated"):
|
|
422
|
+
fields.append(SummaryField("Created", p["whencreated"], "date"))
|
|
423
|
+
return [*fields, *self._common_tail()]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Result containers returned by the query layer and consumed by renderers/exporters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from bhshell.models.entities import ADObject, Node, SummaryField
|
|
9
|
+
|
|
10
|
+
Backend = Literal["cypher", "rest"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Row:
|
|
14
|
+
"""A view row (a dataclass): `as_dict` is what is exported, `as_display` what the table shows."""
|
|
15
|
+
|
|
16
|
+
def as_dict(self) -> dict[str, Any]:
|
|
17
|
+
return asdict(self)
|
|
18
|
+
|
|
19
|
+
def as_display(self) -> dict[str, Any]:
|
|
20
|
+
return self.as_dict()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RelationshipRow(Row):
|
|
25
|
+
"""One relationship in a view.
|
|
26
|
+
|
|
27
|
+
Not every view fills every field; `Column` definitions on the
|
|
28
|
+
owning `ViewResult` select which ones are displayed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
principal: str = ""
|
|
32
|
+
principal_kind: str = ""
|
|
33
|
+
edge: str = ""
|
|
34
|
+
target: str = ""
|
|
35
|
+
target_kind: str = ""
|
|
36
|
+
via: str = ""
|
|
37
|
+
depth: int | None = None
|
|
38
|
+
tier_zero: bool = False
|
|
39
|
+
owned: bool = False
|
|
40
|
+
objectid: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _with_enabled(total: int, enabled: int) -> str:
|
|
44
|
+
return f"{total} ({enabled})" if total else "0"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ContainerRow(Row):
|
|
49
|
+
"""One OU or container of a `tree` view; the counts cover everything below it."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
kind: str
|
|
53
|
+
depth: int
|
|
54
|
+
# The name indented under its parent, for display.
|
|
55
|
+
label: str = ""
|
|
56
|
+
users: int | None = None
|
|
57
|
+
enabled_users: int | None = None
|
|
58
|
+
computers: int | None = None
|
|
59
|
+
enabled_computers: int | None = None
|
|
60
|
+
groups: int | None = None
|
|
61
|
+
tier_zero_below: int | None = None
|
|
62
|
+
owned_below: int | None = None
|
|
63
|
+
gpo_links: int = 0
|
|
64
|
+
blocks_inheritance: bool | None = None
|
|
65
|
+
tier_zero: bool = False
|
|
66
|
+
owned: bool = False
|
|
67
|
+
distinguishedname: str = ""
|
|
68
|
+
objectid: str = ""
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {k: v for k, v in asdict(self).items() if k != "label"}
|
|
72
|
+
|
|
73
|
+
def as_display(self) -> dict[str, Any]:
|
|
74
|
+
out = asdict(self)
|
|
75
|
+
for key in ("users", "computers"):
|
|
76
|
+
total, enabled = out[key], out[f"enabled_{key}"]
|
|
77
|
+
out[key] = None if total is None else _with_enabled(total, enabled or 0)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ContainedRow(Row):
|
|
83
|
+
"""An object below an OU or container (`contents` view)."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
kind: str
|
|
87
|
+
enabled: bool | None = None
|
|
88
|
+
# Containers between the explored one and the object ("direct" when it is a direct child).
|
|
89
|
+
location: str = ""
|
|
90
|
+
depth: int = 1
|
|
91
|
+
tier_zero: bool = False
|
|
92
|
+
owned: bool = False
|
|
93
|
+
distinguishedname: str = ""
|
|
94
|
+
objectid: str = ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class GPOLinkRow(Row):
|
|
99
|
+
"""A GPO linked to the object or to a container above it (`gpos` view)."""
|
|
100
|
+
|
|
101
|
+
gpo: str
|
|
102
|
+
linked_to: str
|
|
103
|
+
linked_kind: str
|
|
104
|
+
enforced: bool | None = None
|
|
105
|
+
# `None` when unknown (REST fallback).
|
|
106
|
+
applies: bool | None = True
|
|
107
|
+
# Why it does not apply ("inheritance blocked at X"), why the link adds nothing ("duplicate: ..."), or ''.
|
|
108
|
+
reason: str = ""
|
|
109
|
+
# Principals outside Tier Zero that can edit the GPO.
|
|
110
|
+
editable_by: list[str] = field(default_factory=list)
|
|
111
|
+
tier_zero: bool = False
|
|
112
|
+
owned: bool = False
|
|
113
|
+
objectid: str = ""
|
|
114
|
+
|
|
115
|
+
def as_display(self) -> dict[str, Any]:
|
|
116
|
+
out = asdict(self)
|
|
117
|
+
if self.applies is None:
|
|
118
|
+
out["applies"] = None
|
|
119
|
+
else:
|
|
120
|
+
out["applies"] = ("yes" if self.applies else "no") + (f" ({self.reason})" if self.reason else "")
|
|
121
|
+
shown = self.editable_by[:3]
|
|
122
|
+
if len(self.editable_by) > len(shown):
|
|
123
|
+
shown = [*shown, f"+{len(self.editable_by) - len(shown)} more"]
|
|
124
|
+
out["editable_by"] = shown
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Column:
|
|
130
|
+
"""A table column: `attr` is a row key; `fmt` picks the cell formatting (see `Renderer.fmt_value`)."""
|
|
131
|
+
|
|
132
|
+
header: str
|
|
133
|
+
attr: str
|
|
134
|
+
fmt: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ViewResult:
|
|
139
|
+
"""The outcome of one relationship view (groups, outbound, ...)."""
|
|
140
|
+
|
|
141
|
+
key: str
|
|
142
|
+
title: str
|
|
143
|
+
columns: list[Column]
|
|
144
|
+
rows: list[Any] = field(default_factory=list) # `Row`s; `RelationshipRow`s for relationship views
|
|
145
|
+
backend: Backend = "cypher"
|
|
146
|
+
notes: list[str] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
def as_dict(self) -> dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"view": self.key,
|
|
151
|
+
"title": self.title,
|
|
152
|
+
"backend": self.backend,
|
|
153
|
+
"notes": self.notes,
|
|
154
|
+
"rows": [r.as_dict() for r in self.rows],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class EntityReport:
|
|
160
|
+
"""A summary and/or a set of relationship views for one entity."""
|
|
161
|
+
|
|
162
|
+
node: Node
|
|
163
|
+
show_summary: bool = True
|
|
164
|
+
views: list[ViewResult] = field(default_factory=list)
|
|
165
|
+
# Summary fields computed by queries (OU contents, linked GPOs, ...), shown after the node's own.
|
|
166
|
+
extra: list[SummaryField] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def entity(self) -> ADObject:
|
|
170
|
+
return ADObject.wrap(self.node)
|
|
171
|
+
|
|
172
|
+
def summary(self) -> list[SummaryField]:
|
|
173
|
+
return [*self.entity.summary(), *self.extra]
|
|
174
|
+
|
|
175
|
+
def as_dict(self) -> dict[str, Any]:
|
|
176
|
+
out: dict[str, Any] = {"entity": self.node.as_dict()}
|
|
177
|
+
if self.show_summary:
|
|
178
|
+
out["summary"] = {f.label: f.value for f in self.summary()}
|
|
179
|
+
out["views"] = {v.key: v.as_dict() for v in self.views}
|
|
180
|
+
return out
|