satchel-server 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. satchel/__init__.py +0 -0
  2. satchel/api/__init__.py +0 -0
  3. satchel/api/app.py +207 -0
  4. satchel/api/audit.py +80 -0
  5. satchel/api/auth.py +133 -0
  6. satchel/api/catalog.py +200 -0
  7. satchel/api/common.py +177 -0
  8. satchel/api/connections.py +226 -0
  9. satchel/api/eval_schemas.py +162 -0
  10. satchel/api/evals.py +320 -0
  11. satchel/api/health.py +61 -0
  12. satchel/api/hooks.py +233 -0
  13. satchel/api/library.py +164 -0
  14. satchel/api/llm.py +251 -0
  15. satchel/api/observability.py +375 -0
  16. satchel/api/proposals.py +166 -0
  17. satchel/api/schemas.py +568 -0
  18. satchel/api/sources.py +416 -0
  19. satchel/api/suggestions.py +362 -0
  20. satchel/api/workbench.py +495 -0
  21. satchel/api/workspace_settings.py +241 -0
  22. satchel/audit.py +265 -0
  23. satchel/cli/__init__.py +0 -0
  24. satchel/cli/clients.py +234 -0
  25. satchel/cli/config.py +163 -0
  26. satchel/cli/doctor.py +369 -0
  27. satchel/cli/evals_cmds.py +254 -0
  28. satchel/cli/infra.py +266 -0
  29. satchel/cli/main.py +627 -0
  30. satchel/cli/onboarding.py +793 -0
  31. satchel/cli/ops.py +145 -0
  32. satchel/cli/secrets_cmds.py +44 -0
  33. satchel/cli/source_cmds.py +368 -0
  34. satchel/cli/storage_cmds.py +56 -0
  35. satchel/core/__init__.py +0 -0
  36. satchel/core/crypto.py +121 -0
  37. satchel/core/item_types.py +203 -0
  38. satchel/core/render.py +78 -0
  39. satchel/core/tokens.py +8 -0
  40. satchel/curation/__init__.py +0 -0
  41. satchel/curation/apply.py +146 -0
  42. satchel/curation/config.py +50 -0
  43. satchel/curation/drafts.py +112 -0
  44. satchel/curation/gaps.py +370 -0
  45. satchel/curation/golden.py +128 -0
  46. satchel/curation/hygiene.py +416 -0
  47. satchel/curation/redact.py +119 -0
  48. satchel/curation/releases.py +88 -0
  49. satchel/curation/run.py +177 -0
  50. satchel/curation/signal_text.py +104 -0
  51. satchel/curation/suggest.py +709 -0
  52. satchel/data/__init__.py +0 -0
  53. satchel/data/connection_admin.py +392 -0
  54. satchel/data/connections.py +124 -0
  55. satchel/data/connectors/__init__.py +9 -0
  56. satchel/data/connectors/base.py +79 -0
  57. satchel/data/connectors/bigquery_conn.py +221 -0
  58. satchel/data/connectors/duckdb_conn.py +121 -0
  59. satchel/data/connectors/postgres.py +87 -0
  60. satchel/data/connectors/snowflake_conn.py +200 -0
  61. satchel/data/crawl.py +153 -0
  62. satchel/data/gateway.py +116 -0
  63. satchel/data/gcs.py +75 -0
  64. satchel/data/objstore.py +165 -0
  65. satchel/data/result_store.py +187 -0
  66. satchel/data/results.py +199 -0
  67. satchel/data/s3.py +114 -0
  68. satchel/data/semantic.py +349 -0
  69. satchel/evals/__init__.py +0 -0
  70. satchel/evals/gate.py +173 -0
  71. satchel/evals/graders.py +404 -0
  72. satchel/evals/harness.py +188 -0
  73. satchel/evals/providers.py +86 -0
  74. satchel/evals/retrieval.py +60 -0
  75. satchel/evals/runner.py +283 -0
  76. satchel/evals/store.py +107 -0
  77. satchel/evals/suite.py +156 -0
  78. satchel/install_hints.py +48 -0
  79. satchel/limits.py +216 -0
  80. satchel/llm/__init__.py +0 -0
  81. satchel/llm/chat.py +416 -0
  82. satchel/llm/connections.py +424 -0
  83. satchel/llm/embeddings.py +161 -0
  84. satchel/mcp/__init__.py +0 -0
  85. satchel/mcp/apps.py +54 -0
  86. satchel/mcp/auth.py +171 -0
  87. satchel/mcp/http.py +118 -0
  88. satchel/mcp/server.py +188 -0
  89. satchel/mcp/stdio.py +77 -0
  90. satchel/oauth/__init__.py +13 -0
  91. satchel/oauth/clients.py +216 -0
  92. satchel/oauth/common.py +105 -0
  93. satchel/oauth/flows.py +413 -0
  94. satchel/oauth/oidc.py +193 -0
  95. satchel/oauth/routes.py +335 -0
  96. satchel/oauth/sessions.py +77 -0
  97. satchel/oauth/tokens.py +181 -0
  98. satchel/resources/__init__.py +0 -0
  99. satchel/resources/compose.yml +24 -0
  100. satchel/resources/initdb/10-app-role.sql +18 -0
  101. satchel/resources/jaffle-shop/context/assets/warehouse/analytics/customers.yaml +12 -0
  102. satchel/resources/jaffle-shop/context/assets/warehouse/analytics/orders.yaml +13 -0
  103. satchel/resources/jaffle-shop/context/assets/warehouse/analytics/payments.yaml +15 -0
  104. satchel/resources/jaffle-shop/context/caveats/guest-checkout-customers.md +9 -0
  105. satchel/resources/jaffle-shop/context/caveats/refunds-pre-2023.md +8 -0
  106. satchel/resources/jaffle-shop/context/evals/core.yaml +113 -0
  107. satchel/resources/jaffle-shop/context/evals/retrieval.yaml +37 -0
  108. satchel/resources/jaffle-shop/context/glossary/business-terms.yaml +50 -0
  109. satchel/resources/jaffle-shop/context/guides/customer-analysis.md +33 -0
  110. satchel/resources/jaffle-shop/context/guides/payments-reconciliation.md +30 -0
  111. satchel/resources/jaffle-shop/context/guides/refunds-and-returns.md +31 -0
  112. satchel/resources/jaffle-shop/context/guides/revenue-reporting.md +38 -0
  113. satchel/resources/jaffle-shop/context/queries/active-customers-90d.yaml +10 -0
  114. satchel/resources/jaffle-shop/context/queries/average-order-value.yaml +13 -0
  115. satchel/resources/jaffle-shop/context/queries/monthly-revenue.yaml +15 -0
  116. satchel/resources/jaffle-shop/context/queries/refund-rate.yaml +21 -0
  117. satchel/resources/jaffle-shop/context/queries/top-customers-by-revenue.yaml +17 -0
  118. satchel/resources/jaffle-shop/context/satchel.config.yaml +9 -0
  119. satchel/resources/jaffle-shop/context/semantic/core/jaffle.mdl.json +147 -0
  120. satchel/resources/jaffle-shop/context/workspace.md +13 -0
  121. satchel/resources/jaffle-shop/seed.py +121 -0
  122. satchel/resources/ui/assets/MonacoImpl-CWtUooPL.js +654 -0
  123. satchel/resources/ui/assets/MonacoImpl-chSTM-fo.css +1 -0
  124. satchel/resources/ui/assets/editor.worker-CET2B_uC.js +12 -0
  125. satchel/resources/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  126. satchel/resources/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  127. satchel/resources/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  128. satchel/resources/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  129. satchel/resources/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  130. satchel/resources/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  131. satchel/resources/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  132. satchel/resources/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  133. satchel/resources/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  134. satchel/resources/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  135. satchel/resources/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  136. satchel/resources/ui/assets/index-D3Gzu3t6.js +147 -0
  137. satchel/resources/ui/assets/index-jj6sxlSE.css +1 -0
  138. satchel/resources/ui/assets/json.worker-BAR7yJ6b.js +51 -0
  139. satchel/resources/ui/assets/jsonMode-CVjFmlq-.js +10 -0
  140. satchel/resources/ui/assets/markdown-Cimd5fb3.js +1 -0
  141. satchel/resources/ui/assets/sql-NEE52Syq.js +1 -0
  142. satchel/resources/ui/assets/yaml-CCk2xI84.js +1 -0
  143. satchel/resources/ui/favicon.svg +1 -0
  144. satchel/resources/ui/index.html +22 -0
  145. satchel/resources/widgets/result.html +234 -0
  146. satchel/retrieval/__init__.py +4 -0
  147. satchel/retrieval/pack.py +201 -0
  148. satchel/retrieval/precedence.py +74 -0
  149. satchel/retrieval/search.py +573 -0
  150. satchel/runtime.py +187 -0
  151. satchel/secrets.py +143 -0
  152. satchel/settings.py +140 -0
  153. satchel/signals/__init__.py +0 -0
  154. satchel/signals/extractors.py +423 -0
  155. satchel/sources/__init__.py +7 -0
  156. satchel/sources/adapters/__init__.py +0 -0
  157. satchel/sources/adapters/cube/__init__.py +134 -0
  158. satchel/sources/adapters/cube/assets.py +259 -0
  159. satchel/sources/adapters/cube/deps.txt +3 -0
  160. satchel/sources/adapters/cube/exprs.py +127 -0
  161. satchel/sources/adapters/cube/meta.py +134 -0
  162. satchel/sources/adapters/cube/model.py +245 -0
  163. satchel/sources/adapters/cube/semantic.py +156 -0
  164. satchel/sources/adapters/dbt/__init__.py +106 -0
  165. satchel/sources/adapters/dbt/assets.py +266 -0
  166. satchel/sources/adapters/dbt/deps.txt +2 -0
  167. satchel/sources/adapters/dbt/manifest.py +218 -0
  168. satchel/sources/adapters/dbt/semantic.py +528 -0
  169. satchel/sources/adapters/sqlmesh/__init__.py +79 -0
  170. satchel/sources/adapters/sqlmesh/deps.txt +3 -0
  171. satchel/sources/adapters/sqlmesh/parse.py +330 -0
  172. satchel/sources/adapters/sqlmesh/project.py +321 -0
  173. satchel/sources/admin.py +282 -0
  174. satchel/sources/apply.py +547 -0
  175. satchel/sources/automation.py +245 -0
  176. satchel/sources/config.py +265 -0
  177. satchel/sources/dbt_build.py +520 -0
  178. satchel/sources/git.py +276 -0
  179. satchel/sources/items.py +344 -0
  180. satchel/sources/precedence.py +249 -0
  181. satchel/sources/registry.py +139 -0
  182. satchel/sources/repo.py +88 -0
  183. satchel/sources/service.py +306 -0
  184. satchel/store/__init__.py +0 -0
  185. satchel/store/alembic.ini +2 -0
  186. satchel/store/db.py +161 -0
  187. satchel/store/migrations/__init__.py +0 -0
  188. satchel/store/migrations/env.py +24 -0
  189. satchel/store/migrations/script.py.mako +21 -0
  190. satchel/store/migrations/versions/0001_initial.py +274 -0
  191. satchel/store/migrations/versions/0002_api_token.py +48 -0
  192. satchel/store/migrations/versions/0003_query_result.py +50 -0
  193. satchel/store/migrations/versions/0004_oauth.py +165 -0
  194. satchel/store/migrations/versions/0005_session_sensitive.py +24 -0
  195. satchel/store/migrations/versions/0006_curation.py +126 -0
  196. satchel/store/migrations/versions/0007_evals.py +98 -0
  197. satchel/store/migrations/versions/0008_feedback_source.py +28 -0
  198. satchel/store/migrations/versions/0009_sources.py +74 -0
  199. satchel/store/migrations/versions/0010_worker_role.py +155 -0
  200. satchel/store/migrations/versions/0011_audit_index.py +22 -0
  201. satchel/store/migrations/versions/0012_llm_connection.py +63 -0
  202. satchel/store/migrations/versions/__init__.py +0 -0
  203. satchel/store/models.py +727 -0
  204. satchel/store/roles.py +274 -0
  205. satchel/store/schema.py +95 -0
  206. satchel/store/scopes.py +28 -0
  207. satchel/sync/__init__.py +3 -0
  208. satchel/sync/chunking.py +82 -0
  209. satchel/sync/embedding_sync.py +46 -0
  210. satchel/sync/importer.py +411 -0
  211. satchel/sync/parse.py +290 -0
  212. satchel/sync/sqlrefs.py +31 -0
  213. satchel/tools/__init__.py +16 -0
  214. satchel/tools/data_tools.py +702 -0
  215. satchel/tools/definitions.py +586 -0
  216. satchel/tools/registry.py +301 -0
  217. satchel/tools/text.py +123 -0
  218. satchel/worker/__init__.py +0 -0
  219. satchel/worker/main.py +162 -0
  220. satchel_server-0.1.1.dist-info/METADATA +73 -0
  221. satchel_server-0.1.1.dist-info/RECORD +223 -0
  222. satchel_server-0.1.1.dist-info/WHEEL +4 -0
  223. satchel_server-0.1.1.dist-info/entry_points.txt +7 -0
