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,1152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Permission resolution for Claude read access.
|
|
3
|
+
|
|
4
|
+
Two-tier model:
|
|
5
|
+
- Policies: Explicit rules (file:*, folder:*, source:*, project:*, client:*)
|
|
6
|
+
- Baseline: Hardcoded fallback (BASELINE_PERMISSION = True)
|
|
7
|
+
|
|
8
|
+
Deny-wins semantics applies ONLY among matching policies.
|
|
9
|
+
If no policies match, the baseline is used.
|
|
10
|
+
|
|
11
|
+
Hierarchy layers (checked for policies):
|
|
12
|
+
file:{id} → folder prefix → project:{id} → client:{id} → source:*
|
|
13
|
+
email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
|
|
14
|
+
chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
|
|
15
|
+
|
|
16
|
+
Resolution:
|
|
17
|
+
1. Collect explicit values from matching policies only
|
|
18
|
+
2. If any policy is 'deny' → return False
|
|
19
|
+
3. If any policy is 'allow' → return True
|
|
20
|
+
4. No policies matched → return BASELINE_PERMISSION
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sqlite3
|
|
25
|
+
from typing import Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from footprinter.db.policies import is_folder_path_scope
|
|
28
|
+
from footprinter.db.sql_utils import chunked_query as _chunked_query
|
|
29
|
+
|
|
30
|
+
# Hardcoded baseline - used when NO policies match
|
|
31
|
+
BASELINE_PERMISSION = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def can_read(conn: sqlite3.Connection, item_type: str, item_id: int) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Resolve whether Claude can read this item.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
40
|
+
item_type: 'file', 'email', 'chat'
|
|
41
|
+
item_id: Row ID in the relevant table
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if reading is permitted, False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
cursor = conn.cursor()
|
|
47
|
+
|
|
48
|
+
if item_type == "file":
|
|
49
|
+
return _can_read_file(cursor, item_id)
|
|
50
|
+
elif item_type == "email":
|
|
51
|
+
return _can_read_email(cursor, item_id)
|
|
52
|
+
elif item_type == "chat":
|
|
53
|
+
return _can_read_chat(cursor, item_id)
|
|
54
|
+
elif item_type == "visit":
|
|
55
|
+
return _can_read_browser(cursor, item_id)
|
|
56
|
+
else:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_permission_with_source(conn: sqlite3.Connection, item_type: str, item_id: int) -> Tuple[bool, str]:
|
|
61
|
+
"""
|
|
62
|
+
Resolve permission and return the source that determined it.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
66
|
+
item_type: 'file', 'email', 'chat'
|
|
67
|
+
item_id: Row ID in the relevant table
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (resolved_permission, source_scope)
|
|
71
|
+
e.g., (True, "folder:~/Work") or (False, "baseline")
|
|
72
|
+
"""
|
|
73
|
+
cursor = conn.cursor()
|
|
74
|
+
|
|
75
|
+
if item_type == "file":
|
|
76
|
+
return _resolve_file_with_source(cursor, item_id)
|
|
77
|
+
elif item_type == "email":
|
|
78
|
+
return _resolve_email_with_source(cursor, item_id)
|
|
79
|
+
elif item_type == "chat":
|
|
80
|
+
return _resolve_chat_with_source(cursor, item_id)
|
|
81
|
+
elif item_type == "project":
|
|
82
|
+
return _resolve_project_permission_with_source(cursor, item_id)
|
|
83
|
+
elif item_type == "client":
|
|
84
|
+
return _resolve_client_permission_with_source(cursor, item_id)
|
|
85
|
+
elif item_type == "folder":
|
|
86
|
+
return _resolve_folder_permission_with_source(cursor, item_id)
|
|
87
|
+
elif item_type == "visit":
|
|
88
|
+
return _resolve_browser_with_source(cursor, item_id)
|
|
89
|
+
else:
|
|
90
|
+
return (False, "baseline")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def batch_resolve_permissions(
|
|
94
|
+
conn: sqlite3.Connection, item_type: str, item_ids: List[int]
|
|
95
|
+
) -> Dict[int, Tuple[bool, str]]:
|
|
96
|
+
"""
|
|
97
|
+
Resolve permissions for multiple items efficiently.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
conn: SQLite connection with row_factory = sqlite3.Row
|
|
101
|
+
item_type: 'file', 'email', 'chat', 'project', 'client'
|
|
102
|
+
item_ids: List of row IDs
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Dict mapping item_id to (allowed, source) tuple
|
|
106
|
+
"""
|
|
107
|
+
if not item_ids:
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
cursor = conn.cursor()
|
|
111
|
+
|
|
112
|
+
if item_type == "file":
|
|
113
|
+
return _batch_resolve_file_permissions(cursor, item_ids)
|
|
114
|
+
elif item_type == "project":
|
|
115
|
+
return _batch_resolve_project_permissions(cursor, item_ids)
|
|
116
|
+
elif item_type == "client":
|
|
117
|
+
return _batch_resolve_client_permissions(cursor, item_ids)
|
|
118
|
+
elif item_type == "email":
|
|
119
|
+
return _batch_resolve_email_permissions(cursor, item_ids)
|
|
120
|
+
elif item_type == "chat":
|
|
121
|
+
return _batch_resolve_chat_permissions(cursor, item_ids)
|
|
122
|
+
elif item_type == "folder":
|
|
123
|
+
return _batch_resolve_folder_permissions(cursor, item_ids)
|
|
124
|
+
elif item_type == "visit":
|
|
125
|
+
return _batch_resolve_browser_permissions(cursor, item_ids)
|
|
126
|
+
else:
|
|
127
|
+
return {id_: (False, "baseline") for id_ in item_ids}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _batch_resolve_file_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
131
|
+
"""Batch resolve permissions for files."""
|
|
132
|
+
conn = cursor.connection
|
|
133
|
+
|
|
134
|
+
# Pre-fetch all permission policies
|
|
135
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
136
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
137
|
+
|
|
138
|
+
# Global policy fallback (used when no specific policies match)
|
|
139
|
+
if "global" in all_policies:
|
|
140
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
141
|
+
else:
|
|
142
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
143
|
+
|
|
144
|
+
# Pre-fetch folder policies sorted by length for prefix matching
|
|
145
|
+
# Exclude numeric-only suffixes (folder:{id}) — those are item-level scopes, not paths
|
|
146
|
+
folder_policies = [
|
|
147
|
+
(scope, setting)
|
|
148
|
+
for scope, setting in all_policies.items()
|
|
149
|
+
if scope.startswith("folder:") and is_folder_path_scope(scope)
|
|
150
|
+
]
|
|
151
|
+
folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
|
|
152
|
+
|
|
153
|
+
# Pre-fetch file data (chunked to stay under SQLite variable limit)
|
|
154
|
+
rows = _chunked_query(
|
|
155
|
+
cursor,
|
|
156
|
+
"""
|
|
157
|
+
SELECT file.id, file.path, file.project_id, project.client_id
|
|
158
|
+
FROM files file
|
|
159
|
+
LEFT JOIN projects project ON file.project_id = project.id
|
|
160
|
+
WHERE file.id IN ({placeholders})
|
|
161
|
+
""",
|
|
162
|
+
item_ids,
|
|
163
|
+
)
|
|
164
|
+
files = {row["id"]: row for row in rows}
|
|
165
|
+
|
|
166
|
+
# Collect unique parent entity IDs for batch resolution
|
|
167
|
+
project_ids = set()
|
|
168
|
+
client_ids = set()
|
|
169
|
+
for row in files.values():
|
|
170
|
+
if row["project_id"]:
|
|
171
|
+
project_ids.add(row["project_id"])
|
|
172
|
+
if row["client_id"]:
|
|
173
|
+
client_ids.add(row["client_id"])
|
|
174
|
+
|
|
175
|
+
# Batch resolve parent entities
|
|
176
|
+
project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
|
|
177
|
+
client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
|
|
178
|
+
|
|
179
|
+
results = {}
|
|
180
|
+
for file_id in item_ids:
|
|
181
|
+
if file_id not in files:
|
|
182
|
+
results[file_id] = (False, "not_found")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
row = files[file_id]
|
|
186
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
187
|
+
|
|
188
|
+
# 1. Item-level policy
|
|
189
|
+
item_scope = f"file:{file_id}"
|
|
190
|
+
if item_scope in all_policies:
|
|
191
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
192
|
+
|
|
193
|
+
# 2. Folder prefix match (most specific first)
|
|
194
|
+
path = row["path"] or ""
|
|
195
|
+
if path:
|
|
196
|
+
for scope, setting in folder_policies:
|
|
197
|
+
prefix = scope[len("folder:") :]
|
|
198
|
+
if prefix.startswith("~"):
|
|
199
|
+
prefix = os.path.expanduser(prefix)
|
|
200
|
+
if path.startswith(prefix):
|
|
201
|
+
policies.append((_resolve(setting), scope))
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# 3. Project-level via full resolution (skip baseline)
|
|
205
|
+
project_id = row["project_id"]
|
|
206
|
+
if project_id and project_id in project_permissions:
|
|
207
|
+
allowed, src = project_permissions[project_id]
|
|
208
|
+
if src != "baseline":
|
|
209
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
210
|
+
|
|
211
|
+
# 4. Client-level via full resolution (skip baseline)
|
|
212
|
+
client_id = row["client_id"]
|
|
213
|
+
if client_id and client_id in client_permissions:
|
|
214
|
+
allowed, src = client_permissions[client_id]
|
|
215
|
+
if src != "baseline":
|
|
216
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
217
|
+
|
|
218
|
+
# 5. Source policy
|
|
219
|
+
source_scope = "source:files"
|
|
220
|
+
if source_scope in all_policies:
|
|
221
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
222
|
+
|
|
223
|
+
# Resolve: deny wins
|
|
224
|
+
for value, source in policies:
|
|
225
|
+
if value is False:
|
|
226
|
+
results[file_id] = (False, source)
|
|
227
|
+
break
|
|
228
|
+
else:
|
|
229
|
+
for value, source in policies:
|
|
230
|
+
if value is True:
|
|
231
|
+
results[file_id] = (True, source)
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
results[file_id] = global_baseline
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _batch_resolve_project_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
240
|
+
"""Batch resolve permissions for projects."""
|
|
241
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
242
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
243
|
+
|
|
244
|
+
# Global policy fallback
|
|
245
|
+
if "global" in all_policies:
|
|
246
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
247
|
+
else:
|
|
248
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
249
|
+
|
|
250
|
+
# Pre-fetch project data for client_id (chunked)
|
|
251
|
+
rows = _chunked_query(
|
|
252
|
+
cursor,
|
|
253
|
+
"SELECT id, client_id FROM projects WHERE id IN ({placeholders})",
|
|
254
|
+
item_ids,
|
|
255
|
+
)
|
|
256
|
+
projects = {row["id"]: row for row in rows}
|
|
257
|
+
|
|
258
|
+
results = {}
|
|
259
|
+
for project_id in item_ids:
|
|
260
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
261
|
+
|
|
262
|
+
# 1. Project-level policy
|
|
263
|
+
proj_scope = f"project:{project_id}"
|
|
264
|
+
if proj_scope in all_policies:
|
|
265
|
+
policies.append((_resolve(all_policies[proj_scope]), proj_scope))
|
|
266
|
+
|
|
267
|
+
# 2. Client-level policy
|
|
268
|
+
if project_id in projects and projects[project_id]["client_id"]:
|
|
269
|
+
client_scope = f"client:{projects[project_id]['client_id']}"
|
|
270
|
+
if client_scope in all_policies:
|
|
271
|
+
policies.append((_resolve(all_policies[client_scope]), client_scope))
|
|
272
|
+
|
|
273
|
+
# 3. Source policy for projects
|
|
274
|
+
source_scope = "source:projects"
|
|
275
|
+
if source_scope in all_policies:
|
|
276
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
277
|
+
|
|
278
|
+
# Resolve: deny wins
|
|
279
|
+
for value, source in policies:
|
|
280
|
+
if value is False:
|
|
281
|
+
results[project_id] = (False, source)
|
|
282
|
+
break
|
|
283
|
+
else:
|
|
284
|
+
for value, source in policies:
|
|
285
|
+
if value is True:
|
|
286
|
+
results[project_id] = (True, source)
|
|
287
|
+
break
|
|
288
|
+
else:
|
|
289
|
+
results[project_id] = global_baseline
|
|
290
|
+
|
|
291
|
+
return results
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _batch_resolve_client_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
295
|
+
"""Batch resolve permissions for clients."""
|
|
296
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
297
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
298
|
+
|
|
299
|
+
# Global policy fallback
|
|
300
|
+
if "global" in all_policies:
|
|
301
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
302
|
+
else:
|
|
303
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
304
|
+
|
|
305
|
+
results = {}
|
|
306
|
+
for client_id in item_ids:
|
|
307
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
308
|
+
|
|
309
|
+
# 1. Client-level policy
|
|
310
|
+
client_scope = f"client:{client_id}"
|
|
311
|
+
if client_scope in all_policies:
|
|
312
|
+
policies.append((_resolve(all_policies[client_scope]), client_scope))
|
|
313
|
+
|
|
314
|
+
# 2. Source policy for clients
|
|
315
|
+
source_scope = "source:clients"
|
|
316
|
+
if source_scope in all_policies:
|
|
317
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
318
|
+
|
|
319
|
+
# Resolve: deny wins
|
|
320
|
+
for value, source in policies:
|
|
321
|
+
if value is False:
|
|
322
|
+
results[client_id] = (False, source)
|
|
323
|
+
break
|
|
324
|
+
else:
|
|
325
|
+
for value, source in policies:
|
|
326
|
+
if value is True:
|
|
327
|
+
results[client_id] = (True, source)
|
|
328
|
+
break
|
|
329
|
+
else:
|
|
330
|
+
results[client_id] = global_baseline
|
|
331
|
+
|
|
332
|
+
return results
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _batch_resolve_email_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
336
|
+
"""Batch resolve permissions for emails."""
|
|
337
|
+
conn = cursor.connection
|
|
338
|
+
|
|
339
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
340
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
341
|
+
|
|
342
|
+
# Global policy fallback
|
|
343
|
+
if "global" in all_policies:
|
|
344
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
345
|
+
else:
|
|
346
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
347
|
+
|
|
348
|
+
rows = _chunked_query(
|
|
349
|
+
cursor,
|
|
350
|
+
"""
|
|
351
|
+
SELECT email.id, email.account, email.project_id, project.client_id
|
|
352
|
+
FROM emails email
|
|
353
|
+
LEFT JOIN projects project ON email.project_id = project.id
|
|
354
|
+
WHERE email.id IN ({placeholders})
|
|
355
|
+
""",
|
|
356
|
+
item_ids,
|
|
357
|
+
)
|
|
358
|
+
emails = {row["id"]: row for row in rows}
|
|
359
|
+
|
|
360
|
+
# Collect unique parent entity IDs for batch resolution
|
|
361
|
+
project_ids = set()
|
|
362
|
+
client_ids = set()
|
|
363
|
+
for row in emails.values():
|
|
364
|
+
if row["project_id"]:
|
|
365
|
+
project_ids.add(row["project_id"])
|
|
366
|
+
if row["client_id"]:
|
|
367
|
+
client_ids.add(row["client_id"])
|
|
368
|
+
|
|
369
|
+
# Batch resolve parent entities
|
|
370
|
+
project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
|
|
371
|
+
client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
|
|
372
|
+
|
|
373
|
+
results = {}
|
|
374
|
+
for email_id in item_ids:
|
|
375
|
+
if email_id not in emails:
|
|
376
|
+
results[email_id] = (False, "not_found")
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
row = emails[email_id]
|
|
380
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
381
|
+
|
|
382
|
+
# 1. Item-level policy
|
|
383
|
+
item_scope = f"email:{email_id}"
|
|
384
|
+
if item_scope in all_policies:
|
|
385
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
386
|
+
|
|
387
|
+
# 2. Project-level via full resolution (skip baseline)
|
|
388
|
+
project_id = row["project_id"]
|
|
389
|
+
if project_id and project_id in project_permissions:
|
|
390
|
+
allowed, src = project_permissions[project_id]
|
|
391
|
+
if src != "baseline":
|
|
392
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
393
|
+
|
|
394
|
+
# 3. Client-level via full resolution (skip baseline)
|
|
395
|
+
client_id = row["client_id"]
|
|
396
|
+
if client_id and client_id in client_permissions:
|
|
397
|
+
allowed, src = client_permissions[client_id]
|
|
398
|
+
if src != "baseline":
|
|
399
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
400
|
+
|
|
401
|
+
# 4. Account-level policy
|
|
402
|
+
account = row["account"] or ""
|
|
403
|
+
if account:
|
|
404
|
+
acct_scope = f"account:{account}"
|
|
405
|
+
if acct_scope in all_policies:
|
|
406
|
+
policies.append((_resolve(all_policies[acct_scope]), acct_scope))
|
|
407
|
+
|
|
408
|
+
# 5. Source policy
|
|
409
|
+
source_scope = "source:emails"
|
|
410
|
+
if source_scope in all_policies:
|
|
411
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
412
|
+
|
|
413
|
+
# Resolve: deny wins
|
|
414
|
+
for value, source in policies:
|
|
415
|
+
if value is False:
|
|
416
|
+
results[email_id] = (False, source)
|
|
417
|
+
break
|
|
418
|
+
else:
|
|
419
|
+
for value, source in policies:
|
|
420
|
+
if value is True:
|
|
421
|
+
results[email_id] = (True, source)
|
|
422
|
+
break
|
|
423
|
+
else:
|
|
424
|
+
results[email_id] = global_baseline
|
|
425
|
+
|
|
426
|
+
return results
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _batch_resolve_chat_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
430
|
+
"""Batch resolve permissions for chats."""
|
|
431
|
+
conn = cursor.connection
|
|
432
|
+
|
|
433
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
434
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
435
|
+
|
|
436
|
+
# Global policy fallback
|
|
437
|
+
if "global" in all_policies:
|
|
438
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
439
|
+
else:
|
|
440
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
441
|
+
|
|
442
|
+
rows = _chunked_query(
|
|
443
|
+
cursor,
|
|
444
|
+
"""
|
|
445
|
+
SELECT chat.id, chat.account, chat.project_id, project.client_id
|
|
446
|
+
FROM chats chat
|
|
447
|
+
LEFT JOIN projects project ON chat.project_id = project.id
|
|
448
|
+
WHERE chat.id IN ({placeholders})
|
|
449
|
+
""",
|
|
450
|
+
item_ids,
|
|
451
|
+
)
|
|
452
|
+
convs = {row["id"]: row for row in rows}
|
|
453
|
+
|
|
454
|
+
# Collect unique parent entity IDs for batch resolution
|
|
455
|
+
project_ids = set()
|
|
456
|
+
client_ids = set()
|
|
457
|
+
for row in convs.values():
|
|
458
|
+
if row["project_id"]:
|
|
459
|
+
project_ids.add(row["project_id"])
|
|
460
|
+
if row["client_id"]:
|
|
461
|
+
client_ids.add(row["client_id"])
|
|
462
|
+
|
|
463
|
+
# Batch resolve parent entities
|
|
464
|
+
project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
|
|
465
|
+
client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
|
|
466
|
+
|
|
467
|
+
results = {}
|
|
468
|
+
for chat_id in item_ids:
|
|
469
|
+
if chat_id not in convs:
|
|
470
|
+
results[chat_id] = (False, "not_found")
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
row = convs[chat_id]
|
|
474
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
475
|
+
|
|
476
|
+
# 1. Item-level policy
|
|
477
|
+
item_scope = f"chat:{chat_id}"
|
|
478
|
+
if item_scope in all_policies:
|
|
479
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
480
|
+
|
|
481
|
+
# 2. Project-level via full resolution (skip baseline)
|
|
482
|
+
project_id = row["project_id"]
|
|
483
|
+
if project_id and project_id in project_permissions:
|
|
484
|
+
allowed, src = project_permissions[project_id]
|
|
485
|
+
if src != "baseline":
|
|
486
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
487
|
+
|
|
488
|
+
# 3. Client-level via full resolution (skip baseline)
|
|
489
|
+
client_id = row["client_id"]
|
|
490
|
+
if client_id and client_id in client_permissions:
|
|
491
|
+
allowed, src = client_permissions[client_id]
|
|
492
|
+
if src != "baseline":
|
|
493
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
494
|
+
|
|
495
|
+
# 4. Account-level policy
|
|
496
|
+
account = row["account"] or ""
|
|
497
|
+
if account:
|
|
498
|
+
acct_scope = f"account:{account}"
|
|
499
|
+
if acct_scope in all_policies:
|
|
500
|
+
policies.append((_resolve(all_policies[acct_scope]), acct_scope))
|
|
501
|
+
|
|
502
|
+
# 5. Source policy
|
|
503
|
+
source_scope = "source:chats"
|
|
504
|
+
if source_scope in all_policies:
|
|
505
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
506
|
+
|
|
507
|
+
# Resolve: deny wins
|
|
508
|
+
for value, source in policies:
|
|
509
|
+
if value is False:
|
|
510
|
+
results[chat_id] = (False, source)
|
|
511
|
+
break
|
|
512
|
+
else:
|
|
513
|
+
for value, source in policies:
|
|
514
|
+
if value is True:
|
|
515
|
+
results[chat_id] = (True, source)
|
|
516
|
+
break
|
|
517
|
+
else:
|
|
518
|
+
results[chat_id] = global_baseline
|
|
519
|
+
|
|
520
|
+
return results
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _batch_resolve_folder_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
524
|
+
"""Batch resolve permissions for folders.
|
|
525
|
+
|
|
526
|
+
Resolution chain: folder:{id} → folder prefix → project:{id} → client:{id} → source:folders
|
|
527
|
+
"""
|
|
528
|
+
conn = cursor.connection
|
|
529
|
+
|
|
530
|
+
cursor.execute("SELECT scope, setting FROM permission_policies")
|
|
531
|
+
all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
532
|
+
|
|
533
|
+
# Global policy fallback
|
|
534
|
+
if "global" in all_policies:
|
|
535
|
+
global_baseline = (_resolve(all_policies["global"]), "global")
|
|
536
|
+
else:
|
|
537
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
538
|
+
|
|
539
|
+
folder_policies = [
|
|
540
|
+
(scope, setting)
|
|
541
|
+
for scope, setting in all_policies.items()
|
|
542
|
+
if scope.startswith("folder:") and is_folder_path_scope(scope)
|
|
543
|
+
]
|
|
544
|
+
folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
|
|
545
|
+
|
|
546
|
+
rows = _chunked_query(
|
|
547
|
+
cursor,
|
|
548
|
+
"""
|
|
549
|
+
SELECT folder.id, folder.path, folder.project_id, project.client_id
|
|
550
|
+
FROM folders folder
|
|
551
|
+
LEFT JOIN projects project ON folder.project_id = project.id
|
|
552
|
+
WHERE folder.id IN ({placeholders})
|
|
553
|
+
""",
|
|
554
|
+
item_ids,
|
|
555
|
+
)
|
|
556
|
+
folders = {row["id"]: row for row in rows}
|
|
557
|
+
|
|
558
|
+
# Collect unique parent entity IDs for batch resolution
|
|
559
|
+
project_ids = set()
|
|
560
|
+
client_ids = set()
|
|
561
|
+
for row in folders.values():
|
|
562
|
+
if row["project_id"]:
|
|
563
|
+
project_ids.add(row["project_id"])
|
|
564
|
+
if row["client_id"]:
|
|
565
|
+
client_ids.add(row["client_id"])
|
|
566
|
+
|
|
567
|
+
# Batch resolve parent entities
|
|
568
|
+
project_permissions = batch_resolve_permissions(conn, "project", list(project_ids)) if project_ids else {}
|
|
569
|
+
client_permissions = batch_resolve_permissions(conn, "client", list(client_ids)) if client_ids else {}
|
|
570
|
+
|
|
571
|
+
results = {}
|
|
572
|
+
for folder_id in item_ids:
|
|
573
|
+
if folder_id not in folders:
|
|
574
|
+
results[folder_id] = (False, "not_found")
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
row = folders[folder_id]
|
|
578
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
579
|
+
|
|
580
|
+
# 1. Item-level policy
|
|
581
|
+
item_scope = f"folder:{folder_id}"
|
|
582
|
+
if item_scope in all_policies:
|
|
583
|
+
policies.append((_resolve(all_policies[item_scope]), item_scope))
|
|
584
|
+
|
|
585
|
+
# 2. Folder prefix match
|
|
586
|
+
path = row["path"] or ""
|
|
587
|
+
if path:
|
|
588
|
+
for scope, setting in folder_policies:
|
|
589
|
+
prefix = scope[len("folder:") :]
|
|
590
|
+
if prefix.startswith("~"):
|
|
591
|
+
prefix = os.path.expanduser(prefix)
|
|
592
|
+
if path.startswith(prefix):
|
|
593
|
+
policies.append((_resolve(setting), scope))
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
# 3. Project-level via full resolution (skip baseline)
|
|
597
|
+
project_id = row["project_id"]
|
|
598
|
+
if project_id and project_id in project_permissions:
|
|
599
|
+
allowed, src = project_permissions[project_id]
|
|
600
|
+
if src != "baseline":
|
|
601
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
602
|
+
|
|
603
|
+
# 4. Client-level via full resolution (skip baseline)
|
|
604
|
+
client_id = row["client_id"]
|
|
605
|
+
if client_id and client_id in client_permissions:
|
|
606
|
+
allowed, src = client_permissions[client_id]
|
|
607
|
+
if src != "baseline":
|
|
608
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
609
|
+
|
|
610
|
+
# 5. Source policy
|
|
611
|
+
source_scope = "source:folders"
|
|
612
|
+
if source_scope in all_policies:
|
|
613
|
+
policies.append((_resolve(all_policies[source_scope]), source_scope))
|
|
614
|
+
|
|
615
|
+
# Resolve: deny wins
|
|
616
|
+
for value, source in policies:
|
|
617
|
+
if value is False:
|
|
618
|
+
results[folder_id] = (False, source)
|
|
619
|
+
break
|
|
620
|
+
else:
|
|
621
|
+
for value, source in policies:
|
|
622
|
+
if value is True:
|
|
623
|
+
results[folder_id] = (True, source)
|
|
624
|
+
break
|
|
625
|
+
else:
|
|
626
|
+
results[folder_id] = global_baseline
|
|
627
|
+
|
|
628
|
+
return results
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _resolve(value: Optional[str]) -> Optional[bool]:
|
|
632
|
+
"""Convert a permission value to bool or None (no policy)."""
|
|
633
|
+
if value == "allow":
|
|
634
|
+
return True
|
|
635
|
+
if value == "deny":
|
|
636
|
+
return False
|
|
637
|
+
return None # 'inherit' or NULL means no policy
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _get_policy(cursor, scope: str) -> Optional[bool]:
|
|
641
|
+
"""Look up a permission_policies row."""
|
|
642
|
+
cursor.execute("SELECT setting FROM permission_policies WHERE scope = ?", (scope,))
|
|
643
|
+
row = cursor.fetchone()
|
|
644
|
+
if row:
|
|
645
|
+
return _resolve(row["setting"])
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _get_global_baseline(cursor) -> Tuple[bool, str]:
|
|
650
|
+
"""Get global policy or fall back to hardcoded baseline."""
|
|
651
|
+
row = cursor.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
|
|
652
|
+
if row:
|
|
653
|
+
return (_resolve(row["setting"]), "global")
|
|
654
|
+
return (BASELINE_PERMISSION, "baseline")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _resolve_parent_permission_with_source(
|
|
658
|
+
conn: sqlite3.Connection, item_type: str, item_id: int
|
|
659
|
+
) -> Optional[Tuple[bool, str]]:
|
|
660
|
+
"""Resolve parent entity permission, returning None if baseline.
|
|
661
|
+
|
|
662
|
+
This is used when resolving file permissions to check parent
|
|
663
|
+
entities (project, client). If the parent resolves to baseline,
|
|
664
|
+
we return None so that baseline doesn't propagate down the hierarchy.
|
|
665
|
+
"""
|
|
666
|
+
allowed, source = resolve_permission_with_source(conn, item_type, item_id)
|
|
667
|
+
if source == "baseline":
|
|
668
|
+
return None
|
|
669
|
+
return (allowed, source)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _can_read_file(cursor, file_id: int) -> bool:
|
|
673
|
+
"""Resolve read permission for a file using policies."""
|
|
674
|
+
resolved, _ = _resolve_file_with_source(cursor, file_id)
|
|
675
|
+
return resolved
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _resolve_file_with_source(cursor, file_id: int) -> Tuple[bool, str]:
|
|
679
|
+
"""Resolve file permission with source tracking."""
|
|
680
|
+
cursor.execute(
|
|
681
|
+
"""
|
|
682
|
+
SELECT file.path, file.project_id, project.client_id
|
|
683
|
+
FROM files file
|
|
684
|
+
LEFT JOIN projects project ON file.project_id = project.id
|
|
685
|
+
WHERE file.id = ?
|
|
686
|
+
""",
|
|
687
|
+
(file_id,),
|
|
688
|
+
)
|
|
689
|
+
row = cursor.fetchone()
|
|
690
|
+
if not row:
|
|
691
|
+
return (False, "not_found")
|
|
692
|
+
|
|
693
|
+
# Collect matching policies only (not baseline)
|
|
694
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
695
|
+
|
|
696
|
+
# 1. Item-level policy (file:{id})
|
|
697
|
+
item_policy = _get_policy(cursor, f"file:{file_id}")
|
|
698
|
+
if item_policy is not None:
|
|
699
|
+
policies.append((item_policy, f"file:{file_id}"))
|
|
700
|
+
|
|
701
|
+
# 2. Folder prefix match (most specific first)
|
|
702
|
+
path = row["path"] or ""
|
|
703
|
+
if path:
|
|
704
|
+
cursor.execute(
|
|
705
|
+
"""
|
|
706
|
+
SELECT scope, setting FROM permission_policies
|
|
707
|
+
WHERE scope LIKE 'folder:%'
|
|
708
|
+
ORDER BY LENGTH(scope) DESC
|
|
709
|
+
"""
|
|
710
|
+
)
|
|
711
|
+
for folder_row in cursor.fetchall():
|
|
712
|
+
if not is_folder_path_scope(folder_row["scope"]):
|
|
713
|
+
continue
|
|
714
|
+
prefix = folder_row["scope"][len("folder:") :]
|
|
715
|
+
if prefix.startswith("~"):
|
|
716
|
+
prefix = os.path.expanduser(prefix)
|
|
717
|
+
if path.startswith(prefix):
|
|
718
|
+
policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
|
|
719
|
+
break # Only use most specific folder match
|
|
720
|
+
|
|
721
|
+
# 3. Project-level via full resolution (project:{id}) - skip baseline
|
|
722
|
+
project_id = row["project_id"]
|
|
723
|
+
if project_id:
|
|
724
|
+
conn = cursor.connection
|
|
725
|
+
result = _resolve_parent_permission_with_source(conn, "project", project_id)
|
|
726
|
+
if result:
|
|
727
|
+
allowed, src = result
|
|
728
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
729
|
+
|
|
730
|
+
# 4. Client-level via full resolution (client:{id}) - skip baseline
|
|
731
|
+
client_id = row["client_id"]
|
|
732
|
+
if client_id:
|
|
733
|
+
conn = cursor.connection
|
|
734
|
+
result = _resolve_parent_permission_with_source(conn, "client", client_id)
|
|
735
|
+
if result:
|
|
736
|
+
allowed, src = result
|
|
737
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
738
|
+
|
|
739
|
+
# 5. Source policy
|
|
740
|
+
source_policy = _get_policy(cursor, "source:files")
|
|
741
|
+
if source_policy is not None:
|
|
742
|
+
policies.append((source_policy, "source:files"))
|
|
743
|
+
|
|
744
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
745
|
+
# If ANY policy is deny, return deny
|
|
746
|
+
for value, source in policies:
|
|
747
|
+
if value is False:
|
|
748
|
+
return (False, source)
|
|
749
|
+
|
|
750
|
+
# Otherwise, first allow policy wins
|
|
751
|
+
for value, source in policies:
|
|
752
|
+
if value is True:
|
|
753
|
+
return (True, source)
|
|
754
|
+
|
|
755
|
+
# No policies matched → use global policy or baseline
|
|
756
|
+
return _get_global_baseline(cursor)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _can_read_email(cursor, email_id: int) -> bool:
|
|
760
|
+
"""Resolve read permission for an email using policies."""
|
|
761
|
+
resolved, _ = _resolve_email_with_source(cursor, email_id)
|
|
762
|
+
return resolved
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _resolve_email_with_source(cursor, email_id: int) -> Tuple[bool, str]:
|
|
766
|
+
"""Resolve email permission with source tracking.
|
|
767
|
+
|
|
768
|
+
Chain: email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
|
|
769
|
+
"""
|
|
770
|
+
cursor.execute(
|
|
771
|
+
"""
|
|
772
|
+
SELECT email.account, email.project_id, project.client_id
|
|
773
|
+
FROM emails email
|
|
774
|
+
LEFT JOIN projects project ON email.project_id = project.id
|
|
775
|
+
WHERE email.id = ?
|
|
776
|
+
""",
|
|
777
|
+
(email_id,),
|
|
778
|
+
)
|
|
779
|
+
row = cursor.fetchone()
|
|
780
|
+
if not row:
|
|
781
|
+
return (False, "not_found")
|
|
782
|
+
|
|
783
|
+
# Collect matching policies only (not baseline)
|
|
784
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
785
|
+
|
|
786
|
+
# 1. Item-level policy (email:{id})
|
|
787
|
+
item_policy = _get_policy(cursor, f"email:{email_id}")
|
|
788
|
+
if item_policy is not None:
|
|
789
|
+
policies.append((item_policy, f"email:{email_id}"))
|
|
790
|
+
|
|
791
|
+
# 2. Project-level via full resolution (project:{id}) - skip baseline
|
|
792
|
+
project_id = row["project_id"]
|
|
793
|
+
if project_id:
|
|
794
|
+
conn = cursor.connection
|
|
795
|
+
result = _resolve_parent_permission_with_source(conn, "project", project_id)
|
|
796
|
+
if result:
|
|
797
|
+
allowed, src = result
|
|
798
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
799
|
+
|
|
800
|
+
# 3. Client-level via full resolution (client:{id}) - skip baseline
|
|
801
|
+
client_id = row["client_id"]
|
|
802
|
+
if client_id:
|
|
803
|
+
conn = cursor.connection
|
|
804
|
+
result = _resolve_parent_permission_with_source(conn, "client", client_id)
|
|
805
|
+
if result:
|
|
806
|
+
allowed, src = result
|
|
807
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
808
|
+
|
|
809
|
+
# 4. Account-level policy (e.g., account:personal)
|
|
810
|
+
account = row["account"] or ""
|
|
811
|
+
if account:
|
|
812
|
+
account_policy = _get_policy(cursor, f"account:{account}")
|
|
813
|
+
if account_policy is not None:
|
|
814
|
+
policies.append((account_policy, f"account:{account}"))
|
|
815
|
+
|
|
816
|
+
# 5. Source policy
|
|
817
|
+
source_policy = _get_policy(cursor, "source:emails")
|
|
818
|
+
if source_policy is not None:
|
|
819
|
+
policies.append((source_policy, "source:emails"))
|
|
820
|
+
|
|
821
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
822
|
+
# If ANY policy is deny, return deny
|
|
823
|
+
for value, source in policies:
|
|
824
|
+
if value is False:
|
|
825
|
+
return (False, source)
|
|
826
|
+
|
|
827
|
+
# Otherwise, first allow policy wins
|
|
828
|
+
for value, source in policies:
|
|
829
|
+
if value is True:
|
|
830
|
+
return (True, source)
|
|
831
|
+
|
|
832
|
+
# No policies matched → use global policy or baseline
|
|
833
|
+
return _get_global_baseline(cursor)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _can_read_chat(cursor, chat_id: int) -> bool:
|
|
837
|
+
"""Resolve read permission for a chat using policies."""
|
|
838
|
+
resolved, _ = _resolve_chat_with_source(cursor, chat_id)
|
|
839
|
+
return resolved
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _resolve_chat_with_source(cursor, chat_id: int) -> Tuple[bool, str]:
|
|
843
|
+
"""Resolve chat permission with source tracking.
|
|
844
|
+
|
|
845
|
+
Chain: chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
|
|
846
|
+
"""
|
|
847
|
+
cursor.execute(
|
|
848
|
+
"""
|
|
849
|
+
SELECT chat.account, chat.project_id, project.client_id
|
|
850
|
+
FROM chats chat
|
|
851
|
+
LEFT JOIN projects project ON chat.project_id = project.id
|
|
852
|
+
WHERE chat.id = ?
|
|
853
|
+
""",
|
|
854
|
+
(chat_id,),
|
|
855
|
+
)
|
|
856
|
+
row = cursor.fetchone()
|
|
857
|
+
if not row:
|
|
858
|
+
return (False, "not_found")
|
|
859
|
+
|
|
860
|
+
# Collect matching policies only (not baseline)
|
|
861
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
862
|
+
|
|
863
|
+
# 1. Item-level policy (chat:{id})
|
|
864
|
+
item_policy = _get_policy(cursor, f"chat:{chat_id}")
|
|
865
|
+
if item_policy is not None:
|
|
866
|
+
policies.append((item_policy, f"chat:{chat_id}"))
|
|
867
|
+
|
|
868
|
+
# 2. Project-level via full resolution (project:{id}) - skip baseline
|
|
869
|
+
project_id = row["project_id"]
|
|
870
|
+
if project_id:
|
|
871
|
+
conn = cursor.connection
|
|
872
|
+
result = _resolve_parent_permission_with_source(conn, "project", project_id)
|
|
873
|
+
if result:
|
|
874
|
+
allowed, src = result
|
|
875
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
876
|
+
|
|
877
|
+
# 3. Client-level via full resolution (client:{id}) - skip baseline
|
|
878
|
+
client_id = row["client_id"]
|
|
879
|
+
if client_id:
|
|
880
|
+
conn = cursor.connection
|
|
881
|
+
result = _resolve_parent_permission_with_source(conn, "client", client_id)
|
|
882
|
+
if result:
|
|
883
|
+
allowed, src = result
|
|
884
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
885
|
+
|
|
886
|
+
# 4. Account-level policy (e.g., account:claude)
|
|
887
|
+
account = row["account"] or ""
|
|
888
|
+
if account:
|
|
889
|
+
account_policy = _get_policy(cursor, f"account:{account}")
|
|
890
|
+
if account_policy is not None:
|
|
891
|
+
policies.append((account_policy, f"account:{account}"))
|
|
892
|
+
|
|
893
|
+
# 5. Source policy (source:chats)
|
|
894
|
+
source_policy = _get_policy(cursor, "source:chats")
|
|
895
|
+
if source_policy is not None:
|
|
896
|
+
policies.append((source_policy, "source:chats"))
|
|
897
|
+
|
|
898
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
899
|
+
# If ANY policy is deny, return deny
|
|
900
|
+
for value, source in policies:
|
|
901
|
+
if value is False:
|
|
902
|
+
return (False, source)
|
|
903
|
+
|
|
904
|
+
# Otherwise, first allow policy wins
|
|
905
|
+
for value, source in policies:
|
|
906
|
+
if value is True:
|
|
907
|
+
return (True, source)
|
|
908
|
+
|
|
909
|
+
# No policies matched → use global policy or baseline
|
|
910
|
+
return _get_global_baseline(cursor)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _resolve_project_permission_with_source(cursor, project_id: int) -> Tuple[bool, str]:
|
|
914
|
+
"""Resolve project permission with source tracking.
|
|
915
|
+
|
|
916
|
+
Chain: project:{id} -> client:{id} -> source:projects
|
|
917
|
+
"""
|
|
918
|
+
cursor.execute(
|
|
919
|
+
"""
|
|
920
|
+
SELECT client_id FROM projects WHERE id = ?
|
|
921
|
+
""",
|
|
922
|
+
(project_id,),
|
|
923
|
+
)
|
|
924
|
+
row = cursor.fetchone()
|
|
925
|
+
if not row:
|
|
926
|
+
return (BASELINE_PERMISSION, "not_found")
|
|
927
|
+
|
|
928
|
+
# Collect matching policies only (not baseline)
|
|
929
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
930
|
+
|
|
931
|
+
# 1. Project-level policy (project:{id})
|
|
932
|
+
project_policy = _get_policy(cursor, f"project:{project_id}")
|
|
933
|
+
if project_policy is not None:
|
|
934
|
+
policies.append((project_policy, f"project:{project_id}"))
|
|
935
|
+
|
|
936
|
+
# 2. Client-level policy via full resolution (skip baseline)
|
|
937
|
+
client_id = row["client_id"]
|
|
938
|
+
if client_id:
|
|
939
|
+
conn = cursor.connection
|
|
940
|
+
result = _resolve_parent_permission_with_source(conn, "client", client_id)
|
|
941
|
+
if result:
|
|
942
|
+
allowed, src = result
|
|
943
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
944
|
+
|
|
945
|
+
# 3. Source policy for projects
|
|
946
|
+
source_policy = _get_policy(cursor, "source:projects")
|
|
947
|
+
if source_policy is not None:
|
|
948
|
+
policies.append((source_policy, "source:projects"))
|
|
949
|
+
|
|
950
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
951
|
+
for value, source in policies:
|
|
952
|
+
if value is False:
|
|
953
|
+
return (False, source)
|
|
954
|
+
|
|
955
|
+
for value, source in policies:
|
|
956
|
+
if value is True:
|
|
957
|
+
return (True, source)
|
|
958
|
+
|
|
959
|
+
# No policies matched → use global policy or baseline
|
|
960
|
+
return _get_global_baseline(cursor)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _resolve_client_permission_with_source(cursor, client_id: int) -> Tuple[bool, str]:
|
|
964
|
+
"""Resolve client permission with source tracking.
|
|
965
|
+
|
|
966
|
+
Chain: client:{id} -> source:clients
|
|
967
|
+
"""
|
|
968
|
+
cursor.execute("SELECT id FROM clients WHERE id = ?", (client_id,))
|
|
969
|
+
row = cursor.fetchone()
|
|
970
|
+
if not row:
|
|
971
|
+
return (BASELINE_PERMISSION, "not_found")
|
|
972
|
+
|
|
973
|
+
# Collect matching policies only (not baseline)
|
|
974
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
975
|
+
|
|
976
|
+
# 1. Client-level policy (client:{id})
|
|
977
|
+
client_policy = _get_policy(cursor, f"client:{client_id}")
|
|
978
|
+
if client_policy is not None:
|
|
979
|
+
policies.append((client_policy, f"client:{client_id}"))
|
|
980
|
+
|
|
981
|
+
# 2. Source policy for clients
|
|
982
|
+
source_policy = _get_policy(cursor, "source:clients")
|
|
983
|
+
if source_policy is not None:
|
|
984
|
+
policies.append((source_policy, "source:clients"))
|
|
985
|
+
|
|
986
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
987
|
+
for value, source in policies:
|
|
988
|
+
if value is False:
|
|
989
|
+
return (False, source)
|
|
990
|
+
|
|
991
|
+
for value, source in policies:
|
|
992
|
+
if value is True:
|
|
993
|
+
return (True, source)
|
|
994
|
+
|
|
995
|
+
# No policies matched → use global policy or baseline
|
|
996
|
+
return _get_global_baseline(cursor)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _resolve_folder_permission_with_source(cursor, folder_id: int) -> Tuple[bool, str]:
|
|
1000
|
+
"""Resolve folder permission with source tracking.
|
|
1001
|
+
|
|
1002
|
+
Chain: folder:{id} → folder prefix → project:{id} → client:{id} → source:folders
|
|
1003
|
+
"""
|
|
1004
|
+
cursor.execute(
|
|
1005
|
+
"""
|
|
1006
|
+
SELECT folder.path, folder.project_id, project.client_id
|
|
1007
|
+
FROM folders folder
|
|
1008
|
+
LEFT JOIN projects project ON folder.project_id = project.id
|
|
1009
|
+
WHERE folder.id = ?
|
|
1010
|
+
""",
|
|
1011
|
+
(folder_id,),
|
|
1012
|
+
)
|
|
1013
|
+
row = cursor.fetchone()
|
|
1014
|
+
if not row:
|
|
1015
|
+
return (BASELINE_PERMISSION, "not_found")
|
|
1016
|
+
|
|
1017
|
+
# Collect matching policies only (not baseline)
|
|
1018
|
+
policies: List[Tuple[Optional[bool], str]] = []
|
|
1019
|
+
|
|
1020
|
+
# 1. Item-level policy (folder:{id})
|
|
1021
|
+
item_policy = _get_policy(cursor, f"folder:{folder_id}")
|
|
1022
|
+
if item_policy is not None:
|
|
1023
|
+
policies.append((item_policy, f"folder:{folder_id}"))
|
|
1024
|
+
|
|
1025
|
+
# 2. Folder prefix match (most specific first)
|
|
1026
|
+
path = row["path"] or ""
|
|
1027
|
+
if path:
|
|
1028
|
+
cursor.execute(
|
|
1029
|
+
"""
|
|
1030
|
+
SELECT scope, setting FROM permission_policies
|
|
1031
|
+
WHERE scope LIKE 'folder:%'
|
|
1032
|
+
ORDER BY LENGTH(scope) DESC
|
|
1033
|
+
"""
|
|
1034
|
+
)
|
|
1035
|
+
for folder_row in cursor.fetchall():
|
|
1036
|
+
if not is_folder_path_scope(folder_row["scope"]):
|
|
1037
|
+
continue
|
|
1038
|
+
prefix = folder_row["scope"][len("folder:") :]
|
|
1039
|
+
if prefix.startswith("~"):
|
|
1040
|
+
prefix = os.path.expanduser(prefix)
|
|
1041
|
+
if path.startswith(prefix):
|
|
1042
|
+
policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
|
|
1043
|
+
break # Only use most specific folder match
|
|
1044
|
+
|
|
1045
|
+
# 3. Project-level via full resolution (skip baseline)
|
|
1046
|
+
project_id = row["project_id"]
|
|
1047
|
+
if project_id:
|
|
1048
|
+
conn = cursor.connection
|
|
1049
|
+
result = _resolve_parent_permission_with_source(conn, "project", project_id)
|
|
1050
|
+
if result:
|
|
1051
|
+
allowed, src = result
|
|
1052
|
+
policies.append((allowed, f"project:{project_id} (via {src})"))
|
|
1053
|
+
|
|
1054
|
+
# 4. Client-level via full resolution (skip baseline)
|
|
1055
|
+
client_id = row["client_id"]
|
|
1056
|
+
if client_id:
|
|
1057
|
+
conn = cursor.connection
|
|
1058
|
+
result = _resolve_parent_permission_with_source(conn, "client", client_id)
|
|
1059
|
+
if result:
|
|
1060
|
+
allowed, src = result
|
|
1061
|
+
policies.append((allowed, f"client:{client_id} (via {src})"))
|
|
1062
|
+
|
|
1063
|
+
# 5. Source policy
|
|
1064
|
+
source_policy = _get_policy(cursor, "source:folders")
|
|
1065
|
+
if source_policy is not None:
|
|
1066
|
+
policies.append((source_policy, "source:folders"))
|
|
1067
|
+
|
|
1068
|
+
# DENY-WINS RESOLUTION among matching policies only:
|
|
1069
|
+
for value, source in policies:
|
|
1070
|
+
if value is False:
|
|
1071
|
+
return (False, source)
|
|
1072
|
+
|
|
1073
|
+
for value, source in policies:
|
|
1074
|
+
if value is True:
|
|
1075
|
+
return (True, source)
|
|
1076
|
+
|
|
1077
|
+
# No policies matched → use global policy or baseline
|
|
1078
|
+
return _get_global_baseline(cursor)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _can_read_browser(cursor, browser_id: int) -> bool:
|
|
1082
|
+
"""Resolve read permission for a browser history item using policies.
|
|
1083
|
+
|
|
1084
|
+
Browser history only has source-level policies (no item/folder/project/client hierarchy).
|
|
1085
|
+
"""
|
|
1086
|
+
resolved, _ = _resolve_browser_with_source(cursor, browser_id)
|
|
1087
|
+
return resolved
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _resolve_browser_with_source(cursor, browser_id: int) -> Tuple[bool, str]:
|
|
1091
|
+
"""Resolve browser history permission with source tracking.
|
|
1092
|
+
|
|
1093
|
+
Browser history has no hierarchy - only source-level policy applies.
|
|
1094
|
+
Chain: source:browser → baseline
|
|
1095
|
+
"""
|
|
1096
|
+
# Verify item exists
|
|
1097
|
+
cursor.execute("SELECT id FROM visits WHERE id = ?", (browser_id,))
|
|
1098
|
+
row = cursor.fetchone()
|
|
1099
|
+
if not row:
|
|
1100
|
+
return (False, "not_found")
|
|
1101
|
+
|
|
1102
|
+
# Only source policy applies
|
|
1103
|
+
source_policy = _get_policy(cursor, "source:browser")
|
|
1104
|
+
if source_policy is not None:
|
|
1105
|
+
return (source_policy, "source:browser")
|
|
1106
|
+
|
|
1107
|
+
# No policies matched → use global policy or baseline
|
|
1108
|
+
return _get_global_baseline(cursor)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _batch_resolve_browser_permissions(cursor, item_ids: List[int]) -> Dict[int, Tuple[bool, str]]:
|
|
1112
|
+
"""Batch resolve permissions for browser history items.
|
|
1113
|
+
|
|
1114
|
+
Since browser history only uses source-level policy, we can resolve once
|
|
1115
|
+
and apply to all items.
|
|
1116
|
+
"""
|
|
1117
|
+
cursor.execute("SELECT scope, setting FROM permission_policies WHERE scope IN ('source:browser', 'global')")
|
|
1118
|
+
rows = {row["scope"]: row["setting"] for row in cursor.fetchall()}
|
|
1119
|
+
|
|
1120
|
+
if "source:browser" in rows:
|
|
1121
|
+
source_permission = _resolve(rows["source:browser"])
|
|
1122
|
+
source = "source:browser"
|
|
1123
|
+
else:
|
|
1124
|
+
source_permission = None
|
|
1125
|
+
|
|
1126
|
+
# Global policy fallback
|
|
1127
|
+
if "global" in rows:
|
|
1128
|
+
global_baseline = (_resolve(rows["global"]), "global")
|
|
1129
|
+
else:
|
|
1130
|
+
global_baseline = (BASELINE_PERMISSION, "baseline")
|
|
1131
|
+
|
|
1132
|
+
# Verify which items exist (chunked)
|
|
1133
|
+
if item_ids:
|
|
1134
|
+
existing_rows = _chunked_query(
|
|
1135
|
+
cursor,
|
|
1136
|
+
"SELECT id FROM visits WHERE id IN ({placeholders})",
|
|
1137
|
+
item_ids,
|
|
1138
|
+
)
|
|
1139
|
+
existing_ids = {row["id"] for row in existing_rows}
|
|
1140
|
+
else:
|
|
1141
|
+
existing_ids = set()
|
|
1142
|
+
|
|
1143
|
+
results = {}
|
|
1144
|
+
for item_id in item_ids:
|
|
1145
|
+
if item_id not in existing_ids:
|
|
1146
|
+
results[item_id] = (False, "not_found")
|
|
1147
|
+
elif source_permission is not None:
|
|
1148
|
+
results[item_id] = (source_permission, source)
|
|
1149
|
+
else:
|
|
1150
|
+
results[item_id] = global_baseline
|
|
1151
|
+
|
|
1152
|
+
return results
|