Skip to content

Commit 398c18a

Browse files
committed
fix: scope bundle list aggregates to API key environment
list_bundle_names() and get_bundle_enrichment() now accept an optional env parameter. When an env-scoped API key calls GET /bundles, the version_count, latest_version, last_updated, contract_count, and last_deployed_at are all scoped to versions deployed in the key's environment. Prevents cross-env metadata leakage (e.g. staging key learning production has 7 newer versions).
1 parent 8640280 commit 398c18a

File tree

2 files changed

+84
-38
lines changed

2 files changed

+84
-38
lines changed

src/edictum_server/routes/bundles.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,17 @@ async def list_bundles(
102102
When accessed via API key, only bundles deployed to the key's environment
103103
are returned.
104104
"""
105-
names = await list_bundle_names(db, auth.tenant_id)
105+
env_filter = auth.env if (auth.auth_type == "api_key" and auth.env) else None
106+
names = await list_bundle_names(db, auth.tenant_id, env=env_filter)
106107
envs_by_name = await get_deployed_envs_by_bundle_name(db, auth.tenant_id)
107-
enrichment = await get_bundle_enrichment(db, auth.tenant_id)
108+
enrichment = await get_bundle_enrichment(db, auth.tenant_id, env=env_filter)
108109
result: list[BundleSummaryResponse] = []
109110
for entry in names:
110111
bname = str(entry["name"])
111112
deployed_envs = envs_by_name.get(bname, [])
112-
# API key auth: only return bundles deployed to the key's env
113-
if auth.auth_type == "api_key" and auth.env:
114-
if auth.env not in deployed_envs:
115-
continue
116-
# Narrow deployed_envs to only the key's env
117-
deployed_envs = [auth.env]
113+
# API key auth: narrow visible envs to the key's env
114+
if env_filter:
115+
deployed_envs = [env_filter] if env_filter in deployed_envs else []
118116
enrich = enrichment.get(bname, {})
119117
result.append(
120118
BundleSummaryResponse(

src/edictum_server/services/bundle_service.py

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,46 @@ async def list_tenant_bundles(
135135
async def list_bundle_names(
136136
db: AsyncSession,
137137
tenant_id: uuid.UUID,
138+
*,
139+
env: str | None = None,
138140
) -> list[dict[str, object]]:
139-
"""Return distinct bundle names with aggregates (version_count, latest_version)."""
140-
result = await db.execute(
141-
select(
142-
Bundle.name,
143-
func.count(Bundle.id).label("version_count"),
144-
func.max(Bundle.version).label("latest_version"),
145-
func.max(Bundle.created_at).label("last_updated"),
141+
"""Return distinct bundle names with aggregates (version_count, latest_version).
142+
143+
When *env* is provided, only versions deployed to that environment are
144+
counted — prevents cross-env metadata leakage for env-scoped API keys.
145+
"""
146+
if env is not None:
147+
# Scoped: only count versions that have a deployment to this env
148+
stmt = (
149+
select(
150+
Bundle.name,
151+
func.count(func.distinct(Bundle.id)).label("version_count"),
152+
func.max(Bundle.version).label("latest_version"),
153+
func.max(Bundle.created_at).label("last_updated"),
154+
)
155+
.join(
156+
Deployment,
157+
(Bundle.tenant_id == Deployment.tenant_id)
158+
& (Bundle.name == Deployment.bundle_name)
159+
& (Bundle.version == Deployment.bundle_version),
160+
)
161+
.where(Bundle.tenant_id == tenant_id, Deployment.env == env)
162+
.group_by(Bundle.name)
163+
.order_by(func.max(Bundle.created_at).desc())
146164
)
147-
.where(Bundle.tenant_id == tenant_id)
148-
.group_by(Bundle.name)
149-
.order_by(func.max(Bundle.created_at).desc())
150-
)
165+
else:
166+
stmt = (
167+
select(
168+
Bundle.name,
169+
func.count(Bundle.id).label("version_count"),
170+
func.max(Bundle.version).label("latest_version"),
171+
func.max(Bundle.created_at).label("last_updated"),
172+
)
173+
.where(Bundle.tenant_id == tenant_id)
174+
.group_by(Bundle.name)
175+
.order_by(func.max(Bundle.created_at).desc())
176+
)
177+
result = await db.execute(stmt)
151178
return [
152179
{
153180
"name": row.name,
@@ -235,9 +262,7 @@ async def get_deployed_envs_by_bundle_name(
235262
.subquery()
236263
)
237264

238-
result = await db.execute(
239-
select(ranked.c.bundle_name, ranked.c.env).where(ranked.c.rn == 1)
240-
)
265+
result = await db.execute(select(ranked.c.bundle_name, ranked.c.env).where(ranked.c.rn == 1))
241266

242267
mapping: dict[str, list[str]] = defaultdict(list)
243268
for name, env in result.all():
@@ -250,28 +275,48 @@ async def get_deployed_envs_by_bundle_name(
250275
async def get_bundle_enrichment(
251276
db: AsyncSession,
252277
tenant_id: uuid.UUID,
278+
*,
279+
env: str | None = None,
253280
) -> dict[str, dict[str, object]]:
254281
"""Return contract_count and last_deployed_at per bundle name.
255282
256-
contract_count is parsed from the latest version's YAML.
283+
contract_count is parsed from the latest deployed version's YAML.
257284
last_deployed_at comes from the deployments table.
285+
286+
When *env* is provided, both the "latest version" subquery and the
287+
deployment timestamp are scoped to that environment — prevents
288+
cross-env metadata leakage for env-scoped API keys.
258289
"""
259-
# Get latest version per bundle name
260-
latest_subq = (
261-
select(
262-
Bundle.name,
263-
func.max(Bundle.version).label("max_version"),
290+
# Get latest version per bundle name (optionally scoped to env)
291+
if env is not None:
292+
# Latest version deployed to this specific env
293+
latest_subq = (
294+
select(
295+
Deployment.bundle_name.label("name"),
296+
func.max(Deployment.bundle_version).label("max_version"),
297+
)
298+
.where(Deployment.tenant_id == tenant_id, Deployment.env == env)
299+
.group_by(Deployment.bundle_name)
300+
.subquery()
301+
)
302+
else:
303+
# Latest version across all envs (dashboard view)
304+
latest_subq = (
305+
select(
306+
Bundle.name,
307+
func.max(Bundle.version).label("max_version"),
308+
)
309+
.where(Bundle.tenant_id == tenant_id)
310+
.group_by(Bundle.name)
311+
.subquery()
264312
)
265-
.where(Bundle.tenant_id == tenant_id)
266-
.group_by(Bundle.name)
267-
.subquery()
268-
)
269313
latest_bundles = await db.execute(
270-
select(Bundle).join(
314+
select(Bundle)
315+
.join(
271316
latest_subq,
272-
(Bundle.name == latest_subq.c.name)
273-
& (Bundle.version == latest_subq.c.max_version),
274-
).where(Bundle.tenant_id == tenant_id)
317+
(Bundle.name == latest_subq.c.name) & (Bundle.version == latest_subq.c.max_version),
318+
)
319+
.where(Bundle.tenant_id == tenant_id)
275320
)
276321

277322
enrichment: dict[str, dict[str, object]] = {}
@@ -287,13 +332,16 @@ async def get_bundle_enrichment(
287332
pass
288333
enrichment[bundle.name] = {"contract_count": contract_count, "last_deployed_at": None}
289334

290-
# Get last deployment date per bundle name
335+
# Get last deployment date per bundle name (scoped to env if provided)
336+
dep_filters = [Deployment.tenant_id == tenant_id]
337+
if env is not None:
338+
dep_filters.append(Deployment.env == env)
291339
dep_result = await db.execute(
292340
select(
293341
Deployment.bundle_name,
294342
func.max(Deployment.created_at).label("last_deployed_at"),
295343
)
296-
.where(Deployment.tenant_id == tenant_id)
344+
.where(*dep_filters)
297345
.group_by(Deployment.bundle_name)
298346
)
299347
for row in dep_result.all():

0 commit comments

Comments
 (0)