fusion-rag 0.8.32__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.
- fusion_rag/__init__.py +21 -0
- fusion_rag/_data_dir.py +22 -0
- fusion_rag/_validators.py +183 -0
- fusion_rag/api/__init__.py +1 -0
- fusion_rag/api/access.py +180 -0
- fusion_rag/api/app_state.py +516 -0
- fusion_rag/api/auth.py +201 -0
- fusion_rag/api/identity.py +150 -0
- fusion_rag/api/logging_setup.py +146 -0
- fusion_rag/api/mcp_server.py +317 -0
- fusion_rag/api/routes.py +400 -0
- fusion_rag/api/routes_admin.py +341 -0
- fusion_rag/api/routes_auth.py +86 -0
- fusion_rag/api/routes_bnup.py +288 -0
- fusion_rag/api/routes_docs.py +1185 -0
- fusion_rag/api/routes_graph.py +127 -0
- fusion_rag/api/routes_kb.py +116 -0
- fusion_rag/api/routes_project.py +75 -0
- fusion_rag/api/routes_search.py +619 -0
- fusion_rag/api/routes_store.py +134 -0
- fusion_rag/api/server.py +485 -0
- fusion_rag/api/tenant.py +258 -0
- fusion_rag/bnup/__init__.py +0 -0
- fusion_rag/bnup/corpus.py +177 -0
- fusion_rag/connectors/__init__.py +186 -0
- fusion_rag/connectors/git_loader.py +142 -0
- fusion_rag/console/__init__.py +1 -0
- fusion_rag/console/assets/index-B2qffG47.css +1 -0
- fusion_rag/console/assets/index-DQEFCBAu.js +40 -0
- fusion_rag/console/index.html +17 -0
- fusion_rag/embed/__init__.py +1 -0
- fusion_rag/embed/client.py +309 -0
- fusion_rag/embed/local.py +80 -0
- fusion_rag/engine/__init__.py +1 -0
- fusion_rag/engine/ast_chunker.py +226 -0
- fusion_rag/engine/audit_logger.py +296 -0
- fusion_rag/engine/bench.py +200 -0
- fusion_rag/engine/bm25_index.py +357 -0
- fusion_rag/engine/chunker.py +227 -0
- fusion_rag/engine/citation_audit.py +210 -0
- fusion_rag/engine/contextualizer.py +156 -0
- fusion_rag/engine/cross_encoder_reranker.py +125 -0
- fusion_rag/engine/document.py +349 -0
- fusion_rag/engine/embedding_cache.py +233 -0
- fusion_rag/engine/eval_set.py +210 -0
- fusion_rag/engine/evaluator.py +460 -0
- fusion_rag/engine/graph_rag.py +314 -0
- fusion_rag/engine/incremental_sync.py +184 -0
- fusion_rag/engine/knowledge_base.py +389 -0
- fusion_rag/engine/llm_auth_state.py +74 -0
- fusion_rag/engine/llm_errors.py +38 -0
- fusion_rag/engine/metrics.py +300 -0
- fusion_rag/engine/ocr_hook.py +135 -0
- fusion_rag/engine/preprocessor.py +147 -0
- fusion_rag/engine/query_rewriter.py +187 -0
- fusion_rag/engine/rag_chain.py +287 -0
- fusion_rag/engine/recall_bench.py +412 -0
- fusion_rag/engine/reranker.py +321 -0
- fusion_rag/engine/retrievers.py +139 -0
- fusion_rag/engine/runtime_config.py +138 -0
- fusion_rag/engine/search_template.py +322 -0
- fusion_rag/engine/sqlite_base.py +118 -0
- fusion_rag/engine/streaming.py +117 -0
- fusion_rag/engine/trajectory_writer.py +105 -0
- fusion_rag/engine/version_manager.py +347 -0
- fusion_rag/permissions/__init__.py +15 -0
- fusion_rag/permissions/acl.py +242 -0
- fusion_rag/store/__init__.py +14 -0
- fusion_rag/store/fusion_store_backend.py +366 -0
- fusion_rag/store/local_backend.py +313 -0
- fusion_rag/store/metadata_store.py +343 -0
- fusion_rag/store/qdrant_backend.py +322 -0
- fusion_rag/store/remote_backend.py +217 -0
- fusion_rag/store/store_backend.py +101 -0
- fusion_rag/store/vector_store.py +148 -0
- fusion_rag-0.8.32.dist-info/METADATA +891 -0
- fusion_rag-0.8.32.dist-info/RECORD +80 -0
- fusion_rag-0.8.32.dist-info/WHEEL +5 -0
- fusion_rag-0.8.32.dist-info/licenses/LICENSE +190 -0
- fusion_rag-0.8.32.dist-info/top_level.txt +1 -0
fusion_rag/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Fusion-RAG — Apple Silicon native offline vector knowledge base backend.
|
|
2
|
+
|
|
3
|
+
All model inference (embeddings, chat) goes through fusion-mlx HTTP API.
|
|
4
|
+
Never imports MLX, mlx-lm, or any engine code directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .embed.client import EmbeddingClient
|
|
8
|
+
from .engine.chunker import Chunker
|
|
9
|
+
from .engine.document import DocumentParser, DocumentType
|
|
10
|
+
from .engine.knowledge_base import KnowledgeBase, KnowledgeBaseConfig
|
|
11
|
+
from .store.metadata_store import MetadataStore
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Chunker",
|
|
15
|
+
"DocumentParser",
|
|
16
|
+
"DocumentType",
|
|
17
|
+
"EmbeddingClient",
|
|
18
|
+
"KnowledgeBase",
|
|
19
|
+
"KnowledgeBaseConfig",
|
|
20
|
+
"MetadataStore",
|
|
21
|
+
]
|
fusion_rag/_data_dir.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Issue #96: single-point data-dir resolution via fusion_core.config.
|
|
2
|
+
# fusion-core's convention passes the SHORT app name ("rag" — see
|
|
3
|
+
# fusion-core tests/test_config_conventions.py), so the env var read is
|
|
4
|
+
# FUSION_RAG_DATA_DIR; passing the full "fusion-rag" name would double the
|
|
5
|
+
# FUSION_ prefix on the env lookup. The bare-app default (~/.rag) would
|
|
6
|
+
# orphan every existing store, so the pre-#96 location ~/.fusion-rag is
|
|
7
|
+
# passed as the explicit default. Call sites stay one-line: data_dir().
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fusion_core.config import service_data_dir
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data_dir() -> Path:
|
|
20
|
+
resolved = service_data_dir("rag", default=Path.home() / ".fusion-rag")
|
|
21
|
+
logger.debug("fusion-rag data dir resolved: %s", resolved)
|
|
22
|
+
return resolved
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Input validation helpers — single trust-boundary layer.
|
|
2
|
+
|
|
3
|
+
Centralizes all external-input confinement (硬伤3):
|
|
4
|
+
- kb_id / table_name / identifier -> regex whitelist
|
|
5
|
+
- filesystem path -> configured root + is_relative_to + symlink reject
|
|
6
|
+
- URL -> scheme whitelist + private-network reject (SSRF guard)
|
|
7
|
+
- git repo_url -> scheme whitelist + ext:: reject (RCE guard)
|
|
8
|
+
|
|
9
|
+
No business logic here. Pure validation + raise on violation. Every
|
|
10
|
+
caller that takes external input and feeds it to fs/SQL/network/git
|
|
11
|
+
must route through these helpers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ipaddress
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
import socket
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# identifier whitelist: letters/digits/_-. , 1-64 chars, no path separators
|
|
26
|
+
_IDENT_RE = re.compile(r"^[A-Za-z0-9_\-]{1,64}$")
|
|
27
|
+
# SQL identifier: letters/digits/_., must start with letter/_
|
|
28
|
+
_SQL_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
|
29
|
+
|
|
30
|
+
_ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
|
|
31
|
+
_ALLOWED_GIT_SCHEMES = frozenset({"http", "https", "git", "ssh"})
|
|
32
|
+
|
|
33
|
+
# RFC1918 + loopback + link-local + unique-local v6 ranges
|
|
34
|
+
_PRIVATE_NETWORKS = [
|
|
35
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
36
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
37
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
38
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
39
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
40
|
+
ipaddress.ip_network("0.0.0.0/8"),
|
|
41
|
+
ipaddress.ip_network("::1/128"),
|
|
42
|
+
ipaddress.ip_network("fc00::/7"),
|
|
43
|
+
ipaddress.ip_network("fe80::/10"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ValidationError(ValueError):
|
|
48
|
+
"""Raised when external input violates trust-boundary rules."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_identifier(name: str, *, field: str = "identifier") -> str:
|
|
52
|
+
"""Validate a KB id / rule id / generic identifier.
|
|
53
|
+
|
|
54
|
+
Rejects path separators, dots, and any non-whitelisted char.
|
|
55
|
+
"""
|
|
56
|
+
# P4-8: the regex ^[A-Za-z0-9_\\-]{1,64}$ already rejects "."/".." (dots
|
|
57
|
+
# not in the charset), so the prior `if name in (".", "..")` branch was
|
|
58
|
+
# unreachable dead code — a sign the validation layers weren't reasoning
|
|
59
|
+
# about each other. Removed; the regex is the sole authority.
|
|
60
|
+
if not name or not _IDENT_RE.match(name):
|
|
61
|
+
logger.warning("validate_identifier reject: field=%s value=%r", field, name)
|
|
62
|
+
raise ValidationError(f"invalid {field}: must match [A-Za-z0-9_-]{{1,64}}")
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_sql_identifier(name: str, *, field: str = "table_name") -> str:
|
|
67
|
+
"""Validate a SQL table/column identifier against injection.
|
|
68
|
+
|
|
69
|
+
Identifiers cannot be bound as params, so we whitelist the charset
|
|
70
|
+
and reject anything containing quotes / semicolons / path separators.
|
|
71
|
+
Caller should still wrap the result in double quotes for SQLite PRAGMA.
|
|
72
|
+
"""
|
|
73
|
+
if not name or not _SQL_IDENT_RE.match(name):
|
|
74
|
+
logger.warning("validate_sql_identifier reject: field=%s value=%r", field, name)
|
|
75
|
+
raise ValidationError(f"invalid {field}: must match [A-Za-z_][A-Za-z0-9_.]*")
|
|
76
|
+
if '"' in name or ";" in name or "--" in name:
|
|
77
|
+
raise ValidationError(f"invalid {field}: forbidden chars")
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_path_under_root(
|
|
82
|
+
path: str,
|
|
83
|
+
*,
|
|
84
|
+
root: str | Path,
|
|
85
|
+
field: str = "file_path",
|
|
86
|
+
allow_root: bool = False,
|
|
87
|
+
) -> Path:
|
|
88
|
+
"""Confine a filesystem path to a configured root (LFI / path-traversal guard).
|
|
89
|
+
|
|
90
|
+
Resolves symlinks, then asserts the resolved path is relative to root.
|
|
91
|
+
Rejects symlink escapes and any path that leaves root.
|
|
92
|
+
|
|
93
|
+
NOTE (P1-11, TOCTOU): this validates the path AT CALL TIME. resolve() follows
|
|
94
|
+
symlinks, so a symlink that points inside root at check-time passes; if an
|
|
95
|
+
attacker swaps it to point outside root between this check and the caller's
|
|
96
|
+
open(), the guard is defeated (classic TOCTOU). A pure-path validator cannot
|
|
97
|
+
close this — callers that read untrusted paths MUST open with O_NOFOLLOW (or
|
|
98
|
+
os.open + lstat-vs-fstat) to make the check-then-use atomic. This function is
|
|
99
|
+
the first line, not the only line.
|
|
100
|
+
"""
|
|
101
|
+
if not path:
|
|
102
|
+
raise ValidationError(f"invalid {field}: empty")
|
|
103
|
+
if not root:
|
|
104
|
+
raise ValidationError(f"invalid root for {field}: empty")
|
|
105
|
+
root_resolved = Path(root).expanduser().resolve()
|
|
106
|
+
target = Path(path).expanduser().resolve()
|
|
107
|
+
if not allow_root and target == root_resolved:
|
|
108
|
+
# P1-11: this branch was a dead `pass` — the docstring claimed it
|
|
109
|
+
# disallowed operating on root itself, but it never raised, so the
|
|
110
|
+
# promise was a lie. All current callers validate a file UNDER root
|
|
111
|
+
# (never the root dir), so rejecting root-equal matches intent. If a
|
|
112
|
+
# future caller legitimately needs the root, pass allow_root=True.
|
|
113
|
+
logger.warning("validate_path_under_root reject: field=%s path equals root=%s", field, root_resolved)
|
|
114
|
+
raise ValidationError(f"invalid {field}: path equals ingest root (not a file under it)")
|
|
115
|
+
if not target.is_relative_to(root_resolved):
|
|
116
|
+
logger.warning(
|
|
117
|
+
"validate_path_under_root reject: field=%s path=%s resolved=%s root=%s",
|
|
118
|
+
field,
|
|
119
|
+
path,
|
|
120
|
+
target,
|
|
121
|
+
root_resolved,
|
|
122
|
+
)
|
|
123
|
+
raise ValidationError(f"invalid {field}: escapes ingest root")
|
|
124
|
+
# Symlink check: if the raw path is a symlink, ensure it doesn't escape.
|
|
125
|
+
raw = Path(path)
|
|
126
|
+
if raw.is_symlink() and not Path(raw.readlink()).resolve().is_relative_to(root_resolved):
|
|
127
|
+
raise ValidationError(f"invalid {field}: symlink escapes root")
|
|
128
|
+
return target
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _host_is_private(host: str) -> bool:
|
|
132
|
+
try:
|
|
133
|
+
addr = ipaddress.ip_address(socket.gethostbyname(host))
|
|
134
|
+
except (OSError, ValueError):
|
|
135
|
+
return False
|
|
136
|
+
return any(addr in net for net in _PRIVATE_NETWORKS)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_url(url: str, *, field: str = "url", allow_private: bool = False) -> str:
|
|
140
|
+
"""Validate an HTTP(S) URL — scheme whitelist + SSRF guard.
|
|
141
|
+
|
|
142
|
+
Rejects non-http(s) schemes, and (unless allow_private) hosts resolving
|
|
143
|
+
to private/loopback/link-local ranges.
|
|
144
|
+
"""
|
|
145
|
+
if not url:
|
|
146
|
+
raise ValidationError(f"invalid {field}: empty")
|
|
147
|
+
parsed = urlparse(url)
|
|
148
|
+
scheme = (parsed.scheme or "").lower()
|
|
149
|
+
if scheme not in _ALLOWED_URL_SCHEMES:
|
|
150
|
+
logger.warning("validate_url reject: field=%s scheme=%s", field, scheme)
|
|
151
|
+
raise ValidationError(f"invalid {field}: scheme must be http/https")
|
|
152
|
+
host = parsed.hostname or ""
|
|
153
|
+
if not host:
|
|
154
|
+
raise ValidationError(f"invalid {field}: no host")
|
|
155
|
+
if not allow_private and _host_is_private(host):
|
|
156
|
+
logger.warning("validate_url reject: field=%s host=%s is private", field, host)
|
|
157
|
+
raise ValidationError(f"invalid {field}: private/loopback host denied")
|
|
158
|
+
return url
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_git_url(url: str, *, field: str = "repo_url") -> str:
|
|
162
|
+
"""Validate a git clone URL — scheme whitelist + ext:: RCE guard.
|
|
163
|
+
|
|
164
|
+
Rejects ext:: (and any non-whitelisted scheme) which git treats as an
|
|
165
|
+
executable transport protocol (RCE via post-checkout hook chain).
|
|
166
|
+
"""
|
|
167
|
+
if not url:
|
|
168
|
+
raise ValidationError(f"invalid {field}: empty")
|
|
169
|
+
# Reject ext:: outright — it is an executable transport.
|
|
170
|
+
if url.startswith("ext::") or "\n" in url or "\0" in url:
|
|
171
|
+
logger.warning("validate_git_url reject: field=%s ext/nul/newline", field)
|
|
172
|
+
raise ValidationError(f"invalid {field}: ext:: transport denied")
|
|
173
|
+
parsed = urlparse(url)
|
|
174
|
+
scheme = (parsed.scheme or "").lower()
|
|
175
|
+
if scheme and scheme not in _ALLOWED_GIT_SCHEMES:
|
|
176
|
+
logger.warning("validate_git_url reject: field=%s scheme=%s", field, scheme)
|
|
177
|
+
raise ValidationError(f"invalid {field}: scheme must be http/https/git/ssh")
|
|
178
|
+
# For http/https also apply SSRF guard.
|
|
179
|
+
if scheme in ("http", "https"):
|
|
180
|
+
host = parsed.hostname or ""
|
|
181
|
+
if host and _host_is_private(host):
|
|
182
|
+
raise ValidationError(f"invalid {field}: private/loopback host denied")
|
|
183
|
+
return url
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Fusion-RAG package."""
|
fusion_rag/api/access.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""P0-5/P0-6: KB-level access control dependency.
|
|
2
|
+
|
|
3
|
+
Wires the existing permission module (ACL + PermissionManager) into the REST
|
|
4
|
+
surface — before this, permission rules were CRUD-able but never enforced,
|
|
5
|
+
which is worse than no permissions: admins believed access was restricted when
|
|
6
|
+
it was not.
|
|
7
|
+
|
|
8
|
+
Semantics:
|
|
9
|
+
- NoAuth (FUSION_RAG_AUTH_BACKEND=none or no admin key): allow. Local-first
|
|
10
|
+
default — a single-user offline box must not start 401-ing.
|
|
11
|
+
- Authenticated subject == "admin": allow any action (ACL bypass).
|
|
12
|
+
- KB has NO permission rules: allow. An open KB keeps working; enforcement
|
|
13
|
+
only engages once an admin has actually written a rule for that KB. This is
|
|
14
|
+
the backward-compat path — existing KBs are not silently locked out.
|
|
15
|
+
- KB HAS rules for the subject: fail-closed. PermissionManager.check_permission
|
|
16
|
+
decides; a denied check is 403, a backend error is 403 (never allow-on-error).
|
|
17
|
+
|
|
18
|
+
This dependency runs the same kb_id validation + KB lookup as _get_base, so
|
|
19
|
+
endpoints can keep their own _get_base call (it is a cheap dict get) OR rely
|
|
20
|
+
on the dependency's resolution. To keep the diff surgical, endpoints keep their
|
|
21
|
+
existing _get_base; the dependency resolves independently and its 404/400
|
|
22
|
+
fires before the handler body runs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
|
|
29
|
+
from fastapi import Depends, HTTPException
|
|
30
|
+
|
|
31
|
+
from .._validators import validate_identifier
|
|
32
|
+
from .app_state import get_kb_manager
|
|
33
|
+
from .auth import verify_api_key
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_base(kb_id: str):
|
|
39
|
+
# Mirror of each router's _get_base: confine kb_id then fetch. Duplicated
|
|
40
|
+
# deliberately (Rule 3) so access.py has no import edge into any router
|
|
41
|
+
# module — routers import access.py, never the reverse.
|
|
42
|
+
try:
|
|
43
|
+
validate_identifier(kb_id, field="kb_id")
|
|
44
|
+
except ValueError:
|
|
45
|
+
raise HTTPException(400, f"Invalid kb_id: {kb_id}")
|
|
46
|
+
# Issue #61: scope by the request's authoritative tenant when isolation is
|
|
47
|
+
# on. The ACL dependency (require_kb_action) runs BEFORE the handler's
|
|
48
|
+
# _get_base on most routes — without tenant scoping here a tenant-A caller
|
|
49
|
+
# would pass the ACL gate on tenant-B's KB. The 404 (not 403) on a
|
|
50
|
+
# cross-tenant mismatch avoids leaking the KB's existence.
|
|
51
|
+
from .tenant import tenant_scope
|
|
52
|
+
|
|
53
|
+
tenant, require_match = tenant_scope()
|
|
54
|
+
try:
|
|
55
|
+
return get_kb_manager().get(kb_id, tenant=tenant, require_tenant_match=require_match)
|
|
56
|
+
except KeyError:
|
|
57
|
+
raise HTTPException(404, f"Knowledge base '{kb_id}' not found")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_kb_access(kb_id: str, subject: str | None, action: str, resource_path: str = "/") -> None:
|
|
61
|
+
# NoAuth -> subject is None -> open (local-first default).
|
|
62
|
+
if subject is None:
|
|
63
|
+
return
|
|
64
|
+
# Admin key authenticates as "admin" (auth.py verify) -> ACL bypass.
|
|
65
|
+
if subject == "admin":
|
|
66
|
+
return
|
|
67
|
+
kb = _resolve_base(kb_id)
|
|
68
|
+
storage_path = kb.vector_path.rsplit("/vectors", 1)[0] if "/vectors" in kb.vector_path else kb.vector_path
|
|
69
|
+
# A-P1-2: reuse the pooled PermissionManager (app_state admin pool) instead
|
|
70
|
+
# of constructing one per request — each prior PermissionManager(...) opened
|
|
71
|
+
# a fresh sqlite conn + re-initialized on every ACL check. One per storage
|
|
72
|
+
# path, reused; closed from the lifespan shutdown.
|
|
73
|
+
from .app_state import get_permission_manager
|
|
74
|
+
|
|
75
|
+
pm = get_permission_manager(storage_path)
|
|
76
|
+
# Open-KB path: if no rules exist for this KB at all, treat it as open.
|
|
77
|
+
# Enforcement only engages once a rule is written (backward compat).
|
|
78
|
+
try:
|
|
79
|
+
all_rules = pm.list_rules(kb_id)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Fail-closed on a permission-store read error — never allow-on-error.
|
|
82
|
+
logger.error("permission store read failed for kb=%s, denying (fail-closed): %s", kb_id, e)
|
|
83
|
+
raise HTTPException(403, "Access denied (permission store unavailable)")
|
|
84
|
+
if not all_rules:
|
|
85
|
+
logger.debug("access: no permission rules for kb=%s -> open KB, allow", kb_id)
|
|
86
|
+
return
|
|
87
|
+
# H4 fix: pass the REAL resource_path, not a hardcoded "/". The prior code
|
|
88
|
+
# always checked "/" — and every path-prefixed rule matches "/" (rule root
|
|
89
|
+
# is a prefix of "/"), so path-level ACL was never enforced: a subject
|
|
90
|
+
# restricted to "/docs/restricted/*" could read "/docs/public/*" too.
|
|
91
|
+
# KB-scoped endpoints pass "/" (the KB is the resource); doc-specific
|
|
92
|
+
# endpoints resolve doc_id -> file_path so a rule scoped to a subtree only
|
|
93
|
+
# permits that subtree.
|
|
94
|
+
try:
|
|
95
|
+
allowed = pm.check_permission(kb_id, subject, action, resource_path)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("permission check failed for kb=%s subject=%s, denying: %s", kb_id, subject, e)
|
|
98
|
+
raise HTTPException(403, "Access denied")
|
|
99
|
+
if not allowed:
|
|
100
|
+
logger.warning("access denied: kb=%s subject=%s action=%s path=%s", kb_id, subject, action, resource_path)
|
|
101
|
+
raise HTTPException(403, "Access denied")
|
|
102
|
+
logger.debug("access granted: kb=%s subject=%s action=%s path=%s", kb_id, subject, action, resource_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def require_admin():
|
|
106
|
+
"""S-P1-3/S-P1-1/S-P1-2: admin-only FastAPI dependency for destructive
|
|
107
|
+
mutations that must not be reachable by a non-admin subject — API key
|
|
108
|
+
management (create/delete/list keys) and permission-rule mutation
|
|
109
|
+
(add/delete rules).
|
|
110
|
+
|
|
111
|
+
Semantics match the ACL bypass path: NoAuth (subject is None) -> allow
|
|
112
|
+
(local-first default); admin subject -> allow; any other authenticated
|
|
113
|
+
subject -> 403. A stored key can never authenticate as "admin" (auth.py
|
|
114
|
+
rejects reserved names), so this gate cannot be passed by a non-admin key.
|
|
115
|
+
"""
|
|
116
|
+
def _dep(subject: str | None = Depends(verify_api_key)) -> str | None:
|
|
117
|
+
if subject is None:
|
|
118
|
+
return subject
|
|
119
|
+
if subject == "admin":
|
|
120
|
+
return subject
|
|
121
|
+
logger.warning("admin-only endpoint denied to subject=%s", subject)
|
|
122
|
+
raise HTTPException(403, "Admin privileges required")
|
|
123
|
+
return _dep
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_kb_action(action: str, resolve_doc_path: bool = False):
|
|
127
|
+
"""Build a FastAPI dependency enforcing `action` on the path's kb_id.
|
|
128
|
+
|
|
129
|
+
Usage: `dependencies=[Depends(require_kb_action("read"))]` on a route that
|
|
130
|
+
has a {kb_id} path param, or `subject = Depends(require_kb_action("read"))`
|
|
131
|
+
as a handler param to also receive the subject string.
|
|
132
|
+
|
|
133
|
+
resolve_doc_path: for doc-specific routes (/bases/{kb_id}/documents/{doc_id})
|
|
134
|
+
set True — the dependency resolves doc_id -> the doc's stored file_path and
|
|
135
|
+
passes it as the ACL resource_path, so a rule scoped to a path subtree only
|
|
136
|
+
permits that subtree. KB-scoped routes leave it False (resource = "/" = KB).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def _dep(
|
|
140
|
+
kb_id: str,
|
|
141
|
+
doc_id: str | None = None,
|
|
142
|
+
subject: str | None = Depends(verify_api_key),
|
|
143
|
+
) -> str | None:
|
|
144
|
+
resource_path = "/"
|
|
145
|
+
if resolve_doc_path and doc_id:
|
|
146
|
+
resource_path = _resolve_doc_resource_path(kb_id, doc_id)
|
|
147
|
+
_check_kb_access(kb_id, subject, action, resource_path)
|
|
148
|
+
return subject
|
|
149
|
+
|
|
150
|
+
return _dep
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_doc_resource_path(kb_id: str, doc_id: str) -> str:
|
|
154
|
+
# H4: map a path-param doc_id to the stored file_path so ACL rules scoped to
|
|
155
|
+
# a path subtree can actually enforce at the doc level. If the doc is absent
|
|
156
|
+
# (404) or the store read errors, fall back to "/" — the handler will 404
|
|
157
|
+
# on the missing doc anyway, and failing open to "/" here would let a
|
|
158
|
+
# subtree-restricted subject delete a doc outside its subtree. Instead, on
|
|
159
|
+
# a store error we deny (fail-closed) by returning a path unlikely to match
|
|
160
|
+
# any rule, so ACL.check's no-match -> deny fires. A missing doc returns "/"
|
|
161
|
+
# because the subsequent handler 404s regardless.
|
|
162
|
+
try:
|
|
163
|
+
kb = _resolve_base(kb_id)
|
|
164
|
+
except HTTPException:
|
|
165
|
+
return "/"
|
|
166
|
+
storage_path = kb.vector_path.rsplit("/vectors", 1)[0] if "/vectors" in kb.vector_path else kb.vector_path
|
|
167
|
+
try:
|
|
168
|
+
# A-P1-1: reuse the pooled MetadataStore (app_state meta pool) instead
|
|
169
|
+
# of constructing one per doc-level ACL resolution — each prior
|
|
170
|
+
# MetadataStore(...) opened a fresh sqlite conn on every gated request.
|
|
171
|
+
from .app_state import get_meta_store
|
|
172
|
+
|
|
173
|
+
ms = get_meta_store(f"{storage_path}/metadata.db")
|
|
174
|
+
doc = ms.get_document(doc_id)
|
|
175
|
+
if not doc:
|
|
176
|
+
return "/"
|
|
177
|
+
return doc.get("file_path", "/") or "/"
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.warning("access: doc resource_path resolve failed for kb=%s doc=%s: %s", kb_id, doc_id, e)
|
|
180
|
+
return "/__acl_unresolved__"
|