satchel/__init__.py ADDED
File without changes
File without changes
satchel/api/app.py ADDED
@@ -0,0 +1,207 @@
1
+ """REST API (design §2, §14 M1/M3a): library, workbench, proposals, observability, catalog.
2
+
3
+ Everything is under ``/v1`` with role enforcement per design §9 (admin > curator > analyst >
4
+ explorer). The OAuth authorization server and OIDC login routes and ``/mcp`` are mounted here
5
+ too. Auth lives in :mod:`satchel.api.auth`; routers in the sibling modules. ``/openapi.json`` is
6
+ the contract the web UI generates its TypeScript types from (``satchel openapi``).
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException
13
+ from fastapi.responses import FileResponse, HTMLResponse
14
+ from fastapi.routing import APIRoute
15
+ from sqlalchemy import Engine, select
16
+
17
+ from satchel.api import (
18
+ audit,
19
+ catalog,
20
+ connections,
21
+ evals,
22
+ hooks,
23
+ library,
24
+ llm,
25
+ observability,
26
+ proposals,
27
+ sources,
28
+ suggestions,
29
+ workbench,
30
+ workspace_settings,
31
+ )
32
+ from satchel.api.auth import ApiContext, Auth, Caller
33
+ from satchel.api.health import mount_readyz
34
+ from satchel.api.schemas import (
35
+ MeOut,
36
+ PackEntryOut,
37
+ PackOut,
38
+ PackRequest,
39
+ SearchHitOut,
40
+ SearchRequest,
41
+ )
42
+ from satchel.llm.chat import ChatProvider
43
+ from satchel.llm.embeddings import EmbeddingProvider, make_embedder
44
+ from satchel.oauth.clients import CimdFetcher
45
+ from satchel.oauth.oidc import OidcProvider
46
+ from satchel.oauth.routes import OAuthApp, mount_oauth
47
+ from satchel.retrieval import build_context_pack, search_context
48
+ from satchel.runtime import TraceMiddleware
49
+ from satchel.settings import Settings, get_settings
50
+ from satchel.store.db import admin_session, get_engine, workspace_session
51
+ from satchel.store.models import AppUser, Workspace
52
+
53
+ __all__ = ["Caller", "create_app"]
54
+
55
+
56
+ # Paths the SPA fallback must never answer (they are APIs, so a miss is a real 404).
57
+ _API_PREFIXES = (
58
+ "v1", "auth", "oauth", "mcp", ".well-known", "openapi.json", "docs", "redoc", "healthz",
59
+ "readyz",
60
+ )
61
+ _DEFAULT_UI_DIR = Path(__file__).resolve().parents[3] / "ui" / "dist" # repo checkout
62
+ _PACKAGED_UI_DIR = Path(__file__).resolve().parents[1] / "resources" / "ui" # built into the wheel
63
+
64
+ _NO_UI_PAGE = """<!doctype html><meta charset="utf-8"><title>satchel</title>
65
+ <body style="font:15px/1.5 system-ui;max-width:640px;margin:48px auto;padding:0 16px">
66
+ <h1>Satchel is running</h1>
67
+ <p>The API and MCP endpoint are up, but this install has no built curator UI.</p>
68
+ <p>The PyPI package includes it: <code>uv tool install --force satchel-server</code>.
69
+ From a satchel repo checkout, build it and point satchel at it:</p>
70
+ <pre>cd ui &amp;&amp; npm ci &amp;&amp; npm run build
71
+ SATCHEL_UI_DIR=$PWD/dist satchel up</pre>
72
+ <p>Or reinstall from the checkout after building, so the UI is bundled:
73
+ <code>uv tool install --force ./server</code></p>
74
+ <p>MCP: <code>/mcp</code> &middot; API docs: <a href="/docs">/docs</a></p></body>"""
75
+
76
+
77
+ def _resolve_ui_dir(configured: str | None) -> Path | None:
78
+ """Configured dir, else the wheel's bundled UI, the repo checkout, or ./ui/dist."""
79
+ if configured:
80
+ return Path(configured).expanduser()
81
+ for cand in (_PACKAGED_UI_DIR, _DEFAULT_UI_DIR, Path.cwd() / "ui" / "dist"):
82
+ if (cand / "index.html").is_file():
83
+ return cand
84
+ return None
85
+
86
+
87
+ def _mount_ui(app: FastAPI, ui_dir: Path) -> bool:
88
+ """Serve the built curator UI at ``/`` with an SPA fallback (registered last)."""
89
+ root = ui_dir.resolve()
90
+ index = root / "index.html"
91
+ if not index.is_file():
92
+ return False
93
+
94
+ @app.get("/{path:path}", include_in_schema=False)
95
+ def spa(path: str) -> FileResponse:
96
+ head = path.split("/", 1)[0]
97
+ if head in _API_PREFIXES:
98
+ raise HTTPException(404, "Not Found")
99
+ candidate = (root / path).resolve()
100
+ if path and candidate.is_file() and candidate.is_relative_to(root):
101
+ return FileResponse(candidate)
102
+ return FileResponse(index, headers={"Cache-Control": "no-cache"})
103
+
104
+ return True
105
+
106
+
107
+ def _operation_id(route: APIRoute) -> str:
108
+ return route.name # stable, readable names for the generated TS client
109
+
110
+
111
+ def create_app(
112
+ engine: Engine | None = None, embedder: EmbeddingProvider | None = None, *,
113
+ mcp: bool = True, settings: Settings | None = None, oidc: OidcProvider | None = None,
114
+ cimd_fetcher: CimdFetcher | None = None, chat: ChatProvider | None = None,
115
+ ) -> FastAPI:
116
+ app = FastAPI(title="satchel", version="0.1.0", generate_unique_id_function=_operation_id)
117
+ app.add_middleware(TraceMiddleware)
118
+ eng = engine or get_engine()
119
+ emb = embedder or make_embedder()
120
+ st = settings or get_settings()
121
+ if mcp:
122
+ from satchel.mcp.http import McpHttp, mount_mcp
123
+
124
+ mount_mcp(app, McpHttp(eng, emb, st)) # Streamable HTTP at /mcp, bearer auth
125
+ mount_oauth(app, OAuthApp(eng, st, oidc, cimd_fetcher)) # /oauth/*, /auth/*, /.well-known/*
126
+
127
+ auth = Auth(ApiContext(eng, emb, st, chat))
128
+ Who = Annotated[Caller, Depends(auth.require("explorer"))]
129
+
130
+ @app.get("/healthz")
131
+ def healthz() -> dict[str, str]:
132
+ return {"status": "ok"}
133
+
134
+ mount_readyz(app, eng)
135
+
136
+ me = APIRouter(tags=["me"])
137
+
138
+ @me.get("/v1/me", response_model=MeOut)
139
+ def get_me(who: Who) -> MeOut:
140
+ with admin_session(engine=eng) as s:
141
+ ws = s.scalars(select(Workspace).where(Workspace.id == who.workspace_id)).one()
142
+ u = s.get(AppUser, who.user_id) if who.user_id else None
143
+ return MeOut(
144
+ user_id=who.user_id, email=u.email if u else None,
145
+ name=u.display_name if u else None, role=who.effective_role,
146
+ workspace_id=ws.id, workspace_slug=ws.slug, workspace_name=ws.name,
147
+ csrf_token=who.csrf, auth=who.auth,
148
+ )
149
+
150
+ app.include_router(me)
151
+ app.include_router(library.make_router(auth))
152
+ app.include_router(workbench.make_router(auth))
153
+ app.include_router(proposals.make_router(auth))
154
+ app.include_router(suggestions.make_router(auth))
155
+ app.include_router(observability.make_router(auth))
156
+ app.include_router(catalog.make_router(auth))
157
+ app.include_router(evals.make_router(auth))
158
+ app.include_router(sources.make_router(auth))
159
+ app.include_router(hooks.make_router(auth.ctx)) # unauthenticated push webhooks
160
+ app.include_router(audit.make_router(auth))
161
+ app.include_router(llm.make_router(auth))
162
+ app.include_router(connections.make_router(auth))
163
+ app.include_router(workspace_settings.make_router(auth))
164
+
165
+ search_r = APIRouter(tags=["retrieval"])
166
+
167
+ @search_r.post("/v1/search", response_model=list[SearchHitOut])
168
+ def search(req: SearchRequest, who: Who) -> list[SearchHitOut]:
169
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
170
+ hits = search_context(s, req.query, types=req.types, k=req.k, embedder=emb)
171
+ return [
172
+ SearchHitOut(
173
+ key=h.key, type=h.type, seq=h.seq, tag=h.tag, score=h.score,
174
+ sources=h.sources, snippet=h.snippet,
175
+ )
176
+ for h in hits
177
+ ]
178
+
179
+ @search_r.post("/v1/context-pack", response_model=PackOut)
180
+ def context_pack(req: PackRequest, who: Who) -> PackOut:
181
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
182
+ pack = build_context_pack(
183
+ s, req.question, who.user_id, embedder=emb, budget_tokens=req.budget_tokens
184
+ )
185
+ return PackOut(
186
+ release_id=pack.release_id,
187
+ budget_tokens=pack.budget_tokens,
188
+ used_tokens=pack.used_tokens,
189
+ text=pack.text,
190
+ entries=[
191
+ PackEntryOut(
192
+ key=e.key, type=e.type, seq=e.seq, tag=e.tag, tokens=e.tokens,
193
+ score=e.score, text=e.text,
194
+ )
195
+ for e in pack.entries
196
+ ],
197
+ )
198
+
199
+ app.include_router(search_r)
200
+ ui = _resolve_ui_dir(st.ui_dir)
201
+ if ui is None or not _mount_ui(app, ui):
202
+
203
+ @app.get("/", include_in_schema=False)
204
+ def no_ui() -> HTMLResponse:
205
+ return HTMLResponse(_NO_UI_PAGE)
206
+
207
+ return app
satchel/api/audit.py ADDED
@@ -0,0 +1,80 @@
1
+ """``GET /v1/audit``: the audit trail for SIEM pulls (admin, RLS-scoped, keyset paginated).
2
+
3
+ Events are ordered by ``(created_at, id)``. Poll with ``since`` and follow ``next_cursor``
4
+ until it is null; remember the last event's ``created_at`` as the next ``since``. For bulk
5
+ export to files or object storage use ``satchel audit export``.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Annotated, Any
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Query
12
+ from pydantic import BaseModel, Field
13
+
14
+ from satchel import audit as audit_lib
15
+ from satchel.api.auth import Auth, Caller
16
+ from satchel.store.db import workspace_session
17
+
18
+
19
+ class AuditEventOut(BaseModel):
20
+ id: int
21
+ workspace: str
22
+ actor_user_id: str | None
23
+ actor_email: str | None
24
+ action: str
25
+ target: dict[str, Any]
26
+ created_at: datetime
27
+
28
+
29
+ class AuditPage(BaseModel):
30
+ events: list[AuditEventOut]
31
+ next_cursor: str | None = Field(
32
+ default=None, description="Pass as `cursor` for the next page; null on the last page."
33
+ )
34
+
35
+
36
+ def _when(name: str, value: str | None) -> datetime | None:
37
+ if value is None:
38
+ return None
39
+ try:
40
+ return audit_lib.parse_when(value)
41
+ except ValueError as e:
42
+ raise HTTPException(422, f"{name}: {e}") from None
43
+
44
+
45
+ def make_router(auth: Auth) -> APIRouter:
46
+ r = APIRouter(tags=["audit"])
47
+ Adm = Annotated[Caller, Depends(auth.require("admin"))]
48
+ eng = auth.ctx.engine
49
+
50
+ @r.get("/v1/audit", response_model=AuditPage)
51
+ def list_audit(
52
+ who: Adm,
53
+ since: Annotated[str | None, Query(description="ISO-8601, inclusive")] = None,
54
+ until: Annotated[str | None, Query(description="ISO-8601, exclusive")] = None,
55
+ cursor: Annotated[str | None, Query(description="From a previous page")] = None,
56
+ limit: Annotated[int, Query(ge=1, le=1000)] = 500,
57
+ ) -> AuditPage:
58
+ after = None
59
+ if cursor is not None:
60
+ try:
61
+ after = audit_lib.decode_cursor(cursor)
62
+ except ValueError:
63
+ raise HTTPException(422, "invalid cursor") from None
64
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
65
+ rows, nxt = audit_lib.fetch_page(
66
+ s, since=_when("since", since), until=_when("until", until), after=after,
67
+ limit=limit, workspace_id=who.workspace_id,
68
+ )
69
+ events = [
70
+ AuditEventOut(
71
+ id=x.id, workspace=x.workspace,
72
+ actor_user_id=str(x.actor_user_id) if x.actor_user_id else None,
73
+ actor_email=x.actor_email, action=x.action, target=x.target,
74
+ created_at=x.created_at,
75
+ )
76
+ for x in rows
77
+ ]
78
+ return AuditPage(events=events, next_cursor=audit_lib.encode_cursor(nxt) if nxt else None)
79
+
80
+ return r
satchel/api/auth.py ADDED
@@ -0,0 +1,133 @@
1
+ """REST authentication + role enforcement (design §9).
2
+
3
+ Auth, first match wins: bearer PAT / OAuth access token; the ``satchel_session`` cookie (writes
4
+ must echo ``X-CSRF-Token``); and only with ``SATCHEL_DEV_AUTH=1`` the unverified
5
+ ``X-Satchel-Workspace`` / ``X-Satchel-User`` headers. ``require(role)`` builds a dependency that
6
+ also enforces a minimum role.
7
+ """
8
+
9
+ import uuid
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Annotated
13
+
14
+ from fastapi import Depends, Header, HTTPException, Request
15
+ from pydantic import BaseModel
16
+ from sqlalchemy import Engine, select
17
+
18
+ from satchel.llm.chat import ChatProvider
19
+ from satchel.llm.embeddings import EmbeddingProvider
20
+ from satchel.mcp.auth import ROLE_RANK, authenticate_bearer
21
+ from satchel.oauth import sessions
22
+ from satchel.settings import Settings
23
+ from satchel.store.db import admin_session
24
+ from satchel.store.models import AppUser, Workspace
25
+
26
+
27
+ @dataclass
28
+ class ApiContext:
29
+ engine: Engine
30
+ embedder: EmbeddingProvider
31
+ settings: Settings
32
+ chat: ChatProvider | None = None # overrides the configured chat provider (tests)
33
+
34
+
35
+ class Caller(BaseModel):
36
+ workspace_id: uuid.UUID
37
+ user_id: uuid.UUID | None
38
+ role: str | None = None
39
+ csrf: str | None = None # only for cookie sessions
40
+ auth: str = "dev" # bearer | cookie | dev
41
+
42
+ @property
43
+ def effective_role(self) -> str:
44
+ """Dev-header calls without a user act as the lowest role."""
45
+ return self.role if self.role in ROLE_RANK else "explorer"
46
+
47
+ def has_role(self, minimum: str) -> bool:
48
+ return ROLE_RANK[self.effective_role] >= ROLE_RANK[minimum]
49
+
50
+
51
+ def _unauth(detail: str) -> HTTPException:
52
+ return HTTPException(401, detail, headers={"WWW-Authenticate": 'Bearer realm="satchel"'})
53
+
54
+
55
+ class Auth:
56
+ def __init__(self, ctx: ApiContext) -> None:
57
+ self.ctx = ctx
58
+
59
+ def caller(
60
+ self,
61
+ request: Request,
62
+ authorization: Annotated[str | None, Header()] = None,
63
+ x_satchel_workspace: Annotated[str | None, Header()] = None,
64
+ x_satchel_user: Annotated[str | None, Header()] = None,
65
+ ) -> Caller:
66
+ eng, st = self.ctx.engine, self.ctx.settings
67
+ if authorization:
68
+ p = authenticate_bearer(authorization, engine=eng, settings=st)
69
+ if p is None:
70
+ raise _unauth("invalid, expired or revoked token")
71
+ return Caller(
72
+ workspace_id=p.workspace_id, user_id=p.user_id, role=p.role, auth="bearer"
73
+ )
74
+ cookie = request.cookies.get(sessions.COOKIE)
75
+ if cookie:
76
+ info = sessions.load_session(cookie, engine=eng)
77
+ if info is None:
78
+ raise _unauth("session expired")
79
+ if request.method not in sessions.SAFE_METHODS and not sessions.csrf_ok(
80
+ info, request.headers.get(sessions.CSRF_HEADER)
81
+ ):
82
+ raise HTTPException(403, "missing or invalid X-CSRF-Token")
83
+ return Caller(
84
+ workspace_id=info.workspace_id, user_id=info.user_id, role=info.role,
85
+ csrf=info.csrf, auth="cookie",
86
+ )
87
+ if not st.dev_auth:
88
+ raise _unauth("authentication required")
89
+ # DEV-ONLY header auth (SATCHEL_DEV_AUTH=1): unverified. Without headers (e.g. a UI build
90
+ # without VITE_DEV_AUTH) it acts as the first admin of the configured workspace
91
+ # (SATCHEL_WORKSPACE / config file), so `SATCHEL_DEV_AUTH=1 satchel up` works with any UI
92
+ # build.
93
+ slug = x_satchel_workspace or st.workspace
94
+ if not slug:
95
+ raise HTTPException(401, "dev auth: send X-Satchel-Workspace or set SATCHEL_WORKSPACE")
96
+ with admin_session(engine=eng) as s:
97
+ ws = s.scalars(select(Workspace).where(Workspace.slug == slug)).first()
98
+ if ws is None:
99
+ raise HTTPException(404, f"unknown workspace: {slug}")
100
+ if not x_satchel_user and not x_satchel_workspace:
101
+ admin = s.scalars(
102
+ select(AppUser)
103
+ .where(AppUser.workspace_id == ws.id, AppUser.role == "admin")
104
+ .order_by(AppUser.created_at)
105
+ ).first()
106
+ if admin is not None:
107
+ return Caller(workspace_id=ws.id, user_id=admin.id, role="admin", auth="dev")
108
+ user_id: uuid.UUID | None = None
109
+ role: str | None = None
110
+ if x_satchel_user:
111
+ try:
112
+ user_id = uuid.UUID(x_satchel_user)
113
+ except ValueError:
114
+ raise HTTPException(400, "X-Satchel-User must be a UUID") from None
115
+ u = s.scalars(
116
+ select(AppUser).where(AppUser.id == user_id, AppUser.workspace_id == ws.id)
117
+ ).first()
118
+ if u is None:
119
+ raise HTTPException(403, "user is not a member of this workspace")
120
+ role = u.role
121
+ return Caller(workspace_id=ws.id, user_id=user_id, role=role, auth="dev")
122
+
123
+ def require(self, minimum: str) -> Callable[..., Caller]:
124
+ """Dependency: an authenticated caller with at least ``minimum`` role."""
125
+
126
+ def dep(who: Annotated[Caller, Depends(self.caller)]) -> Caller:
127
+ if not who.has_role(minimum):
128
+ raise HTTPException(
129
+ 403, f"requires the {minimum} role; your role is {who.effective_role}"
130
+ )
131
+ return who
132
+
133
+ return dep
satchel/api/catalog.py ADDED
@@ -0,0 +1,200 @@
1
+ """Catalog: connections and the asset tree with documentation / endorsement state."""
2
+
3
+ import uuid
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
7
+ from sqlalchemy import ColumnElement, exists, func, or_, select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from satchel.api.auth import Auth, Caller
11
+ from satchel.api.common import escape_like
12
+ from satchel.api.connections import secret_source
13
+ from satchel.api.schemas import (
14
+ AssetDetail,
15
+ AssetLinkedItem,
16
+ AssetNode,
17
+ AssetPage,
18
+ ConnectionOut,
19
+ DocRefOut,
20
+ )
21
+ from satchel.core.item_types import yaml_item_dict
22
+ from satchel.sources.precedence import DocRef, group_by_key, load_sources
23
+ from satchel.store.db import workspace_session
24
+ from satchel.store.models import (
25
+ Asset,
26
+ Connection,
27
+ ContextEntityLink,
28
+ ContextItem,
29
+ ContextVersion,
30
+ )
31
+
32
+
33
+ class _Docs:
34
+ """Documentation and endorsement lookups from the *published* context.
35
+
36
+ When several sources (or a hand-written doc) describe one asset, the primary doc is used
37
+ (see ``satchel.sources.precedence``) and the others are listed as ``also_described_by``."""
38
+
39
+ def __init__(self, s: Session) -> None:
40
+ self.doc: dict[uuid.UUID, tuple[str, bool]] = {}
41
+ self.refs: dict[uuid.UUID, tuple[DocRef, list[DocRef]]] = {}
42
+ groups = group_by_key(
43
+ ((asset_id, item, ver) for asset_id, item, ver in s.execute(
44
+ select(ContextEntityLink.asset_id, ContextItem, ContextVersion)
45
+ .join(ContextVersion, ContextVersion.id == ContextEntityLink.version_id)
46
+ .join(ContextItem, ContextItem.published_version_id == ContextVersion.id)
47
+ .where(
48
+ ContextItem.type == "asset_doc",
49
+ ContextEntityLink.relation == "defines",
50
+ ContextItem.state != "deprecated",
51
+ )
52
+ )),
53
+ load_sources(s),
54
+ )
55
+ for asset_id, g in groups.items():
56
+ prim = g.primary
57
+ self.doc[asset_id] = (
58
+ prim.item.key, bool(prim.version.meta.get("endorsed")) if prim.version else False
59
+ )
60
+ self.refs[asset_id] = (prim.ref, [m.ref for m in g.others])
61
+ self.endorse: dict[str, tuple[bool, bool]] = {}
62
+ for meta, body in s.execute(
63
+ select(ContextVersion.meta, ContextVersion.body)
64
+ .join(ContextItem, ContextItem.published_version_id == ContextVersion.id)
65
+ .where(ContextItem.type == "endorsement", ContextItem.state != "deprecated")
66
+ ):
67
+ d = yaml_item_dict("endorsement", body, meta)
68
+ if d.get("asset"):
69
+ self.endorse[str(d["asset"]).lower()] = (
70
+ bool(d.get("endorsed")), bool(d.get("excluded_from_ai")),
71
+ )
72
+
73
+ def node(self, a: Asset, conn_name: str | None) -> AssetNode:
74
+ doc = self.doc.get(a.id)
75
+ e_flag, excluded = self.endorse.get(a.fqn.lower(), (False, False))
76
+ prim, others = self.refs.get(a.id, (None, []))
77
+ return AssetNode(
78
+ id=a.id, connection=conn_name, kind=a.kind, fqn=a.fqn, name=a.fqn.split(".")[-1],
79
+ data_type=a.data_type, dropped=a.dropped_at is not None, has_doc=doc is not None,
80
+ doc_item_key=doc[0] if doc else None, endorsed=bool(doc and doc[1]) or e_flag,
81
+ excluded_from_ai=excluded,
82
+ primary=DocRefOut(source=prim.label, key=prim.key, primary=True) if prim else None,
83
+ also_described_by=[DocRefOut(source=o.label, key=o.key) for o in others],
84
+ )
85
+
86
+
87
+ def _tree(s: Session, roots: list[Asset], conn_names: dict[uuid.UUID, str]) -> list[AssetNode]:
88
+ docs = _Docs(s)
89
+ nodes: dict[uuid.UUID, AssetNode] = {}
90
+ level = roots
91
+ out = [docs.node(a, conn_names.get(a.connection_id) if a.connection_id else None)
92
+ for a in roots]
93
+ for a, n in zip(roots, out, strict=True):
94
+ nodes[a.id] = n
95
+ for _ in range(5): # database > schema > table > column
96
+ if not level:
97
+ break
98
+ kids = list(
99
+ s.scalars(
100
+ select(Asset).where(Asset.parent_id.in_([a.id for a in level])).order_by(Asset.fqn)
101
+ )
102
+ )
103
+ for k in kids:
104
+ node = docs.node(k, conn_names.get(k.connection_id) if k.connection_id else None)
105
+ nodes[k.id] = node
106
+ if k.parent_id in nodes:
107
+ nodes[k.parent_id].children.append(node)
108
+ level = kids
109
+ return out
110
+
111
+
112
+ def make_router(auth: Auth) -> APIRouter:
113
+ r = APIRouter(tags=["catalog"])
114
+ Any_ = Annotated[Caller, Depends(auth.require("explorer"))]
115
+ eng = auth.ctx.engine
116
+
117
+ @r.get("/v1/connections", response_model=list[ConnectionOut])
118
+ def list_connections(who: Any_) -> list[ConnectionOut]:
119
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
120
+ n = (
121
+ select(func.count())
122
+ .where(Asset.connection_id == Connection.id, Asset.dropped_at.is_(None))
123
+ .scalar_subquery()
124
+ )
125
+ return [
126
+ ConnectionOut(
127
+ id=c.id, name=c.name, kind=c.kind, default_for_ai=c.default_for_ai,
128
+ has_secret=c.secret_ref is not None, asset_count=cnt,
129
+ secret=secret_source(c.secret_ref),
130
+ config=dict(c.config or {}) if who.has_role("admin") else None,
131
+ )
132
+ for c, cnt in s.execute(select(Connection, n).order_by(Connection.name))
133
+ ]
134
+
135
+ @r.get("/v1/assets", response_model=AssetPage)
136
+ def list_assets(
137
+ who: Any_,
138
+ connection: Annotated[str | None, Query(description="Connection name")] = None,
139
+ q: Annotated[str | None, Query(description="Substring of an asset name")] = None,
140
+ limit: Annotated[int, Query(ge=1, le=500)] = 100,
141
+ offset: Annotated[int, Query(ge=0)] = 0,
142
+ ) -> AssetPage:
143
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
144
+ conn_names = {c.id: c.name for c in s.scalars(select(Connection))}
145
+ conds: list[ColumnElement[bool]] = [Asset.parent_id.is_(None)]
146
+ if connection:
147
+ cid = next((i for i, n in conn_names.items() if n == connection), None)
148
+ if cid is None:
149
+ raise HTTPException(404, f"no such connection: {connection}")
150
+ conds.append(Asset.connection_id == cid)
151
+ if q and q.strip():
152
+ pat = f"%{escape_like(q.strip())}%"
153
+ child = Asset.__table__.alias("child")
154
+ conds.append(
155
+ or_(
156
+ Asset.fqn.ilike(pat, escape="\\"),
157
+ exists().where(child.c.parent_id == Asset.id,
158
+ child.c.fqn.ilike(pat, escape="\\")),
159
+ )
160
+ )
161
+ total = s.scalar(select(func.count()).select_from(Asset).where(*conds)) or 0
162
+ roots = list(
163
+ s.scalars(
164
+ select(Asset).where(*conds).order_by(Asset.fqn).limit(limit).offset(offset)
165
+ )
166
+ )
167
+ return AssetPage(total=total, limit=limit, offset=offset,
168
+ assets=_tree(s, roots, conn_names))
169
+
170
+ @r.get("/v1/assets/{asset_id}", response_model=AssetDetail)
171
+ def get_asset(asset_id: uuid.UUID, who: Any_) -> AssetDetail:
172
+ with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
173
+ a = s.get(Asset, asset_id)
174
+ if a is None:
175
+ raise HTTPException(404, "no such asset")
176
+ conn_names = {c.id: c.name for c in s.scalars(select(Connection))}
177
+ node = _tree(s, [a], conn_names)[0]
178
+ linked = s.execute(
179
+ select(ContextItem.key, ContextItem.type, ContextItem.state,
180
+ ContextEntityLink.relation)
181
+ .join(ContextVersion, ContextVersion.item_id == ContextItem.id)
182
+ .join(ContextEntityLink, ContextEntityLink.version_id == ContextVersion.id)
183
+ .where(
184
+ ContextEntityLink.asset_id == asset_id,
185
+ or_(
186
+ ContextVersion.id == ContextItem.published_version_id,
187
+ ContextVersion.id == ContextItem.current_version_id,
188
+ ),
189
+ )
190
+ .distinct()
191
+ .order_by(ContextItem.key, ContextEntityLink.relation)
192
+ )
193
+ return AssetDetail(
194
+ **node.model_dump(), stats=a.stats, first_seen_at=a.first_seen_at,
195
+ last_seen_at=a.last_seen_at,
196
+ linked_items=[AssetLinkedItem(key=k, type=t, state=st, relation=rel)
197
+ for k, t, st, rel in linked],
198
+ )
199
+
200
+ return r