footprinter-cli 1.0.0rc1__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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +431 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
- footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
- footprinter/bundled/samples/visible-file-sample.txt +2 -0
- footprinter/cli/__init__.py +135 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +327 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/_sample_seed.py +204 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +543 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +2001 -0
- footprinter/cli/status.py +747 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +602 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +724 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +487 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +315 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +223 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +112 -0
- footprinter/ingest/pipe_runner.py +200 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +186 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +67 -0
- footprinter/mcp/errors.py +105 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +19 -0
- footprinter/paths.py +117 -0
- footprinter/permissions.py +1152 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1264 -0
- footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
- footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
- footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""access_service — access gating, visibility filtering, and permission resolution.
|
|
2
|
+
|
|
3
|
+
Combines the former ``read_service`` (3-stage gating) and ``visibility``
|
|
4
|
+
(list filtering, inherit resolution, opaque field sets) into one module.
|
|
5
|
+
|
|
6
|
+
Gating stages (for non-ADMIN roles):
|
|
7
|
+
1. Existence — item must exist in DB
|
|
8
|
+
2. Visibility — ``mcp_view`` must not be hidden/opaque
|
|
9
|
+
3. Permission — ``mcp_read`` must not be deny
|
|
10
|
+
|
|
11
|
+
Visibility values: 'hidden' -> exclude, 'opaque' -> minimal fields,
|
|
12
|
+
'visible' -> full. 'inherit' -> resolves to the global policy at query
|
|
13
|
+
time (loaded by ``load_globals``). Missing (None) -> treated as 'opaque'
|
|
14
|
+
(fail-closed).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import sqlite3
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from footprinter.db.chats import get_chat_detail
|
|
22
|
+
from footprinter.db.emails import get_email
|
|
23
|
+
from footprinter.db.files import get_file
|
|
24
|
+
from footprinter.services.roles import Role
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Gating
|
|
30
|
+
"gate_access",
|
|
31
|
+
"VALID_TYPES",
|
|
32
|
+
# Visibility resolution
|
|
33
|
+
"load_globals",
|
|
34
|
+
"resolve_inherit_visibility",
|
|
35
|
+
"resolve_inherit_permission",
|
|
36
|
+
"has_global_permission",
|
|
37
|
+
"is_global_policy_loaded",
|
|
38
|
+
# List filtering
|
|
39
|
+
"filter_result",
|
|
40
|
+
"filter_results_list",
|
|
41
|
+
"strip_content_for_denied",
|
|
42
|
+
"get_opaque_metadata",
|
|
43
|
+
# Opaque field sets
|
|
44
|
+
"OPAQUE_FILE_FIELDS",
|
|
45
|
+
"OPAQUE_EMAIL_FIELDS",
|
|
46
|
+
"OPAQUE_CHAT_FIELDS",
|
|
47
|
+
"OPAQUE_FOLDER_FIELDS",
|
|
48
|
+
"OPAQUE_BROWSER_FIELDS",
|
|
49
|
+
"OPAQUE_PROJECT_FIELDS",
|
|
50
|
+
"OPAQUE_CLIENT_FIELDS",
|
|
51
|
+
"_read_visibility",
|
|
52
|
+
"_filter_to_opaque",
|
|
53
|
+
"_CONTENT_FIELDS",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
VALID_TYPES = frozenset({"file", "email", "chat"})
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Global policy cache — refreshed per-request by load_globals()
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_global_visibility: Optional[str] = None
|
|
63
|
+
_global_permission: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_globals(conn: sqlite3.Connection) -> None:
|
|
67
|
+
"""Read global visibility and permission policies and cache them.
|
|
68
|
+
|
|
69
|
+
Called once per MCP request (from ``get_db()``). Two PK lookups.
|
|
70
|
+
"""
|
|
71
|
+
global _global_visibility, _global_permission
|
|
72
|
+
|
|
73
|
+
row = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
|
|
74
|
+
_global_visibility = row["setting"] if row else None
|
|
75
|
+
|
|
76
|
+
row = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
|
|
77
|
+
_global_permission = row["setting"] if row else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def has_global_permission() -> bool:
|
|
81
|
+
"""Return whether the cached global permission is 'allow'.
|
|
82
|
+
|
|
83
|
+
Public replacement for direct ``_global_permission`` access.
|
|
84
|
+
"""
|
|
85
|
+
return _global_permission == "allow"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_global_policy_loaded() -> bool:
|
|
89
|
+
"""Return whether a global permission policy has been loaded.
|
|
90
|
+
|
|
91
|
+
Unlike ``has_global_permission()`` which checks if the policy is
|
|
92
|
+
specifically ``'allow'``, this checks whether *any* policy exists.
|
|
93
|
+
"""
|
|
94
|
+
return _global_permission is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Inherit resolution
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_inherit_visibility(value: Optional[str]) -> str:
|
|
103
|
+
"""Resolve a visibility value, mapping ``inherit`` to the global policy.
|
|
104
|
+
|
|
105
|
+
- ``None`` -> ``'opaque'`` (fail-closed: truly missing data)
|
|
106
|
+
- ``'inherit'`` -> cached global visibility, or ``'opaque'`` baseline
|
|
107
|
+
- Explicit values (``'hidden'``, ``'opaque'``, ``'visible'``) pass through
|
|
108
|
+
"""
|
|
109
|
+
if value is None:
|
|
110
|
+
return "opaque"
|
|
111
|
+
if value == "inherit":
|
|
112
|
+
return _global_visibility or "opaque"
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_inherit_permission(value: Optional[str]) -> str:
|
|
117
|
+
"""Resolve a permission value, mapping ``inherit`` to the global policy.
|
|
118
|
+
|
|
119
|
+
- ``None`` -> ``'deny'`` (fail-closed: truly missing data)
|
|
120
|
+
- ``'inherit'`` -> cached global permission, or ``'allow'`` baseline
|
|
121
|
+
(``BASELINE_PERMISSION = True`` in permissions.py)
|
|
122
|
+
- Explicit values (``'allow'``, ``'deny'``) pass through
|
|
123
|
+
"""
|
|
124
|
+
if value is None:
|
|
125
|
+
return "deny"
|
|
126
|
+
if value == "inherit":
|
|
127
|
+
return _global_permission or "allow"
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Opaque field sets
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
OPAQUE_FILE_FIELDS = {"id", "content_type", "source", "project_id"}
|
|
136
|
+
OPAQUE_EMAIL_FIELDS = {"id", "account", "project_id", "client_id"}
|
|
137
|
+
OPAQUE_CHAT_FIELDS = {"id", "account", "project_id", "client_id"}
|
|
138
|
+
OPAQUE_FOLDER_FIELDS = {"id", "direct_files", "direct_file_count", "source", "project_id"}
|
|
139
|
+
OPAQUE_BROWSER_FIELDS = {"id", "browser", "project_id"}
|
|
140
|
+
OPAQUE_PROJECT_FIELDS = {"id", "type", "project_type", "status", "client_id"}
|
|
141
|
+
OPAQUE_CLIENT_FIELDS = {"id", "client_type", "status"}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# List filtering
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_visibility(result: Dict[str, Any]) -> str:
|
|
150
|
+
"""Read mcp_view from a result dict, resolving ``inherit`` via global policy."""
|
|
151
|
+
return resolve_inherit_visibility(result.get("mcp_view"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def filter_result(item_type: str, full_result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
155
|
+
"""Filter a single result dict based on its ``mcp_view`` value.
|
|
156
|
+
|
|
157
|
+
Returns None if hidden, minimal dict if opaque, full dict if visible.
|
|
158
|
+
"""
|
|
159
|
+
visibility = _read_visibility(full_result)
|
|
160
|
+
|
|
161
|
+
if visibility == "hidden":
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if visibility == "opaque":
|
|
165
|
+
return _filter_to_opaque(item_type, full_result)
|
|
166
|
+
|
|
167
|
+
return full_result # visible
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def filter_results_list(
|
|
171
|
+
item_type: str, results: List[Dict[str, Any]], id_key: str = "id"
|
|
172
|
+
) -> Tuple[List[Dict[str, Any]], int]:
|
|
173
|
+
"""Filter a list of results, returning filtered list and suppressed count.
|
|
174
|
+
|
|
175
|
+
Reads ``mcp_view`` from each result dict instead of querying the database.
|
|
176
|
+
"""
|
|
177
|
+
filtered = []
|
|
178
|
+
suppressed = 0
|
|
179
|
+
|
|
180
|
+
for result in results:
|
|
181
|
+
visibility = _read_visibility(result)
|
|
182
|
+
|
|
183
|
+
if visibility == "hidden":
|
|
184
|
+
suppressed += 1
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if visibility == "opaque":
|
|
188
|
+
filtered.append(_filter_to_opaque(item_type, result))
|
|
189
|
+
else:
|
|
190
|
+
filtered.append(result)
|
|
191
|
+
|
|
192
|
+
return filtered, suppressed
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _filter_to_opaque(item_type: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
196
|
+
"""Filter a result dict to only include opaque-allowed fields."""
|
|
197
|
+
if item_type == "file":
|
|
198
|
+
allowed = OPAQUE_FILE_FIELDS
|
|
199
|
+
elif item_type == "email":
|
|
200
|
+
allowed = OPAQUE_EMAIL_FIELDS
|
|
201
|
+
elif item_type == "chat":
|
|
202
|
+
allowed = OPAQUE_CHAT_FIELDS
|
|
203
|
+
elif item_type == "folder":
|
|
204
|
+
allowed = OPAQUE_FOLDER_FIELDS
|
|
205
|
+
elif item_type == "visit":
|
|
206
|
+
allowed = OPAQUE_BROWSER_FIELDS
|
|
207
|
+
elif item_type == "project":
|
|
208
|
+
allowed = OPAQUE_PROJECT_FIELDS
|
|
209
|
+
elif item_type == "client":
|
|
210
|
+
allowed = OPAQUE_CLIENT_FIELDS
|
|
211
|
+
else:
|
|
212
|
+
allowed = {"id"}
|
|
213
|
+
|
|
214
|
+
return {k: v for k, v in result.items() if k in allowed}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Content fields that listing tools must strip when mcp_read != 'allow'
|
|
218
|
+
_CONTENT_FIELDS: Dict[str, List[str]] = {
|
|
219
|
+
"chat": ["snippet", "summary"],
|
|
220
|
+
"email": ["snippet"],
|
|
221
|
+
"file": ["snippet"],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def strip_content_for_denied(item_type: str, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
226
|
+
"""Strip content fields from results where resolved permission is not 'allow'.
|
|
227
|
+
|
|
228
|
+
Uses ``resolve_inherit_permission`` so that ``inherit`` resolves to the
|
|
229
|
+
global policy (or baseline ``'allow'`` when no global policy is loaded).
|
|
230
|
+
``NULL`` / missing values fail closed to ``'deny'``. Items are NOT
|
|
231
|
+
removed — only content keys are deleted, preserving the "you matched
|
|
232
|
+
something" signal.
|
|
233
|
+
"""
|
|
234
|
+
fields = _CONTENT_FIELDS.get(item_type, [])
|
|
235
|
+
if not fields:
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
for result in results:
|
|
239
|
+
if resolve_inherit_permission(result.get("mcp_read")) != "allow":
|
|
240
|
+
for field in fields:
|
|
241
|
+
result.pop(field, None)
|
|
242
|
+
return results
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_opaque_metadata(conn: sqlite3.Connection, item_type: str, item_id: int) -> Dict[str, Any]:
|
|
246
|
+
"""Get opaque metadata for an item, filtered to opaque-allowed fields.
|
|
247
|
+
|
|
248
|
+
Used by footprinter_read when returning visibility-restricted errors.
|
|
249
|
+
Delegates to db/ layer for the fetch, then filters to opaque fields.
|
|
250
|
+
"""
|
|
251
|
+
if item_type == "file":
|
|
252
|
+
row = get_file(conn, item_id)
|
|
253
|
+
elif item_type == "email":
|
|
254
|
+
row = get_email(conn, item_id)
|
|
255
|
+
elif item_type == "chat":
|
|
256
|
+
row = get_chat_detail(conn, item_id)
|
|
257
|
+
else:
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
if not row:
|
|
261
|
+
return {}
|
|
262
|
+
return _filter_to_opaque(item_type, row)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# 3-stage access gating
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def gate_access(
|
|
271
|
+
conn: sqlite3.Connection,
|
|
272
|
+
item_type: str,
|
|
273
|
+
item_id: int,
|
|
274
|
+
*,
|
|
275
|
+
role: Role = Role.ADMIN,
|
|
276
|
+
) -> dict:
|
|
277
|
+
"""3-stage access gating + content for a single item.
|
|
278
|
+
|
|
279
|
+
Returns dict with ``status`` key:
|
|
280
|
+
|
|
281
|
+
- ``ok`` — access granted; includes ``metadata`` (+ ``content`` for email/chat)
|
|
282
|
+
- ``hidden`` — item hidden from this role
|
|
283
|
+
- ``opaque`` — minimal ``metadata`` only
|
|
284
|
+
- ``denied`` — permission denied
|
|
285
|
+
- ``not_found`` — item doesn't exist
|
|
286
|
+
- ``invalid_type`` — unrecognised item_type
|
|
287
|
+
"""
|
|
288
|
+
if item_type not in VALID_TYPES:
|
|
289
|
+
return {"status": "invalid_type"}
|
|
290
|
+
|
|
291
|
+
# Stage 1: Existence — fetch full detail via db/ layer
|
|
292
|
+
if item_type == "file":
|
|
293
|
+
metadata = get_file(conn, item_id)
|
|
294
|
+
elif item_type == "email":
|
|
295
|
+
metadata = get_email(conn, item_id)
|
|
296
|
+
else:
|
|
297
|
+
metadata = get_chat_detail(conn, item_id)
|
|
298
|
+
|
|
299
|
+
if not metadata:
|
|
300
|
+
return {"status": "not_found"}
|
|
301
|
+
|
|
302
|
+
# Stage 2: Visibility (ADMIN bypasses)
|
|
303
|
+
if not role.sees_all:
|
|
304
|
+
visibility = resolve_inherit_visibility(metadata.get("mcp_view"))
|
|
305
|
+
if visibility == "hidden":
|
|
306
|
+
return {"status": "hidden"}
|
|
307
|
+
if visibility == "opaque":
|
|
308
|
+
return {
|
|
309
|
+
"status": "opaque",
|
|
310
|
+
"metadata": _filter_to_opaque(item_type, metadata),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Stage 3: Permission (ADMIN bypasses)
|
|
314
|
+
if not role.sees_all:
|
|
315
|
+
if resolve_inherit_permission(metadata.get("mcp_read")) == "deny":
|
|
316
|
+
return {
|
|
317
|
+
"status": "denied",
|
|
318
|
+
"metadata": _filter_to_opaque(item_type, metadata),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Stage 4: Return content — reuse metadata already fetched
|
|
322
|
+
if item_type == "file":
|
|
323
|
+
return {"status": "ok", "metadata": metadata}
|
|
324
|
+
elif item_type == "email":
|
|
325
|
+
content = metadata.pop("body_preview", "") or ""
|
|
326
|
+
return {"status": "ok", "metadata": metadata, "content": content}
|
|
327
|
+
else:
|
|
328
|
+
messages = metadata.pop("messages", [])
|
|
329
|
+
summary = metadata.pop("summary", None) or ""
|
|
330
|
+
content_parts = []
|
|
331
|
+
if summary:
|
|
332
|
+
content_parts.append(f"Summary: {summary}")
|
|
333
|
+
for msg in messages:
|
|
334
|
+
role_name = msg.get("role") or "unknown"
|
|
335
|
+
text = msg.get("content") or ""
|
|
336
|
+
timestamp = msg.get("created_at") or ""
|
|
337
|
+
if timestamp:
|
|
338
|
+
content_parts.append(f"[{timestamp}] {role_name}: {text}")
|
|
339
|
+
else:
|
|
340
|
+
content_parts.append(f"{role_name}: {text}")
|
|
341
|
+
content = "\n\n".join(content_parts)
|
|
342
|
+
return {"status": "ok", "metadata": metadata, "content": content}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Chat read service — get/list with role-based visibility filtering."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from footprinter.db import chats as db
|
|
7
|
+
from footprinter.services.access_service import (
|
|
8
|
+
filter_result,
|
|
9
|
+
filter_results_list,
|
|
10
|
+
strip_content_for_denied,
|
|
11
|
+
)
|
|
12
|
+
from footprinter.services.roles import Role
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get(conn: sqlite3.Connection, chat_id: int, *, role: Role = Role.ADMIN) -> dict | None:
|
|
16
|
+
"""Fetch a single chat with messages by ID, filtered by role."""
|
|
17
|
+
result = db.get_chat_detail(conn, chat_id)
|
|
18
|
+
if result is None:
|
|
19
|
+
return None
|
|
20
|
+
if role.sees_all:
|
|
21
|
+
return result
|
|
22
|
+
return filter_result("chat", result)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def assign(
|
|
26
|
+
conn: sqlite3.Connection,
|
|
27
|
+
chat_id: int,
|
|
28
|
+
*,
|
|
29
|
+
role: Role = Role.ADMIN,
|
|
30
|
+
project_id: int | None = None,
|
|
31
|
+
client_id: int | None = None,
|
|
32
|
+
) -> dict | None:
|
|
33
|
+
"""Assign a chat to a project and/or client.
|
|
34
|
+
|
|
35
|
+
Returns result dict on success, None if not found.
|
|
36
|
+
Raises PermissionError if role cannot write.
|
|
37
|
+
"""
|
|
38
|
+
if not role.can_write:
|
|
39
|
+
raise PermissionError("Role does not permit write operations")
|
|
40
|
+
result = db.update_chat_relationships(
|
|
41
|
+
conn,
|
|
42
|
+
chat_id,
|
|
43
|
+
project_id=project_id,
|
|
44
|
+
client_id=client_id,
|
|
45
|
+
)
|
|
46
|
+
if result is None:
|
|
47
|
+
return None
|
|
48
|
+
resp: dict = {"id": chat_id}
|
|
49
|
+
if project_id is not None:
|
|
50
|
+
resp["project_id"] = project_id
|
|
51
|
+
if client_id is not None:
|
|
52
|
+
resp["client_id"] = client_id
|
|
53
|
+
return resp
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_(
|
|
57
|
+
conn: sqlite3.Connection,
|
|
58
|
+
*,
|
|
59
|
+
role: Role = Role.ADMIN,
|
|
60
|
+
account: Optional[str] = None,
|
|
61
|
+
query: Optional[str] = None,
|
|
62
|
+
sort_by: str = "modified_at",
|
|
63
|
+
order: str = "desc",
|
|
64
|
+
status: Optional[str | list[str]] = None,
|
|
65
|
+
limit: int = 50,
|
|
66
|
+
page: int = 1,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""List chats with pagination, filtered by role."""
|
|
69
|
+
response = db.list_chats(
|
|
70
|
+
conn,
|
|
71
|
+
account=account,
|
|
72
|
+
query=query,
|
|
73
|
+
sort_by=sort_by,
|
|
74
|
+
order=order,
|
|
75
|
+
status=status,
|
|
76
|
+
limit=limit,
|
|
77
|
+
page=page,
|
|
78
|
+
)
|
|
79
|
+
if role.sees_all:
|
|
80
|
+
return response
|
|
81
|
+
filtered, suppressed = filter_results_list("chat", response["chats"])
|
|
82
|
+
filtered = strip_content_for_denied("chat", filtered)
|
|
83
|
+
response["chats"] = filtered
|
|
84
|
+
response["suppressed"] = suppressed
|
|
85
|
+
return response
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Client service — get/list with role-based visibility, upsert and soft delete."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from footprinter.db import clients as db
|
|
7
|
+
from footprinter.services.access_service import (
|
|
8
|
+
_read_visibility,
|
|
9
|
+
filter_result,
|
|
10
|
+
filter_results_list,
|
|
11
|
+
)
|
|
12
|
+
from footprinter.services.includes import validate_include
|
|
13
|
+
from footprinter.services.roles import Role
|
|
14
|
+
|
|
15
|
+
VALID_INCLUDES = frozenset({"projects", "aggregates"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_client_aggregates(client_name: str, conn: sqlite3.Connection, *, role: Role) -> dict:
|
|
19
|
+
"""Compute per-project file counts for a client, respecting visibility.
|
|
20
|
+
|
|
21
|
+
Derives aggregates from the visibility-filtered project list rather
|
|
22
|
+
than raw SQL, so hidden/opaque projects are excluded for non-admin roles.
|
|
23
|
+
"""
|
|
24
|
+
from footprinter.services import project_service
|
|
25
|
+
|
|
26
|
+
resp = project_service.list_(conn, role=role, client=client_name)
|
|
27
|
+
per_project = [
|
|
28
|
+
{
|
|
29
|
+
"project_id": p["id"],
|
|
30
|
+
"project_name": p["name"],
|
|
31
|
+
"file_count": p.get("file_count", 0),
|
|
32
|
+
}
|
|
33
|
+
for p in resp["projects"]
|
|
34
|
+
if "name" in p # Exclude opaque projects (minimal dicts lack "name")
|
|
35
|
+
]
|
|
36
|
+
return {
|
|
37
|
+
"project_count": len(per_project),
|
|
38
|
+
"file_count": sum(p["file_count"] for p in per_project),
|
|
39
|
+
"per_project": per_project,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get(
|
|
44
|
+
conn: sqlite3.Connection,
|
|
45
|
+
client_id: int,
|
|
46
|
+
*,
|
|
47
|
+
role: Role = Role.ADMIN,
|
|
48
|
+
include: list[str] | None = None,
|
|
49
|
+
) -> dict | None:
|
|
50
|
+
"""Fetch a single client by ID, filtered by role.
|
|
51
|
+
|
|
52
|
+
Pass ``include`` to attach nested data:
|
|
53
|
+
- ``"projects"`` — list of projects belonging to this client
|
|
54
|
+
- ``"aggregates"`` — file counts per project
|
|
55
|
+
"""
|
|
56
|
+
includes = validate_include(include, VALID_INCLUDES)
|
|
57
|
+
result = db.get_client(conn, client_id)
|
|
58
|
+
if result is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Strip nested data that db layer embeds by default
|
|
62
|
+
result.pop("projects", None)
|
|
63
|
+
result.pop("file_count", None)
|
|
64
|
+
|
|
65
|
+
# Attach includes only when caller has full access to this entity
|
|
66
|
+
is_full = role.sees_all or _read_visibility(result) == "visible"
|
|
67
|
+
if is_full and includes:
|
|
68
|
+
if "projects" in includes:
|
|
69
|
+
from footprinter.services import project_service
|
|
70
|
+
|
|
71
|
+
resp = project_service.list_(conn, role=role, client=result["name"])
|
|
72
|
+
result["projects"] = resp["projects"]
|
|
73
|
+
if "aggregates" in includes:
|
|
74
|
+
result["aggregates"] = _get_client_aggregates(
|
|
75
|
+
result["name"],
|
|
76
|
+
conn,
|
|
77
|
+
role=role,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if role.sees_all:
|
|
81
|
+
return result
|
|
82
|
+
return filter_result("client", result)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_(
|
|
86
|
+
conn: sqlite3.Connection,
|
|
87
|
+
*,
|
|
88
|
+
role: Role = Role.ADMIN,
|
|
89
|
+
include: list[str] | None = None,
|
|
90
|
+
status: Optional[str | list[str]] = None,
|
|
91
|
+
limit: int = 50,
|
|
92
|
+
page: int = 1,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""List clients with pagination, filtered by role."""
|
|
95
|
+
includes = validate_include(include, VALID_INCLUDES)
|
|
96
|
+
response = db.list_clients(conn, status=status, limit=limit, page=page)
|
|
97
|
+
|
|
98
|
+
# Track which items are fully visible before filtering strips fields
|
|
99
|
+
visible_ids: set[int] = set()
|
|
100
|
+
if includes and not role.sees_all:
|
|
101
|
+
visible_ids = {c["id"] for c in response["clients"] if _read_visibility(c) == "visible"}
|
|
102
|
+
|
|
103
|
+
if not role.sees_all:
|
|
104
|
+
filtered, suppressed = filter_results_list("client", response["clients"])
|
|
105
|
+
response["clients"] = filtered
|
|
106
|
+
response["suppressed"] = suppressed
|
|
107
|
+
|
|
108
|
+
if includes:
|
|
109
|
+
for client in response["clients"]:
|
|
110
|
+
# Only attach to fully-visible items (admin sees all)
|
|
111
|
+
if not role.sees_all and client["id"] not in visible_ids:
|
|
112
|
+
continue
|
|
113
|
+
if "projects" in includes:
|
|
114
|
+
from footprinter.services import project_service
|
|
115
|
+
|
|
116
|
+
resp = project_service.list_(conn, role=role, client=client["name"])
|
|
117
|
+
client["projects"] = resp["projects"]
|
|
118
|
+
if "aggregates" in includes:
|
|
119
|
+
client["aggregates"] = _get_client_aggregates(
|
|
120
|
+
client["name"],
|
|
121
|
+
conn,
|
|
122
|
+
role=role,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_by_name(
|
|
129
|
+
conn: sqlite3.Connection,
|
|
130
|
+
name: str,
|
|
131
|
+
*,
|
|
132
|
+
role: Role = Role.ADMIN,
|
|
133
|
+
) -> dict | None:
|
|
134
|
+
"""Resolve a client by fuzzy name match, with navigation data.
|
|
135
|
+
|
|
136
|
+
Returns full client dict with projects and aggregates for single match,
|
|
137
|
+
disambiguation dict for multiple ambiguous matches, or None.
|
|
138
|
+
"""
|
|
139
|
+
rows = db.find_by_name_fuzzy(conn, name)
|
|
140
|
+
if not rows:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Filter hidden for VIEWER
|
|
144
|
+
if not role.sees_all:
|
|
145
|
+
rows = [r for r in rows if _read_visibility(r) != "hidden"]
|
|
146
|
+
if not rows:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
if len(rows) == 1:
|
|
150
|
+
return _build_client_navigation(conn, rows[0], role=role)
|
|
151
|
+
|
|
152
|
+
# Check exact match
|
|
153
|
+
exact = [r for r in rows if r["name"].lower() == name.lower()]
|
|
154
|
+
if len(exact) == 1:
|
|
155
|
+
return _build_client_navigation(conn, exact[0], role=role)
|
|
156
|
+
|
|
157
|
+
# Disambiguation
|
|
158
|
+
from footprinter.services.access_service import resolve_inherit_visibility
|
|
159
|
+
|
|
160
|
+
matches = []
|
|
161
|
+
for r in rows:
|
|
162
|
+
vis = resolve_inherit_visibility(r.get("mcp_view"))
|
|
163
|
+
if vis == "opaque":
|
|
164
|
+
matches.append({"id": r["id"], "visibility": "restricted"})
|
|
165
|
+
else:
|
|
166
|
+
matches.append({"id": r["id"], "name": r["name"]})
|
|
167
|
+
return {
|
|
168
|
+
"disambiguation": True,
|
|
169
|
+
"message": f"Multiple matches for '{name}'. Please be more specific.",
|
|
170
|
+
"matches": matches,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_client_navigation(conn: sqlite3.Connection, row: dict, *, role: Role) -> dict:
|
|
175
|
+
"""Build full client navigation dict from a client row."""
|
|
176
|
+
visibility = _read_visibility(row)
|
|
177
|
+
if not role.sees_all and visibility == "opaque":
|
|
178
|
+
return filter_result("client", row)
|
|
179
|
+
|
|
180
|
+
# Get projects for this client (hidden filtered)
|
|
181
|
+
from footprinter.services import project_service
|
|
182
|
+
|
|
183
|
+
proj_resp = project_service.list_(conn, role=role, client=row["name"])
|
|
184
|
+
projects = proj_resp["projects"]
|
|
185
|
+
|
|
186
|
+
result = {**row}
|
|
187
|
+
result["projects"] = projects
|
|
188
|
+
|
|
189
|
+
# Aggregate stats across all projects
|
|
190
|
+
project_ids = [p["id"] for p in projects if "id" in p]
|
|
191
|
+
nav = db.get_client_navigation(conn, row["id"], project_ids)
|
|
192
|
+
result.update(nav)
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def upsert(
|
|
198
|
+
conn: sqlite3.Connection,
|
|
199
|
+
*,
|
|
200
|
+
name: str,
|
|
201
|
+
client_type: str,
|
|
202
|
+
role: Role = Role.ADMIN,
|
|
203
|
+
path_pattern: Optional[str] = None,
|
|
204
|
+
status: Optional[str] = None,
|
|
205
|
+
status_reason: Optional[str] = None,
|
|
206
|
+
slug: Optional[str] = None,
|
|
207
|
+
) -> dict:
|
|
208
|
+
"""Insert or update a client by name.
|
|
209
|
+
|
|
210
|
+
Matches on ``name`` (UNIQUE constraint). Returns dict with ``id``,
|
|
211
|
+
``action`` ("created"|"updated"), and ``slug`` on create.
|
|
212
|
+
Raises PermissionError if role cannot write, ValueError on bad input.
|
|
213
|
+
"""
|
|
214
|
+
if not role.can_write:
|
|
215
|
+
raise PermissionError("Role does not permit write operations")
|
|
216
|
+
|
|
217
|
+
name = (name or "").strip()
|
|
218
|
+
if not name:
|
|
219
|
+
raise ValueError("Name cannot be empty")
|
|
220
|
+
|
|
221
|
+
existing_id = db.find_client_id_by_name(conn, name)
|
|
222
|
+
|
|
223
|
+
if existing_id is None:
|
|
224
|
+
result = db.create_client(
|
|
225
|
+
conn,
|
|
226
|
+
name=name,
|
|
227
|
+
client_type=client_type,
|
|
228
|
+
path_pattern=path_pattern,
|
|
229
|
+
)
|
|
230
|
+
new_id = result["id"]
|
|
231
|
+
# Apply optional fields that create_client doesn't accept
|
|
232
|
+
post_update: dict = {}
|
|
233
|
+
if status is not None:
|
|
234
|
+
post_update["status"] = status
|
|
235
|
+
if post_update:
|
|
236
|
+
db.update_client(conn, new_id, **post_update)
|
|
237
|
+
return {"id": new_id, "slug": result["slug"], "action": "created"}
|
|
238
|
+
|
|
239
|
+
update_fields: dict = {"client_type": client_type}
|
|
240
|
+
if path_pattern is not None:
|
|
241
|
+
update_fields["path_pattern"] = path_pattern
|
|
242
|
+
if status is not None:
|
|
243
|
+
update_fields["status"] = status
|
|
244
|
+
if status_reason is not None:
|
|
245
|
+
update_fields["status_reason"] = status_reason
|
|
246
|
+
db.update_client(conn, existing_id, **update_fields)
|
|
247
|
+
return {"id": existing_id, "action": "updated"}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def delete(
|
|
251
|
+
conn: sqlite3.Connection,
|
|
252
|
+
client_id: int,
|
|
253
|
+
*,
|
|
254
|
+
role: Role = Role.ADMIN,
|
|
255
|
+
) -> dict | None:
|
|
256
|
+
"""Soft-delete a client by setting status to 'removed'.
|
|
257
|
+
|
|
258
|
+
Returns ``{"id", "status"}`` on success, ``None`` if not found.
|
|
259
|
+
Raises PermissionError if role cannot write.
|
|
260
|
+
"""
|
|
261
|
+
if not role.can_write:
|
|
262
|
+
raise PermissionError("Role does not permit write operations")
|
|
263
|
+
|
|
264
|
+
result = db.update_client(conn, client_id, status="removed", status_reason="cli:delete")
|
|
265
|
+
if result is None:
|
|
266
|
+
return None
|
|
267
|
+
return {"id": client_id, "status": "removed"}
|