interloper-api 0.16.0__tar.gz → 0.18.0__tar.gz

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 (32) hide show
  1. {interloper_api-0.16.0 → interloper_api-0.18.0}/PKG-INFO +1 -1
  2. {interloper_api-0.16.0 → interloper_api-0.18.0}/pyproject.toml +1 -1
  3. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/app.py +7 -1
  4. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/dependencies.py +34 -0
  5. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/assets.py +49 -14
  6. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/backfills.py +4 -3
  7. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/destinations.py +27 -4
  8. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/__init__.py +2 -0
  9. interloper_api-0.18.0/src/interloper_api/routes/external/criteo.py +68 -0
  10. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/jobs.py +43 -19
  11. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/oauth.py +18 -11
  12. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/organisations.py +3 -0
  13. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/resources.py +44 -9
  14. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/runs.py +34 -10
  15. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/sources.py +26 -4
  16. {interloper_api-0.16.0 → interloper_api-0.18.0}/README.md +0 -0
  17. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/__init__.py +0 -0
  18. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/email.py +0 -0
  19. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/__init__.py +0 -0
  20. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/admin.py +0 -0
  21. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/agent.py +0 -0
  22. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/auth.py +0 -0
  23. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/catalog.py +0 -0
  24. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
  25. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
  26. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_ads.py +0 -0
  27. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_cloud.py +0 -0
  28. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/impact.py +0 -0
  29. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
  30. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
  31. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/tiktok_ads.py +0 -0
  32. {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.16.0"
6
+ version = "0.18.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from typing import Any
6
7
 
7
8
  from fastapi import APIRouter, FastAPI
@@ -27,6 +28,8 @@ from interloper_api.routes import (
27
28
  )
28
29
  from interloper_api.routes import catalog as catalog_routes
29
30
 
31
+ logger = logging.getLogger(__name__)
32
+
30
33
 
31
34
  def create_app(
32
35
  store: Store | None = None,
@@ -91,7 +94,10 @@ def create_app(
91
94
 
92
95
  api.include_router(agent_routes.router, prefix="/agent", tags=["agent"])
93
96
  except ImportError:
94
- pass
97
+ logger.warning(
98
+ "Agent routes not mounted: the 'agent' extra is not installed "
99
+ "(install interloper-api[agent]); /agent endpoints will return 404."
100
+ )
95
101
 
96
102
  @api.get("/health")
97
103
  def health() -> dict[str, str]:
@@ -217,6 +217,40 @@ def get_org_id(
217
217
  # -- RBAC dependencies -------------------------------------------------------
218
218
 
219
219
 
220
+ def authorize_org_member(
221
+ user: Profile,
222
+ org_id: UUID,
223
+ store: Store,
224
+ *,
225
+ minimum: str = "viewer",
226
+ detail: str = "Not found",
227
+ ) -> None:
228
+ """Authorize access to a resource owned by ``org_id`` by membership.
229
+
230
+ Unlike the ``require_*`` dependencies, which bind to the session's *active*
231
+ organisation, this checks the user's role in the resource's organisation —
232
+ so ID-addressed endpoints work for members of the owning org regardless of
233
+ which org is currently selected. Non-members get a 404 carrying the same
234
+ ``detail`` as a missing resource, so IDs don't act as an existence oracle;
235
+ members with an insufficient role get a 403.
236
+
237
+ Args:
238
+ user: The authenticated user.
239
+ org_id: The organisation that owns the resource.
240
+ store: The Store instance.
241
+ minimum: Minimum role required (``viewer``, ``editor``, ``admin``).
242
+ detail: 404 detail, matching the route's missing-resource message.
243
+
244
+ Raises:
245
+ HTTPException: 404 if not a member, 403 if the role is insufficient.
246
+ """
247
+ role = store.get_user_role(user.id, org_id)
248
+ if role is None:
249
+ raise HTTPException(status_code=404, detail=detail)
250
+ if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
251
+ raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
252
+
253
+
220
254
  def _check_role(
221
255
  minimum: str,
222
256
  user: Profile,
@@ -12,7 +12,14 @@ from interloper_db.models import Asset, AssetResource, Destination, DestinationR
12
12
  from pydantic import BaseModel
13
13
  from sqlmodel import Session, select
14
14
 
15
- from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
15
+ from interloper_api.dependencies import (
16
+ authorize_org_member,
17
+ get_current_user,
18
+ get_org_id,
19
+ get_store,
20
+ require_editor,
21
+ require_viewer,
22
+ )
16
23
 
17
24
  router = APIRouter()
18
25
 
@@ -116,6 +123,30 @@ def _dest_to_response(dest: Destination) -> DestinationResponse:
116
123
  )
117
124
 
118
125
 
126
+ def _load_authorized_asset(asset_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Asset:
127
+ """Load an asset and authorize the user by membership in its org.
128
+
129
+ Args:
130
+ asset_id: The asset UUID.
131
+ user: The authenticated user.
132
+ store: The Store instance.
133
+ minimum: Minimum role required in the asset's organisation.
134
+
135
+ Returns:
136
+ The Asset row.
137
+
138
+ Raises:
139
+ HTTPException: 404 if missing or the user is not a member of the
140
+ owning org, 403 if the role is insufficient.
141
+ """
142
+ try:
143
+ asset = store.get_asset(asset_id)
144
+ except NotFoundError:
145
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
146
+ authorize_org_member(user, asset.org_id, store, minimum=minimum, detail=f"Asset {asset_id} not found")
147
+ return asset
148
+
149
+
119
150
  def _asset_to_response(asset: Asset) -> AssetResponse:
120
151
  """Convert a DB Asset to an AssetResponse."""
121
152
  return AssetResponse(
@@ -183,14 +214,11 @@ def list_dependencies(
183
214
  @router.get("/{asset_id}")
184
215
  def get_asset(
185
216
  asset_id: UUID,
186
- user: Profile = Depends(require_viewer),
217
+ user: Profile = Depends(get_current_user),
187
218
  store: Store = Depends(get_store),
188
219
  ) -> AssetResponse:
189
- """Get a single asset by ID."""
190
- try:
191
- asset = store.get_asset(asset_id)
192
- except NotFoundError:
193
- raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
220
+ """Get a single asset by ID. Authorized by membership in the asset's org."""
221
+ asset = _load_authorized_asset(asset_id, user, store)
194
222
  return _asset_to_response(asset)
195
223
 
196
224
 
@@ -198,10 +226,11 @@ def get_asset(
198
226
  def update_asset(
199
227
  asset_id: UUID,
200
228
  body: AssetUpdateRequest,
201
- user: Profile = Depends(require_editor),
229
+ user: Profile = Depends(get_current_user),
202
230
  store: Store = Depends(get_store),
203
231
  ) -> AssetResponse:
204
232
  """Update an asset's configuration, resources, or destinations."""
233
+ _load_authorized_asset(asset_id, user, store, minimum="editor")
205
234
  try:
206
235
  asset = store.update_asset(
207
236
  asset_id,
@@ -218,10 +247,11 @@ def update_asset(
218
247
  @router.delete("/{asset_id}")
219
248
  def delete_asset(
220
249
  asset_id: UUID,
221
- user: Profile = Depends(require_editor),
250
+ user: Profile = Depends(get_current_user),
222
251
  store: Store = Depends(get_store),
223
252
  ) -> dict[str, str]:
224
253
  """Delete a standalone asset."""
254
+ _load_authorized_asset(asset_id, user, store, minimum="editor")
225
255
  try:
226
256
  store.delete_asset(asset_id)
227
257
  except NotFoundError:
@@ -238,10 +268,14 @@ def delete_asset(
238
268
  def add_dependency(
239
269
  asset_id: UUID,
240
270
  body: AddDependencyRequest,
241
- user: Profile = Depends(require_editor),
271
+ user: Profile = Depends(get_current_user),
242
272
  store: Store = Depends(get_store),
243
273
  ) -> DependencyResponse:
244
- """Add a dependency between two assets."""
274
+ """Add a dependency between two assets. Both must belong to the same org."""
275
+ asset = _load_authorized_asset(asset_id, user, store, minimum="editor")
276
+ upstream = _load_authorized_asset(body.upstream_asset_id, user, store, minimum="editor")
277
+ if upstream.org_id != asset.org_id:
278
+ raise HTTPException(status_code=404, detail=f"Asset {body.upstream_asset_id} not found")
245
279
  try:
246
280
  dep = store.add_dependency(asset_id, body.upstream_asset_id, body.param_name)
247
281
  except NotFoundError as e:
@@ -256,10 +290,11 @@ def add_dependency(
256
290
  def remove_dependency(
257
291
  asset_id: UUID,
258
292
  upstream_asset_id: UUID,
259
- user: Profile = Depends(require_editor),
293
+ user: Profile = Depends(get_current_user),
260
294
  store: Store = Depends(get_store),
261
295
  ) -> None:
262
296
  """Remove an asset dependency."""
297
+ _load_authorized_asset(asset_id, user, store, minimum="editor")
263
298
  try:
264
299
  store.remove_dependency(asset_id, upstream_asset_id)
265
300
  except NotFoundError as e:
@@ -272,11 +307,11 @@ def remove_dependency(
272
307
  @router.get("/{asset_id}/partition-row-counts")
273
308
  def get_partition_row_counts(
274
309
  asset_id: UUID,
275
- user: Profile = Depends(require_viewer),
276
- org_id: UUID = Depends(get_org_id),
310
+ user: Profile = Depends(get_current_user),
277
311
  store: Store = Depends(get_store),
278
312
  ) -> PartitionRowCountsResponse:
279
313
  """Get row counts grouped by partition for an asset."""
314
+ _load_authorized_asset(asset_id, user, store)
280
315
  try:
281
316
  il_asset = store.load_asset(asset_id)
282
317
  except NotFoundError as e:
@@ -11,7 +11,7 @@ from interloper_db import Profile, Store
11
11
  from interloper_db.models import Backfill
12
12
  from pydantic import BaseModel
13
13
 
14
- from interloper_api.dependencies import get_org_id, get_store, require_viewer
14
+ from interloper_api.dependencies import authorize_org_member, get_current_user, get_org_id, get_store, require_viewer
15
15
 
16
16
  router = APIRouter()
17
17
 
@@ -76,12 +76,13 @@ def list_backfills(
76
76
  @router.get("/{backfill_id}")
77
77
  def get_backfill(
78
78
  backfill_id: UUID,
79
- user: Profile = Depends(require_viewer),
79
+ user: Profile = Depends(get_current_user),
80
80
  store: Store = Depends(get_store),
81
81
  ) -> BackfillResponse:
82
- """Get a single backfill by ID."""
82
+ """Get a single backfill by ID. Authorized by membership in the backfill's org."""
83
83
  try:
84
84
  backfill = store.get_backfill(backfill_id)
85
85
  except NotFoundError:
86
86
  raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
87
+ authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
87
88
  return _backfill_to_response(backfill)
@@ -7,11 +7,18 @@ from uuid import UUID
7
7
 
8
8
  from fastapi import APIRouter, Depends, HTTPException
9
9
  from interloper.errors import NotFoundError
10
- from interloper_db import Store
10
+ from interloper_db import Profile, Store
11
11
  from interloper_db.models import Destination, DestinationResource
12
12
  from pydantic import BaseModel
13
13
 
14
- from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
14
+ from interloper_api.dependencies import (
15
+ authorize_org_member,
16
+ get_current_user,
17
+ get_org_id,
18
+ get_store,
19
+ require_editor,
20
+ require_viewer,
21
+ )
15
22
 
16
23
  router = APIRouter()
17
24
 
@@ -87,14 +94,29 @@ def create_destination(
87
94
  return _to_response(dest)
88
95
 
89
96
 
97
+ def _authorize_destination(destination_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> None:
98
+ """Authorize the user by membership in the destination's org.
99
+
100
+ Raises:
101
+ HTTPException: 404 if missing or the user is not a member of the
102
+ owning org, 403 if the role is insufficient.
103
+ """
104
+ try:
105
+ dest = store.get_destination(destination_id)
106
+ except NotFoundError:
107
+ raise HTTPException(status_code=404, detail=f"Destination {destination_id} not found")
108
+ authorize_org_member(user, dest.org_id, store, minimum=minimum, detail=f"Destination {destination_id} not found")
109
+
110
+
90
111
  @router.put("/{destination_id}")
91
112
  def update_destination(
92
113
  destination_id: UUID,
93
114
  body: DestinationCreateRequest,
94
- user: object = Depends(require_editor),
115
+ user: Profile = Depends(get_current_user),
95
116
  store: Store = Depends(get_store),
96
117
  ) -> DestinationResponse:
97
118
  """Update a destination."""
119
+ _authorize_destination(destination_id, user, store, minimum="editor")
98
120
  try:
99
121
  dest = store.update_destination(
100
122
  destination_id,
@@ -110,9 +132,10 @@ def update_destination(
110
132
  @router.delete("/{destination_id}")
111
133
  def delete_destination(
112
134
  destination_id: UUID,
113
- user: object = Depends(require_editor),
135
+ user: Profile = Depends(get_current_user),
114
136
  store: Store = Depends(get_store),
115
137
  ) -> dict[str, str]:
116
138
  """Delete a destination."""
139
+ _authorize_destination(destination_id, user, store, minimum="editor")
117
140
  store.delete_destination(destination_id)
118
141
  return {"status": "deleted"}
@@ -36,6 +36,7 @@ def handle_error(error: Exception, context: str) -> None:
36
36
 
37
37
  # Import and register sub-routers after helpers are defined.
38
38
  from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
39
+ from interloper_api.routes.external.criteo import sub_router as criteo_router # noqa: E402
39
40
  from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
40
41
  from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
41
42
  from interloper_api.routes.external.google_cloud import sub_router as google_cloud_router # noqa: E402
@@ -45,6 +46,7 @@ from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_a
45
46
  from interloper_api.routes.external.tiktok_ads import sub_router as tiktok_ads_router # noqa: E402
46
47
 
47
48
  router.include_router(amazon_ads_router)
49
+ router.include_router(criteo_router)
48
50
  router.include_router(facebook_ads_router)
49
51
  router.include_router(google_ads_router)
50
52
  router.include_router(google_cloud_router)
@@ -0,0 +1,68 @@
1
+ """Criteo external API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from fastapi import APIRouter, Depends
7
+ from interloper_db import Profile
8
+ from pydantic import BaseModel
9
+
10
+ from interloper_api.dependencies import require_viewer
11
+ from interloper_api.routes.external import handle_error
12
+
13
+ sub_router = APIRouter()
14
+
15
+ _BASE_URL = "https://api.criteo.com"
16
+ _TOKEN_URL = f"{_BASE_URL}/oauth2/token"
17
+ _ADVERTISERS_URL = f"{_BASE_URL}/2025-04/advertisers/me"
18
+
19
+
20
+ class CriteoConnectionRequest(BaseModel):
21
+ """Criteo connection credentials (matches CriteoConnection fields)."""
22
+
23
+ client_id: str
24
+ client_secret: str
25
+ refresh_token: str
26
+
27
+
28
+ async def _get_access_token(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> str:
29
+ """Exchange the refresh token for an access token."""
30
+ resp = await client.post(
31
+ _TOKEN_URL,
32
+ data={
33
+ "grant_type": "refresh_token",
34
+ "client_id": body.client_id,
35
+ "client_secret": body.client_secret,
36
+ "refresh_token": body.refresh_token,
37
+ },
38
+ )
39
+ resp.raise_for_status()
40
+ return resp.json()["access_token"]
41
+
42
+
43
+ async def _list_advertisers(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> list[dict[str, str]]:
44
+ """List the advertisers the connection can access, sorted by name."""
45
+ token = await _get_access_token(client, body)
46
+ resp = await client.get(_ADVERTISERS_URL, headers={"Authorization": f"Bearer {token}"})
47
+ resp.raise_for_status()
48
+
49
+ advertisers = resp.json().get("data", [])
50
+ results = [
51
+ {"id": a["id"], "name": a.get("attributes", {}).get("advertiserName") or a["id"]}
52
+ for a in advertisers
53
+ ]
54
+ return sorted(results, key=lambda a: a["name"].lower())
55
+
56
+
57
+ @sub_router.post("/criteo/advertisers")
58
+ async def criteo_advertisers(
59
+ body: CriteoConnectionRequest,
60
+ _user: Profile = Depends(require_viewer),
61
+ ) -> list[dict[str, str]]:
62
+ """Fetch the Criteo advertisers accessible by the connection."""
63
+ try:
64
+ async with httpx.AsyncClient(timeout=30) as client:
65
+ return await _list_advertisers(client, body)
66
+ except Exception as exc:
67
+ handle_error(exc, "fetching Criteo advertisers")
68
+ return []
@@ -11,7 +11,14 @@ from interloper_db import Profile, Store
11
11
  from interloper_db.models import Job
12
12
  from pydantic import BaseModel
13
13
 
14
- from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
14
+ from interloper_api.dependencies import (
15
+ authorize_org_member,
16
+ get_current_user,
17
+ get_org_id,
18
+ get_store,
19
+ require_editor,
20
+ require_viewer,
21
+ )
15
22
 
16
23
  router = APIRouter()
17
24
 
@@ -62,6 +69,30 @@ class BackfillRequest(BaseModel):
62
69
  fail_fast: bool = False
63
70
 
64
71
 
72
+ def _load_authorized_job(job_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Job:
73
+ """Load a job and authorize the user by membership in its org.
74
+
75
+ Args:
76
+ job_id: The job UUID.
77
+ user: The authenticated user.
78
+ store: The Store instance.
79
+ minimum: Minimum role required in the job's organisation.
80
+
81
+ Returns:
82
+ The Job row.
83
+
84
+ Raises:
85
+ HTTPException: 404 if missing or the user is not a member of the
86
+ owning org, 403 if the role is insufficient.
87
+ """
88
+ try:
89
+ job = store.get_job(job_id)
90
+ except NotFoundError:
91
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
92
+ authorize_org_member(user, job.org_id, store, minimum=minimum, detail=f"Job {job_id} not found")
93
+ return job
94
+
95
+
65
96
  def _job_to_response(job: Job) -> JobResponse:
66
97
  """Convert a DB Job to a JobResponse.
67
98
 
@@ -102,14 +133,11 @@ def list_jobs(
102
133
  @router.get("/{job_id}")
103
134
  def get_job(
104
135
  job_id: UUID,
105
- user: Profile = Depends(require_viewer),
136
+ user: Profile = Depends(get_current_user),
106
137
  store: Store = Depends(get_store),
107
138
  ) -> JobResponse:
108
- """Get a single job by ID."""
109
- try:
110
- job = store.get_job(job_id)
111
- except NotFoundError:
112
- raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
139
+ """Get a single job by ID. Authorized by membership in the job's org."""
140
+ job = _load_authorized_job(job_id, user, store)
113
141
  return _job_to_response(job)
114
142
 
115
143
 
@@ -139,10 +167,11 @@ def create_job(
139
167
  def update_job(
140
168
  job_id: UUID,
141
169
  body: JobCreateRequest,
142
- user: Profile = Depends(require_editor),
170
+ user: Profile = Depends(get_current_user),
143
171
  store: Store = Depends(get_store),
144
172
  ) -> JobResponse:
145
173
  """Update an existing job."""
174
+ _load_authorized_job(job_id, user, store, minimum="editor")
146
175
  try:
147
176
  job = store.update_job(
148
177
  job_id,
@@ -163,10 +192,11 @@ def update_job(
163
192
  @router.delete("/{job_id}")
164
193
  def delete_job(
165
194
  job_id: UUID,
166
- user: Profile = Depends(require_editor),
195
+ user: Profile = Depends(get_current_user),
167
196
  store: Store = Depends(get_store),
168
197
  ) -> dict[str, str]:
169
198
  """Delete a job."""
199
+ _load_authorized_job(job_id, user, store, minimum="editor")
170
200
  store.delete_job(job_id)
171
201
  return {"status": "deleted"}
172
202
 
@@ -175,14 +205,11 @@ def delete_job(
175
205
  def queue_run(
176
206
  job_id: UUID,
177
207
  body: RunRequest | None = None,
178
- user: Profile = Depends(require_editor),
208
+ user: Profile = Depends(get_current_user),
179
209
  store: Store = Depends(get_store),
180
210
  ) -> dict[str, str]:
181
211
  """Queue a single run for a job."""
182
- try:
183
- job = store.get_job(job_id)
184
- except NotFoundError:
185
- raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
212
+ job = _load_authorized_job(job_id, user, store, minimum="editor")
186
213
 
187
214
  run = store.create_run(
188
215
  job.org_id,
@@ -196,14 +223,11 @@ def queue_run(
196
223
  def queue_backfill(
197
224
  job_id: UUID,
198
225
  body: BackfillRequest,
199
- user: Profile = Depends(require_editor),
226
+ user: Profile = Depends(get_current_user),
200
227
  store: Store = Depends(get_store),
201
228
  ) -> dict[str, str]:
202
229
  """Queue a backfill for a job."""
203
- try:
204
- job = store.get_job(job_id)
205
- except NotFoundError:
206
- raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
230
+ job = _load_authorized_job(job_id, user, store, minimum="editor")
207
231
 
208
232
  backfill = store.create_backfill(
209
233
  job.org_id,
@@ -24,10 +24,13 @@ import os
24
24
  from typing import Any
25
25
 
26
26
  import httpx
27
- from fastapi import APIRouter, HTTPException
27
+ from fastapi import APIRouter, Depends, HTTPException
28
28
  from interloper.oauth import OAuthProvider, providers
29
+ from interloper_db import Profile
29
30
  from pydantic import BaseModel
30
31
 
32
+ from interloper_api.dependencies import get_current_user
33
+
31
34
  logger = logging.getLogger(__name__)
32
35
 
33
36
  router = APIRouter(prefix="/oauth", tags=["oauth"])
@@ -77,8 +80,10 @@ async def _exchange(
77
80
  code: The authorization code.
78
81
 
79
82
  Returns:
80
- The token response, with ``client_id`` / ``client_secret`` injected
81
- so they can be stored alongside the tokens.
83
+ The provider's raw token response (e.g. ``refresh_token``). The app
84
+ credentials are *not* injected: they are the in-house per-provider
85
+ values resolved from env at runtime, so the secret never leaves the
86
+ server.
82
87
  """
83
88
  logical_values = {
84
89
  "grant_type": "authorization_code",
@@ -103,10 +108,7 @@ async def _exchange(
103
108
  resp = await client.post(spec.token_url, json=params, headers=headers)
104
109
  resp.raise_for_status()
105
110
 
106
- result = resp.json()
107
- result["client_id"] = cfg.client_id
108
- result["client_secret"] = cfg.client_secret
109
- return result
111
+ return resp.json()
110
112
 
111
113
 
112
114
  # ---------------------------------------------------------------------------
@@ -155,11 +157,16 @@ def list_providers() -> list[ProviderInfo]:
155
157
 
156
158
 
157
159
  @router.post("/{provider}")
158
- async def exchange_token(provider: str, body: TokenExchangeRequest) -> dict[str, Any]:
159
- """Exchange an authorization code for tokens.
160
+ async def exchange_token(
161
+ provider: str,
162
+ body: TokenExchangeRequest,
163
+ _user: Profile = Depends(get_current_user),
164
+ ) -> dict[str, Any]:
165
+ """Exchange an authorization code for tokens. Requires authentication.
160
166
 
161
- The response includes ``client_id`` and ``client_secret`` so they
162
- can be stored alongside the tokens in the connection data.
167
+ Returns only the provider's token response (e.g. ``refresh_token``); the
168
+ in-house app credentials are never included connections resolve them
169
+ from env at runtime.
163
170
  """
164
171
  spec = providers().get(provider)
165
172
  if spec is None:
@@ -240,6 +240,9 @@ def cancel_invitation(
240
240
  store: Store = Depends(get_store),
241
241
  ) -> dict[str, str]:
242
242
  """Cancel a pending invitation. Requires admin role."""
243
+ invitations = store.list_invitations(org_id)
244
+ if not any(inv.id == invitation_id for inv in invitations):
245
+ raise HTTPException(status_code=404, detail="Invitation not found")
243
246
  if not store.delete_invitation(invitation_id):
244
247
  raise HTTPException(status_code=404, detail="Invitation not found")
245
248
 
@@ -8,9 +8,18 @@ from uuid import UUID
8
8
  from fastapi import APIRouter, Depends, HTTPException
9
9
  from interloper.errors import NotFoundError
10
10
  from interloper_db import Profile, Store
11
+ from interloper_db.models import Resource
11
12
  from pydantic import BaseModel
12
13
 
13
- from interloper_api.dependencies import get_catalog, get_org_id, get_store, require_editor, require_viewer
14
+ from interloper_api.dependencies import (
15
+ authorize_org_member,
16
+ get_catalog,
17
+ get_current_user,
18
+ get_org_id,
19
+ get_store,
20
+ require_editor,
21
+ require_viewer,
22
+ )
14
23
 
15
24
  router = APIRouter()
16
25
 
@@ -49,6 +58,30 @@ class ResourceDetailResponse(ResourceResponse):
49
58
  data: dict[str, Any] = {}
50
59
 
51
60
 
61
+ def _load_authorized_resource(resource_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Resource:
62
+ """Load a resource and authorize the user by membership in its org.
63
+
64
+ Args:
65
+ resource_id: The resource UUID.
66
+ user: The authenticated user.
67
+ store: The Store instance.
68
+ minimum: Minimum role required in the resource's organisation.
69
+
70
+ Returns:
71
+ The Resource row.
72
+
73
+ Raises:
74
+ HTTPException: 404 if missing or the user is not a member of the
75
+ owning org, 403 if the role is insufficient.
76
+ """
77
+ try:
78
+ resource = store.load_resource(resource_id)
79
+ except NotFoundError:
80
+ raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found")
81
+ authorize_org_member(user, resource.org_id, store, minimum=minimum, detail=f"Resource {resource_id} not found")
82
+ return resource
83
+
84
+
52
85
  @router.get("/kinds")
53
86
  def list_resource_kinds(
54
87
  catalog: dict[str, Any] = Depends(get_catalog),
@@ -90,14 +123,14 @@ def list_resources(
90
123
  @router.get("/{resource_id}")
91
124
  def get_resource(
92
125
  resource_id: UUID,
93
- user: Profile = Depends(require_viewer),
126
+ user: Profile = Depends(get_current_user),
94
127
  store: Store = Depends(get_store),
95
128
  ) -> ResourceDetailResponse:
96
- """Get a single resource by ID, including its data payload."""
97
- try:
98
- r = store.load_resource(resource_id)
99
- except NotFoundError:
100
- raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found")
129
+ """Get a single resource by ID, including its data payload.
130
+
131
+ Authorized by membership in the resource's org.
132
+ """
133
+ r = _load_authorized_resource(resource_id, user, store)
101
134
 
102
135
  return ResourceDetailResponse(
103
136
  id=r.id,
@@ -144,10 +177,11 @@ def create_resource(
144
177
  def update_resource(
145
178
  resource_id: UUID,
146
179
  body: ResourceCreateRequest,
147
- user: Profile = Depends(require_editor),
180
+ user: Profile = Depends(get_current_user),
148
181
  store: Store = Depends(get_store),
149
182
  ) -> ResourceResponse:
150
183
  """Update an existing resource."""
184
+ _load_authorized_resource(resource_id, user, store, minimum="editor")
151
185
  try:
152
186
  resource = store.update_resource(
153
187
  resource_id,
@@ -174,9 +208,10 @@ def update_resource(
174
208
  @router.delete("/{resource_id}")
175
209
  def delete_resource(
176
210
  resource_id: UUID,
177
- user: Profile = Depends(require_editor),
211
+ user: Profile = Depends(get_current_user),
178
212
  store: Store = Depends(get_store),
179
213
  ) -> dict[str, str]:
180
214
  """Delete a resource."""
215
+ _load_authorized_resource(resource_id, user, store, minimum="editor")
181
216
  store.delete_resource(resource_id)
182
217
  return {"status": "deleted"}
@@ -12,7 +12,7 @@ from interloper_db import Profile, Store
12
12
  from interloper_db.models import Event, Run
13
13
  from pydantic import BaseModel
14
14
 
15
- from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
15
+ from interloper_api.dependencies import authorize_org_member, get_current_user, get_org_id, get_store, require_viewer
16
16
 
17
17
  router = APIRouter()
18
18
 
@@ -100,6 +100,30 @@ def _run_to_response(run: Run) -> RunResponse:
100
100
  )
101
101
 
102
102
 
103
+ def _load_authorized_run(run_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Run:
104
+ """Load a run and authorize the user by membership in its org.
105
+
106
+ Args:
107
+ run_id: The run UUID.
108
+ user: The authenticated user.
109
+ store: The Store instance.
110
+ minimum: Minimum role required in the run's organisation.
111
+
112
+ Returns:
113
+ The Run row.
114
+
115
+ Raises:
116
+ HTTPException: 404 if missing or the user is not a member of the
117
+ owning org, 403 if the role is insufficient.
118
+ """
119
+ try:
120
+ run = store.get_run(run_id)
121
+ except NotFoundError:
122
+ raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
123
+ authorize_org_member(user, run.org_id, store, minimum=minimum, detail=f"Run {run_id} not found")
124
+ return run
125
+
126
+
103
127
  def _event_to_response(event: Event) -> EventResponse:
104
128
  """Convert a DB Event to an EventResponse.
105
129
 
@@ -158,24 +182,22 @@ def list_runs(
158
182
  @router.get("/{run_id}")
159
183
  def get_run(
160
184
  run_id: UUID,
161
- user: Profile = Depends(require_viewer),
185
+ user: Profile = Depends(get_current_user),
162
186
  store: Store = Depends(get_store),
163
187
  ) -> RunResponse:
164
- """Get a single run by ID."""
165
- try:
166
- run = store.get_run(run_id)
167
- except NotFoundError:
168
- raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
188
+ """Get a single run by ID. Authorized by membership in the run's org."""
189
+ run = _load_authorized_run(run_id, user, store)
169
190
  return _run_to_response(run)
170
191
 
171
192
 
172
193
  @router.get("/{run_id}/asset-executions")
173
194
  def list_asset_executions(
174
195
  run_id: UUID,
175
- user: Profile = Depends(require_viewer),
196
+ user: Profile = Depends(get_current_user),
176
197
  store: Store = Depends(get_store),
177
198
  ) -> list[AssetExecutionResponse]:
178
199
  """List asset executions for a run."""
200
+ _load_authorized_run(run_id, user, store)
179
201
  rows = store.list_asset_executions(run_id)
180
202
  return [
181
203
  AssetExecutionResponse(
@@ -196,7 +218,7 @@ def list_asset_executions(
196
218
  def retry_run(
197
219
  run_id: UUID,
198
220
  body: RetryRequest | None = None,
199
- user: Profile = Depends(require_editor),
221
+ user: Profile = Depends(get_current_user),
200
222
  store: Store = Depends(get_store),
201
223
  ) -> dict[str, str]:
202
224
  """Queue a retry of a failed run.
@@ -205,6 +227,7 @@ def retry_run(
205
227
  ``scope="all"`` the whole DAG re-runs; with ``scope="failed"`` only the
206
228
  previously failed/cancelled assets re-run.
207
229
  """
230
+ _load_authorized_run(run_id, user, store, minimum="editor")
208
231
  scope = body.scope if body else "all"
209
232
  try:
210
233
  run = store.retry_run(run_id, scope=scope)
@@ -222,7 +245,7 @@ def list_run_events(
222
245
  limit: int = 100,
223
246
  offset: int = 0,
224
247
  asset_id: UUID | None = None,
225
- user: Profile = Depends(require_viewer),
248
+ user: Profile = Depends(get_current_user),
226
249
  store: Store = Depends(get_store),
227
250
  ) -> list[EventResponse]:
228
251
  """List events for a run, oldest first.
@@ -234,6 +257,7 @@ def list_run_events(
234
257
  through every event — including the terminal/outcome events
235
258
  (``asset_completed``, ``asset_failed``, ``run_failed``, …) that sort last.
236
259
  """
260
+ _load_authorized_run(run_id, user, store)
237
261
  limit = max(1, min(limit, MAX_EVENTS_PAGE_SIZE))
238
262
  offset = max(0, offset)
239
263
  total = store.count_events(run_id=run_id, asset_id=asset_id)
@@ -13,7 +13,14 @@ from pydantic import BaseModel
13
13
  from sqlalchemy.orm import selectinload
14
14
  from sqlmodel import Session, select
15
15
 
16
- from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
16
+ from interloper_api.dependencies import (
17
+ authorize_org_member,
18
+ get_current_user,
19
+ get_org_id,
20
+ get_store,
21
+ require_editor,
22
+ require_viewer,
23
+ )
17
24
 
18
25
  router = APIRouter()
19
26
 
@@ -152,15 +159,29 @@ def create_source(
152
159
  return _load_source_for_response(source.id)
153
160
 
154
161
 
162
+ def _authorize_source(source_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> None:
163
+ """Authorize the user by membership in the source's org.
164
+
165
+ Raises:
166
+ HTTPException: 404 if missing or the user is not a member of the
167
+ owning org, 403 if the role is insufficient.
168
+ """
169
+ try:
170
+ source = store.get_source(source_id)
171
+ except NotFoundError:
172
+ raise HTTPException(status_code=404, detail=f"Source {source_id} not found")
173
+ authorize_org_member(user, source.org_id, store, minimum=minimum, detail=f"Source {source_id} not found")
174
+
175
+
155
176
  @router.put("/{source_id}")
156
177
  def update_source(
157
178
  source_id: UUID,
158
179
  body: SourceCreateRequest,
159
- user: Profile = Depends(require_editor),
160
- org_id: UUID = Depends(get_org_id),
180
+ user: Profile = Depends(get_current_user),
161
181
  store: Store = Depends(get_store),
162
182
  ) -> SourceResponse:
163
183
  """Update a source."""
184
+ _authorize_source(source_id, user, store, minimum="editor")
164
185
  try:
165
186
  store.update_source(
166
187
  source_id,
@@ -179,9 +200,10 @@ def update_source(
179
200
  @router.delete("/{source_id}")
180
201
  def delete_source(
181
202
  source_id: UUID,
182
- user: Profile = Depends(require_editor),
203
+ user: Profile = Depends(get_current_user),
183
204
  store: Store = Depends(get_store),
184
205
  ) -> dict[str, str]:
185
206
  """Delete a source. Assets cascade via FK."""
207
+ _authorize_source(source_id, user, store, minimum="editor")
186
208
  store.delete_source(source_id)
187
209
  return {"status": "deleted"}