keywright-server 0.0.1__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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, TypeDecorator
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def now() -> datetime:
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UTCDateTime(TypeDecorator):
|
|
16
|
+
"""Timezone-aware UTC datetimes on every backend (SQLite drops tzinfo otherwise)."""
|
|
17
|
+
|
|
18
|
+
impl = DateTime(timezone=True)
|
|
19
|
+
cache_ok = True
|
|
20
|
+
|
|
21
|
+
def process_bind_param(self, value, dialect):
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
if value.tzinfo is None:
|
|
25
|
+
return value.replace(tzinfo=UTC)
|
|
26
|
+
return value.astimezone(UTC)
|
|
27
|
+
|
|
28
|
+
def process_result_value(self, value, dialect):
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def new_id(prefix: str) -> str:
|
|
35
|
+
return f"{prefix}_{secrets.token_urlsafe(9)}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Base(DeclarativeBase):
|
|
39
|
+
type_annotation_map: ClassVar[dict] = {dict[str, Any]: JSON, list[Any]: JSON}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KnowledgeDocument(Base):
|
|
43
|
+
__tablename__ = "knowledge_documents"
|
|
44
|
+
__table_args__ = (Index("knowledge_document_path", "resource_id", "path", unique=True),)
|
|
45
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("doc"))
|
|
46
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
|
|
47
|
+
path: Mapped[str] = mapped_column(String)
|
|
48
|
+
current_revision: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KnowledgeRevision(Base):
|
|
52
|
+
__tablename__ = "knowledge_revisions"
|
|
53
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("rev"))
|
|
54
|
+
document_id: Mapped[str] = mapped_column(ForeignKey("knowledge_documents.id", ondelete="CASCADE"), index=True)
|
|
55
|
+
title: Mapped[str] = mapped_column(String)
|
|
56
|
+
kind: Mapped[str] = mapped_column(String, default="documentation")
|
|
57
|
+
sha256: Mapped[str] = mapped_column(String)
|
|
58
|
+
# Markdown is immutable, content-addressed on disk. The database holds its provenance and index.
|
|
59
|
+
source: Mapped[str] = mapped_column(Text, default="")
|
|
60
|
+
source_version: Mapped[str] = mapped_column(String, default="")
|
|
61
|
+
base_revision: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
62
|
+
proposed_by: Mapped[str | None] = mapped_column(ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
|
63
|
+
attribution: Mapped[str] = mapped_column(String, default="admin")
|
|
64
|
+
rationale: Mapped[str] = mapped_column(Text, default="")
|
|
65
|
+
status: Mapped[str] = mapped_column(String, default="pending", index=True)
|
|
66
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
67
|
+
reviewed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
68
|
+
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class KnowledgeChunk(Base):
|
|
72
|
+
__tablename__ = "knowledge_chunks"
|
|
73
|
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
74
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
|
|
75
|
+
revision_id: Mapped[str] = mapped_column(ForeignKey("knowledge_revisions.id", ondelete="CASCADE"), index=True)
|
|
76
|
+
ordinal: Mapped[int] = mapped_column(Integer)
|
|
77
|
+
line_start: Mapped[int] = mapped_column(Integer)
|
|
78
|
+
line_end: Mapped[int] = mapped_column(Integer)
|
|
79
|
+
title: Mapped[str] = mapped_column(Text)
|
|
80
|
+
body: Mapped[str] = mapped_column(Text)
|
|
81
|
+
section_path: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MailMessage(Base):
|
|
85
|
+
__tablename__ = "mail_messages"
|
|
86
|
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
87
|
+
source: Mapped[str] = mapped_column(String, index=True)
|
|
88
|
+
uid: Mapped[str] = mapped_column(String)
|
|
89
|
+
message_id: Mapped[str] = mapped_column(String, default="")
|
|
90
|
+
sender: Mapped[str] = mapped_column(Text, default="")
|
|
91
|
+
recipients: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
92
|
+
subject: Mapped[str] = mapped_column(Text, default="")
|
|
93
|
+
date: Mapped[str] = mapped_column(String, default="")
|
|
94
|
+
attachments: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
95
|
+
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
96
|
+
status: Mapped[str] = mapped_column(String, default="unprocessed", index=True)
|
|
97
|
+
note: Mapped[str] = mapped_column(Text, default="")
|
|
98
|
+
plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
99
|
+
receipts: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
100
|
+
request_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
101
|
+
present: Mapped[bool] = mapped_column(default=True)
|
|
102
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
103
|
+
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MailRule(Base):
|
|
107
|
+
__tablename__ = "mail_rules"
|
|
108
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("mailrule"))
|
|
109
|
+
name: Mapped[str] = mapped_column(String)
|
|
110
|
+
sender: Mapped[str] = mapped_column(String, default="")
|
|
111
|
+
subject: Mapped[str] = mapped_column(String, default="")
|
|
112
|
+
action: Mapped[str] = mapped_column(String, default="pdf")
|
|
113
|
+
target: Mapped[str] = mapped_column(String, default="")
|
|
114
|
+
enabled: Mapped[bool] = mapped_column(default=True)
|
|
115
|
+
position: Mapped[int] = mapped_column(Integer, default=100)
|
|
116
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Agent(Base):
|
|
120
|
+
"""An identity that talks MCP to us. One bearer token each; revoking it revokes everything."""
|
|
121
|
+
|
|
122
|
+
__tablename__ = "agents"
|
|
123
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("agt"))
|
|
124
|
+
name: Mapped[str] = mapped_column(String, unique=True)
|
|
125
|
+
token_hash: Mapped[str] = mapped_column(String, unique=True)
|
|
126
|
+
token_prefix: Mapped[str] = mapped_column(String, default="")
|
|
127
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
128
|
+
disabled_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
129
|
+
last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
130
|
+
note: Mapped[str] = mapped_column(Text, default="")
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def disabled(self) -> bool:
|
|
134
|
+
return self.disabled_at is not None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Resource(Base):
|
|
138
|
+
"""Something an agent may want: an SSH host, an upstream MCP server, or an LLM gateway.
|
|
139
|
+
|
|
140
|
+
`default_mode` is the answer when no policy or grant speaks:
|
|
141
|
+
open - allowed, audited
|
|
142
|
+
approval - ask a human
|
|
143
|
+
locked - never grantable from a notification; only an explicit policy in the UI
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
__tablename__ = "resources"
|
|
147
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("res"))
|
|
148
|
+
kind: Mapped[str] = mapped_column(String) # ssh | mcp | llm
|
|
149
|
+
name: Mapped[str] = mapped_column(String, unique=True) # second path segment: ssh/<name>/...
|
|
150
|
+
default_mode: Mapped[str] = mapped_column(String, default="approval")
|
|
151
|
+
config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
152
|
+
description: Mapped[str] = mapped_column(Text, default="")
|
|
153
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def path_prefix(self) -> str:
|
|
157
|
+
return f"{self.kind}/{self.name}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MCPOAuthConnection(Base):
|
|
161
|
+
"""Broker-owned OAuth credentials, separate from agent-visible resource configuration."""
|
|
162
|
+
|
|
163
|
+
__tablename__ = "mcp_oauth_connections"
|
|
164
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("oauth"))
|
|
165
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), unique=True)
|
|
166
|
+
config_key: Mapped[str] = mapped_column(String)
|
|
167
|
+
generation: Mapped[str] = mapped_column(String, default=lambda: new_id("gen"))
|
|
168
|
+
sealed_data: Mapped[str] = mapped_column(Text)
|
|
169
|
+
status: Mapped[str] = mapped_column(String, default="disconnected")
|
|
170
|
+
lease_owner: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
171
|
+
lease_until: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class MCPOAuthFlow(Base):
|
|
175
|
+
__tablename__ = "mcp_oauth_flows"
|
|
176
|
+
state_hash: Mapped[str] = mapped_column(String, primary_key=True)
|
|
177
|
+
connection_id: Mapped[str] = mapped_column(ForeignKey("mcp_oauth_connections.id", ondelete="CASCADE"))
|
|
178
|
+
browser_hash: Mapped[str] = mapped_column(String)
|
|
179
|
+
admin_hash: Mapped[str] = mapped_column(String)
|
|
180
|
+
generation: Mapped[str] = mapped_column(String)
|
|
181
|
+
sealed_data: Mapped[str] = mapped_column(Text)
|
|
182
|
+
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Policy(Base):
|
|
186
|
+
"""A standing rule. `agent_id` NULL applies to every agent. `deny` always wins."""
|
|
187
|
+
|
|
188
|
+
__tablename__ = "policies"
|
|
189
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("pol"))
|
|
190
|
+
agent_id: Mapped[str | None] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=True)
|
|
191
|
+
pattern: Mapped[str] = mapped_column(String)
|
|
192
|
+
constraints: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
193
|
+
request_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
194
|
+
effect: Mapped[str] = mapped_column(String) # allow | deny
|
|
195
|
+
note: Mapped[str] = mapped_column(Text, default="")
|
|
196
|
+
created_by: Mapped[str] = mapped_column(String, default="ui")
|
|
197
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AccessRequest(Base):
|
|
201
|
+
__tablename__ = "requests"
|
|
202
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("req"))
|
|
203
|
+
agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
|
|
204
|
+
session_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
205
|
+
patterns: Mapped[list[Any]] = mapped_column(JSON)
|
|
206
|
+
reason: Mapped[str] = mapped_column(Text, default="")
|
|
207
|
+
detail: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) # e.g. {"command": ...}
|
|
208
|
+
status: Mapped[str] = mapped_column(String, default="pending") # pending|approved|denied|expired
|
|
209
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
210
|
+
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
211
|
+
decided_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
212
|
+
decision_scope: Mapped[str | None] = mapped_column(String, nullable=True) # once|session|ttl|always|deny
|
|
213
|
+
decision_options: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
214
|
+
decided_via: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
215
|
+
deny_reason: Mapped[str] = mapped_column(Text, default="")
|
|
216
|
+
# Jev's verdict (semantic.py), kept apart from `detail` so nothing a grant binds to changes.
|
|
217
|
+
# none_as_null: "no verdict asked" is SQL NULL, not the JSON value null, so IS NOT NULL finds verdicts.
|
|
218
|
+
semantic: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), nullable=True)
|
|
219
|
+
|
|
220
|
+
agent: Mapped[Agent] = relationship(lazy="joined")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Grant(Base):
|
|
224
|
+
"""A temporary permission produced by a decision. `once` grants are consumed atomically."""
|
|
225
|
+
|
|
226
|
+
__tablename__ = "grants"
|
|
227
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("grt"))
|
|
228
|
+
agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
|
|
229
|
+
pattern: Mapped[str] = mapped_column(String)
|
|
230
|
+
constraints: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
231
|
+
scope: Mapped[str] = mapped_column(String) # once | session | ttl
|
|
232
|
+
session_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
233
|
+
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
234
|
+
uses_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
235
|
+
request_id: Mapped[str | None] = mapped_column(ForeignKey("requests.id", ondelete="SET NULL"), nullable=True)
|
|
236
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
237
|
+
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
238
|
+
|
|
239
|
+
def is_active(self, at: datetime, session_id: str | None) -> bool:
|
|
240
|
+
if self.revoked_at is not None:
|
|
241
|
+
return False
|
|
242
|
+
if self.expires_at is not None and self.expires_at <= at:
|
|
243
|
+
return False
|
|
244
|
+
if self.uses_remaining is not None and self.uses_remaining <= 0:
|
|
245
|
+
return False
|
|
246
|
+
# a session grant is bound to exactly one session id; one without any id is dead
|
|
247
|
+
if self.scope == "session":
|
|
248
|
+
return bool(self.session_id) and self.session_id == session_id
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class LlmKey(Base):
|
|
253
|
+
"""A LiteLLM virtual key minted for one approval on one LLM gateway. The key is the lease made
|
|
254
|
+
concrete: it carries the approved models, the grant's lifetime and a hard spend ceiling, and it
|
|
255
|
+
is deleted upstream the moment the grants behind it are revoked. Agents never see it; the
|
|
256
|
+
broker uses it on their behalf."""
|
|
257
|
+
|
|
258
|
+
__tablename__ = "llm_keys"
|
|
259
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("lkey"))
|
|
260
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"))
|
|
261
|
+
agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
|
|
262
|
+
request_id: Mapped[str | None] = mapped_column(ForeignKey("requests.id", ondelete="SET NULL"), nullable=True)
|
|
263
|
+
grant_ids: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
264
|
+
key: Mapped[str] = mapped_column(Text) # the upstream secret; only ever sent to LiteLLM
|
|
265
|
+
key_alias: Mapped[str] = mapped_column(String, default="")
|
|
266
|
+
models: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
267
|
+
limits: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) # max_budget, rpm_limit, ...
|
|
268
|
+
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
269
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
270
|
+
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
271
|
+
|
|
272
|
+
def is_active(self, at: datetime) -> bool:
|
|
273
|
+
return self.revoked_at is None and (self.expires_at is None or self.expires_at > at)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Nudge(Base):
|
|
277
|
+
"""An agent asking the human to come back to the conversation: a question to answer, a choice to
|
|
278
|
+
make, work to look at. It carries no authority and grants nothing; it is a tap on the shoulder,
|
|
279
|
+
kept until the human clears it so the phone and the dashboard agree on what is outstanding."""
|
|
280
|
+
|
|
281
|
+
__tablename__ = "nudges"
|
|
282
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("ndg"))
|
|
283
|
+
agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
|
|
284
|
+
session_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
285
|
+
message: Mapped[str] = mapped_column(Text, default="")
|
|
286
|
+
context: Mapped[str] = mapped_column(String, default="") # where the conversation is: host:directory
|
|
287
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
288
|
+
cleared_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
289
|
+
cleared_via: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
290
|
+
|
|
291
|
+
agent: Mapped[Agent] = relationship(lazy="joined")
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def open(self) -> bool:
|
|
295
|
+
return self.cleared_at is None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class AuditEvent(Base):
|
|
299
|
+
__tablename__ = "audit"
|
|
300
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
301
|
+
ts: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
302
|
+
agent_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
303
|
+
agent_name: Mapped[str] = mapped_column(String, default="")
|
|
304
|
+
session_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
305
|
+
tool: Mapped[str] = mapped_column(String)
|
|
306
|
+
patterns: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
307
|
+
decision: Mapped[str] = mapped_column(String) # allow|deny|locked|pending|error|approved|denied|revoked...
|
|
308
|
+
detail: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
309
|
+
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
310
|
+
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class AdminLoginFlow(Base):
|
|
314
|
+
"""Short-lived, browser-bound OIDC transaction; consumed before exchanging its code."""
|
|
315
|
+
__tablename__ = "admin_login_flows"
|
|
316
|
+
state_hash: Mapped[str] = mapped_column(String, primary_key=True)
|
|
317
|
+
browser_hash: Mapped[str] = mapped_column(String)
|
|
318
|
+
config_key: Mapped[str] = mapped_column(String)
|
|
319
|
+
data: Mapped[dict[str, Any]] = mapped_column(JSON)
|
|
320
|
+
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class AdminSession(Base):
|
|
324
|
+
"""Opaque browser sessions. Provider access/refresh/ID tokens are never retained."""
|
|
325
|
+
__tablename__ = "admin_sessions"
|
|
326
|
+
token_hash: Mapped[str] = mapped_column(String, primary_key=True)
|
|
327
|
+
config_key: Mapped[str] = mapped_column(String)
|
|
328
|
+
identity: Mapped[dict[str, Any]] = mapped_column(JSON)
|
|
329
|
+
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Setting(Base):
|
|
333
|
+
"""Key/value store for everything configurable in the UI (notifier config, admin password hash...)."""
|
|
334
|
+
|
|
335
|
+
__tablename__ = "settings"
|
|
336
|
+
key: Mapped[str] = mapped_column(String, primary_key=True)
|
|
337
|
+
value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
338
|
+
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
Index("ix_grants_agent", Grant.agent_id, Grant.revoked_at)
|
|
342
|
+
Index("ix_requests_status", AccessRequest.status, AccessRequest.created_at)
|
|
343
|
+
Index("ix_audit_ts", AuditEvent.ts)
|
|
344
|
+
Index("ix_nudges_open", Nudge.cleared_at, Nudge.created_at)
|
|
345
|
+
Index("ix_llm_keys_active", LlmKey.resource_id, LlmKey.revoked_at)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# Inventory describes infrastructure; none of these records confer agent permissions.
|
|
349
|
+
class InventoryAsset(Base):
|
|
350
|
+
__tablename__ = "inventory_assets"
|
|
351
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("asset"))
|
|
352
|
+
name: Mapped[str] = mapped_column(String, unique=True)
|
|
353
|
+
kind: Mapped[str] = mapped_column(String, default="physical")
|
|
354
|
+
parent_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_assets.id"), nullable=True)
|
|
355
|
+
site: Mapped[str] = mapped_column(String, default="")
|
|
356
|
+
tags: Mapped[str] = mapped_column(String, default="")
|
|
357
|
+
notes: Mapped[str] = mapped_column(Text, default="")
|
|
358
|
+
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class InventoryInterface(Base):
|
|
362
|
+
__tablename__ = "inventory_interfaces"
|
|
363
|
+
__table_args__ = (Index("uq_inventory_interface_name", "asset_id", "name", unique=True),)
|
|
364
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("iface"))
|
|
365
|
+
asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"))
|
|
366
|
+
name: Mapped[str] = mapped_column(String)
|
|
367
|
+
medium: Mapped[str] = mapped_column(String, default="unknown")
|
|
368
|
+
mac: Mapped[str] = mapped_column(String, default="")
|
|
369
|
+
attached_to_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_assets.id"), nullable=True)
|
|
370
|
+
port: Mapped[str] = mapped_column(String, default="")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class InventorySubnet(Base):
|
|
374
|
+
__tablename__ = "inventory_subnets"
|
|
375
|
+
__table_args__ = (Index("uq_inventory_subnet_scope", "scope", "cidr", unique=True),)
|
|
376
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("net"))
|
|
377
|
+
name: Mapped[str] = mapped_column(String)
|
|
378
|
+
cidr: Mapped[str] = mapped_column(String)
|
|
379
|
+
scope: Mapped[str] = mapped_column(String, default="default")
|
|
380
|
+
site: Mapped[str] = mapped_column(String, default="")
|
|
381
|
+
vlan: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
382
|
+
gateway: Mapped[str] = mapped_column(String, default="")
|
|
383
|
+
dhcp_start: Mapped[str] = mapped_column(String, default="")
|
|
384
|
+
dhcp_end: Mapped[str] = mapped_column(String, default="")
|
|
385
|
+
notes: Mapped[str] = mapped_column(Text, default="")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class InventoryAllocation(Base):
|
|
389
|
+
__tablename__ = "inventory_allocations"
|
|
390
|
+
__table_args__ = (Index("uq_inventory_address", "subnet_id", "address", unique=True),)
|
|
391
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("addr"))
|
|
392
|
+
subnet_id: Mapped[str] = mapped_column(ForeignKey("inventory_subnets.id"))
|
|
393
|
+
interface_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_interfaces.id"), nullable=True)
|
|
394
|
+
address: Mapped[str] = mapped_column(String)
|
|
395
|
+
state: Mapped[str] = mapped_column(String, default="reserved")
|
|
396
|
+
label: Mapped[str] = mapped_column(String, default="")
|
|
397
|
+
notes: Mapped[str] = mapped_column(Text, default="")
|
|
398
|
+
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class InventoryResourceLink(Base):
|
|
402
|
+
__tablename__ = "inventory_resource_links"
|
|
403
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), primary_key=True)
|
|
404
|
+
asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class DiscoveryRun(Base):
|
|
408
|
+
__tablename__ = "inventory_discovery_runs"
|
|
409
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("sync"))
|
|
410
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
|
|
411
|
+
provider: Mapped[str] = mapped_column(String)
|
|
412
|
+
source_fingerprint: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
413
|
+
started_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
414
|
+
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
415
|
+
status: Mapped[str] = mapped_column(String, default="running")
|
|
416
|
+
count: Mapped[int] = mapped_column(Integer, default=0)
|
|
417
|
+
error: Mapped[str] = mapped_column(String, default="")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class DiscoveryObservation(Base):
|
|
421
|
+
__tablename__ = "inventory_discovery_observations"
|
|
422
|
+
__table_args__ = (Index("uq_inventory_observation", "resource_id", "provider", "external_id", unique=True),)
|
|
423
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("obs"))
|
|
424
|
+
resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"))
|
|
425
|
+
provider: Mapped[str] = mapped_column(String)
|
|
426
|
+
external_id: Mapped[str] = mapped_column(String)
|
|
427
|
+
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
428
|
+
previous: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
429
|
+
first_seen: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
430
|
+
last_seen: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
431
|
+
changed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
|
432
|
+
run_id: Mapped[str] = mapped_column(ForeignKey("inventory_discovery_runs.id", ondelete="CASCADE"))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class DiscoveryLink(Base):
|
|
436
|
+
__tablename__ = "inventory_discovery_links"
|
|
437
|
+
observation_id: Mapped[str] = mapped_column(
|
|
438
|
+
ForeignKey("inventory_discovery_observations.id", ondelete="CASCADE"), primary_key=True)
|
|
439
|
+
asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"), index=True)
|
|
440
|
+
interface_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_interfaces.id"), nullable=True)
|
|
441
|
+
reviewed_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
442
|
+
reviewed_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Network graph from source observations, with explicit freshness and provenance."""
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
|
|
7
|
+
from . import discovery
|
|
8
|
+
from .models import (
|
|
9
|
+
DiscoveryLink,
|
|
10
|
+
DiscoveryObservation,
|
|
11
|
+
DiscoveryRun,
|
|
12
|
+
InventoryAsset,
|
|
13
|
+
InventoryInterface,
|
|
14
|
+
Resource,
|
|
15
|
+
now,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
REFRESH_SECONDS = 60
|
|
19
|
+
FRESH_SECONDS = 180
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def iso(value):
|
|
23
|
+
return value.isoformat() if value else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def snapshot(db, source=''):
|
|
27
|
+
resources = list((await db.scalars(select(Resource).order_by(Resource.name))).all())
|
|
28
|
+
resources = [r for r in resources if r.kind == 'mcp' and r.config.get('adapter') == 'omada']
|
|
29
|
+
selected = next((r for r in resources if r.id == source), None) if source else next(iter(resources), None)
|
|
30
|
+
result = {'sources': [{'id': r.id, 'name': r.name} for r in resources],
|
|
31
|
+
'source': selected.id if selected else '', 'nodes': [], 'edges': [], 'counts': {},
|
|
32
|
+
'refresh_seconds': REFRESH_SECONDS, 'fresh_seconds': FRESH_SECONDS,
|
|
33
|
+
'generated_at': iso(now()), 'last_success': None, 'latest_status': '', 'error': '',
|
|
34
|
+
'fresh': False, 'configuration_changed': False}
|
|
35
|
+
if not selected:
|
|
36
|
+
return result
|
|
37
|
+
recent_runs = select(DiscoveryRun).where(
|
|
38
|
+
DiscoveryRun.resource_id == selected.id, DiscoveryRun.provider == 'omada').order_by(
|
|
39
|
+
DiscoveryRun.started_at.desc(), DiscoveryRun.id.desc()).limit(1)
|
|
40
|
+
latest = await db.scalar(recent_runs)
|
|
41
|
+
success = await db.scalar(recent_runs.where(DiscoveryRun.status == 'succeeded'))
|
|
42
|
+
at = now()
|
|
43
|
+
changed = bool(success and success.source_fingerprint != discovery.source_fingerprint(selected.config))
|
|
44
|
+
fresh = bool(success and success.finished_at and success.finished_at >= at - timedelta(seconds=FRESH_SECONDS)
|
|
45
|
+
and not changed and latest.status != 'failed')
|
|
46
|
+
result.update(last_success=iso(success.finished_at) if success else None,
|
|
47
|
+
latest_status=latest.status if latest else '', error=latest.error if latest else '',
|
|
48
|
+
fresh=fresh, configuration_changed=changed)
|
|
49
|
+
observations = list((await db.scalars(select(DiscoveryObservation).where(
|
|
50
|
+
DiscoveryObservation.resource_id == selected.id, DiscoveryObservation.provider == 'omada'))).all())
|
|
51
|
+
links = {r.observation_id: r for r in (await db.scalars(select(DiscoveryLink))).all()}
|
|
52
|
+
by_identity = {r.external_id: r for r in observations}
|
|
53
|
+
by_asset = {}
|
|
54
|
+
for r in observations:
|
|
55
|
+
if r.id in links:
|
|
56
|
+
by_asset.setdefault(links[r.id].asset_id, []).append(r)
|
|
57
|
+
interfaces = list((await db.scalars(select(InventoryInterface))).all())
|
|
58
|
+
assets = {r.id: r for r in (await db.scalars(select(InventoryAsset))).all()}
|
|
59
|
+
manual_nodes = {}
|
|
60
|
+
for row in sorted(observations, key=lambda r: (r.data.get('name', '').casefold(), r.id)):
|
|
61
|
+
data = row.data
|
|
62
|
+
missing = bool(success and row.run_id != success.id)
|
|
63
|
+
if not fresh:
|
|
64
|
+
state = 'stale'
|
|
65
|
+
elif missing:
|
|
66
|
+
state = 'old' if row.last_seen < at - timedelta(hours=24) else 'missing'
|
|
67
|
+
else:
|
|
68
|
+
state = {'Active': 'online', 'Inactive': 'inactive'}.get(data.get('status'), 'unknown')
|
|
69
|
+
link = links.get(row.id)
|
|
70
|
+
site_id = row.external_id.split('/')[0]
|
|
71
|
+
target = by_identity.get(data.get('attachment', ''))
|
|
72
|
+
node = {'id': row.id, 'name': data.get('name') or data.get('mac') or 'Unnamed device',
|
|
73
|
+
'site': data.get('site', ''), 'site_id': site_id,
|
|
74
|
+
'mac': data.get('mac', ''), 'addresses': data.get('addresses', []),
|
|
75
|
+
'medium': data.get('medium', 'unknown'), 'port': data.get('port', ''),
|
|
76
|
+
'role': data.get('device_type') or ('network' if data.get('kind') == 'network' else 'client'),
|
|
77
|
+
'uplink_note': data.get('uplink_note', ''),
|
|
78
|
+
'model': data.get('model', ''), 'hub': data.get('kind') == 'network',
|
|
79
|
+
'state': state, 'source_status': data.get('status', 'Unknown'),
|
|
80
|
+
'missing': missing, 'last_observed': iso(row.last_seen), 'first_observed': iso(row.first_seen),
|
|
81
|
+
'unreviewed': link is None, 'attachment': target.id if target and target.id != row.id else '',
|
|
82
|
+
'attachment_label': target.data.get('name', '') if target else data.get('attachment', ''),
|
|
83
|
+
'attachment_source': 'Omada' if data.get('attachment') else '',
|
|
84
|
+
'review_url': f'/ui/inventory/discovery/{row.id}/review',
|
|
85
|
+
'asset_url': f'/ui/inventory/assets/{link.asset_id}' if link else ''}
|
|
86
|
+
result['nodes'].append(node)
|
|
87
|
+
if target and target.id != row.id:
|
|
88
|
+
result['edges'].append({'source': target.id, 'target': row.id, 'medium': node['medium'],
|
|
89
|
+
'label': node['port'], 'provenance': 'Omada', 'historical': missing})
|
|
90
|
+
elif link and not data.get('attachment'):
|
|
91
|
+
# Only reviewed inventory links can supply a manual uplink; MAC/IP guesses are not topology.
|
|
92
|
+
for iface in interfaces:
|
|
93
|
+
if (iface.asset_id != link.asset_id or not iface.attached_to_id
|
|
94
|
+
or data.get('device_type') == 'ap' and data.get('medium') in ('wifi', 'wired')
|
|
95
|
+
and iface.medium != data['medium']):
|
|
96
|
+
continue
|
|
97
|
+
targets = [r for r in by_asset.get(iface.attached_to_id, [])
|
|
98
|
+
if r.external_id.split('/')[0] == site_id and r.id != row.id]
|
|
99
|
+
if not targets and iface.attached_to_id in assets and iface.attached_to_id not in by_asset:
|
|
100
|
+
# Explicit inventory connections can include an unmanaged WAN, modem, or switch.
|
|
101
|
+
parent = assets[iface.attached_to_id]
|
|
102
|
+
parent_id = f'inventory:{site_id}:{parent.id}'
|
|
103
|
+
manual_nodes[parent_id] = {
|
|
104
|
+
'id': parent_id, 'name': parent.name, 'site': data.get('site', ''), 'site_id': site_id,
|
|
105
|
+
'mac': '', 'addresses': [], 'medium': 'unknown', 'port': '',
|
|
106
|
+
'role': 'external' if parent.kind == 'service' else 'network', 'model': '',
|
|
107
|
+
'hub': True, 'virtual': True, 'state': 'unknown', 'source_status': 'Not monitored',
|
|
108
|
+
'missing': False, 'last_observed': None, 'first_observed': None, 'unreviewed': False,
|
|
109
|
+
'attachment': '', 'attachment_label': '', 'attachment_source': '', 'uplink_note': '',
|
|
110
|
+
'review_url': '', 'asset_url': f'/ui/inventory/assets/{parent.id}'}
|
|
111
|
+
result['edges'].append({'source': parent_id, 'target': row.id, 'medium': iface.medium,
|
|
112
|
+
'label': iface.port, 'provenance': 'Inventory', 'historical': False})
|
|
113
|
+
if not node['attachment']:
|
|
114
|
+
node['attachment'], node['attachment_label'] = parent_id, parent.name
|
|
115
|
+
node['attachment_source'] = 'Inventory'
|
|
116
|
+
node['medium'], node['port'] = iface.medium, iface.port
|
|
117
|
+
if len(targets) == 1:
|
|
118
|
+
parent = targets[0]
|
|
119
|
+
result['edges'].append({'source': parent.id, 'target': row.id, 'medium': iface.medium,
|
|
120
|
+
'label': iface.port, 'provenance': 'Inventory', 'historical': False})
|
|
121
|
+
if not node['attachment']:
|
|
122
|
+
node['attachment'], node['attachment_label'] = parent.id, parent.data.get('name', '')
|
|
123
|
+
node['attachment_source'] = 'Inventory'
|
|
124
|
+
node['medium'], node['port'] = iface.medium, iface.port
|
|
125
|
+
result['counts'] = dict(Counter(n['state'] for n in result['nodes']))
|
|
126
|
+
result['nodes'].extend(manual_nodes.values())
|
|
127
|
+
result['counts']['unreviewed'] = sum(n['unreviewed'] for n in result['nodes'])
|
|
128
|
+
return result
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Pluggable notification channels. A driver turns an access request into something a human can
|
|
2
|
+
answer (phone push, chat message, webhook...) and optionally feeds decisions back."""
|
|
3
|
+
|
|
4
|
+
# Importing a driver module registers it. Add new drivers here.
|
|
5
|
+
from . import homeassistant, log, webhook
|
|
6
|
+
from .base import Field, Notifier, ProbeResult, registry
|
|
7
|
+
|
|
8
|
+
__all__ = ["Field", "Notifier", "ProbeResult", "homeassistant", "log", "registry", "webhook"]
|