loki-mode 7.47.0 → 7.49.0
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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +85 -2
- package/autonomy/crash.sh +47 -21
- package/autonomy/loki +50 -27
- package/autonomy/run.sh +378 -1
- package/autonomy/spec-interrogation.sh +1 -0
- package/autonomy/telemetry.sh +28 -8
- package/bin/postinstall.js +22 -10
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_v2.py +258 -12
- package/dashboard/server.py +64 -10
- package/dashboard/telemetry.py +34 -6
- package/docs/INSTALLATION.md +10 -3
- package/docs/PRIVACY.md +82 -24
- package/loki-ts/dist/loki.js +30 -30
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/dashboard/api_v2.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing import Any, Optional
|
|
|
20
20
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
21
21
|
from fastapi.responses import StreamingResponse
|
|
22
22
|
from pydantic import BaseModel, Field
|
|
23
|
+
from sqlalchemy import select
|
|
23
24
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
24
25
|
|
|
25
26
|
from . import auth
|
|
@@ -28,6 +29,7 @@ from . import api_keys
|
|
|
28
29
|
from . import tenants as tenants_mod
|
|
29
30
|
from . import runs as runs_mod
|
|
30
31
|
from .database import get_db
|
|
32
|
+
from .models import Project, Run
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
# ---------------------------------------------------------------------------
|
|
@@ -120,6 +122,178 @@ def _audit_context(request: Request, token_info: Optional[dict] = None) -> dict:
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Tenant isolation (P3-7)
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
#
|
|
129
|
+
# The audit (backlog P3-7) flagged that API-level cross-tenant access was NOT
|
|
130
|
+
# enforced: any authenticated caller with the `read` scope could list every
|
|
131
|
+
# tenant and read any tenant's projects, regardless of which tenant they
|
|
132
|
+
# belong to. A caller authenticated for tenant A could read tenant B's data.
|
|
133
|
+
#
|
|
134
|
+
# The caller's tenant MUST come from the trusted, server-validated token --
|
|
135
|
+
# never from a client-supplied request header, which the caller could simply
|
|
136
|
+
# set to a victim tenant's id and bypass the boundary entirely. The auth layer
|
|
137
|
+
# (dashboard/auth.py) has no dedicated tenant field, but a validated token's
|
|
138
|
+
# `scopes` list IS trusted: it is returned by validate_token() (from the
|
|
139
|
+
# server-side token store) and by validate_oidc_token() (from
|
|
140
|
+
# cryptographically/issuer-validated claims). We therefore bind a token to a
|
|
141
|
+
# tenant via a `tenant:<id>` scope, parsed out of token_info["scopes"]. To
|
|
142
|
+
# scope a token to tenant 5, mint it with that scope in its scope list, e.g.
|
|
143
|
+
# via the API-key endpoint or auth.generate_token:
|
|
144
|
+
#
|
|
145
|
+
# POST /api/v2/api-keys {"name": "...", "scopes": ["read", "tenant:5"]}
|
|
146
|
+
# auth.generate_token(name="...", scopes=["read", "tenant:5"])
|
|
147
|
+
#
|
|
148
|
+
# A token's scopes decide crossing rights:
|
|
149
|
+
#
|
|
150
|
+
# * A global admin (scope `*`, i.e. the admin role) may cross any tenant.
|
|
151
|
+
# * A non-admin token is pinned to the tenant in its `tenant:<id>` scope;
|
|
152
|
+
# a request that targets a different tenant is denied with 403.
|
|
153
|
+
# * When auth is disabled (no enterprise token auth and no OIDC) there is no
|
|
154
|
+
# caller identity to isolate -- this is single-user local mode -- so access
|
|
155
|
+
# is not restricted.
|
|
156
|
+
#
|
|
157
|
+
# This boundary is deliberately fail-closed for authenticated non-admin
|
|
158
|
+
# callers: a token with no `tenant:<id>` scope cannot reach ANY tenant-scoped
|
|
159
|
+
# resource (every cross-tenant check fails), so an un-scoped token is not
|
|
160
|
+
# silently granted access to a tenant it was never bound to.
|
|
161
|
+
|
|
162
|
+
TENANT_SCOPE_PREFIX = "tenant:"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TenantContext:
|
|
166
|
+
"""Resolved tenant boundary for the current caller.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
tenant_id: The tenant the caller's token is bound to (parsed from its
|
|
170
|
+
trusted `tenant:<id>` scope), or None if the token carries no
|
|
171
|
+
tenant scope.
|
|
172
|
+
is_global_admin: True if the caller holds admin scope and may
|
|
173
|
+
legitimately cross tenant boundaries.
|
|
174
|
+
auth_enabled: True if any auth method (token or OIDC) is active.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
__slots__ = ("tenant_id", "is_global_admin", "auth_enabled")
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
tenant_id: Optional[int],
|
|
182
|
+
is_global_admin: bool,
|
|
183
|
+
auth_enabled: bool,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.tenant_id = tenant_id
|
|
186
|
+
self.is_global_admin = is_global_admin
|
|
187
|
+
self.auth_enabled = auth_enabled
|
|
188
|
+
|
|
189
|
+
def enforce(self, target_tenant_id: int) -> None:
|
|
190
|
+
"""Raise 403 if the caller may not access the given tenant's resources.
|
|
191
|
+
|
|
192
|
+
A global admin may access any tenant. When auth is disabled there is
|
|
193
|
+
no caller to isolate, so access is allowed. Otherwise the caller's
|
|
194
|
+
token-bound tenant must exactly match the target tenant.
|
|
195
|
+
"""
|
|
196
|
+
if self.is_global_admin or not self.auth_enabled:
|
|
197
|
+
return
|
|
198
|
+
if self.tenant_id is None or self.tenant_id != target_tenant_id:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=403,
|
|
201
|
+
detail="Cross-tenant access denied",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _tenant_id_from_token(token_info: Optional[dict]) -> Optional[int]:
|
|
206
|
+
"""Extract the caller's tenant id from a validated token's scopes.
|
|
207
|
+
|
|
208
|
+
Looks for a single `tenant:<id>` scope in the (trusted) token scope list.
|
|
209
|
+
Returns None if the token carries no such scope. If the token carries
|
|
210
|
+
conflicting tenant scopes (more than one distinct tenant), access is
|
|
211
|
+
denied (403) rather than silently picking one.
|
|
212
|
+
"""
|
|
213
|
+
if not token_info:
|
|
214
|
+
return None
|
|
215
|
+
found: set[int] = set()
|
|
216
|
+
for scope in token_info.get("scopes", []):
|
|
217
|
+
if isinstance(scope, str) and scope.startswith(TENANT_SCOPE_PREFIX):
|
|
218
|
+
raw = scope[len(TENANT_SCOPE_PREFIX):].strip()
|
|
219
|
+
try:
|
|
220
|
+
found.add(int(raw))
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
# Malformed tenant scope -- ignore it (does not grant access).
|
|
223
|
+
continue
|
|
224
|
+
if not found:
|
|
225
|
+
return None
|
|
226
|
+
if len(found) > 1:
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=403,
|
|
229
|
+
detail="Token carries conflicting tenant scopes",
|
|
230
|
+
)
|
|
231
|
+
return next(iter(found))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_tenant_context(
|
|
235
|
+
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
236
|
+
) -> TenantContext:
|
|
237
|
+
"""FastAPI dependency that resolves the caller's tenant boundary.
|
|
238
|
+
|
|
239
|
+
The caller's tenant is derived solely from the trusted, server-validated
|
|
240
|
+
token (its `tenant:<id>` scope); whether the caller is a global admin (and
|
|
241
|
+
may cross tenants) is read from the same token's scopes. No client-supplied
|
|
242
|
+
header is consulted, so a caller cannot impersonate another tenant.
|
|
243
|
+
"""
|
|
244
|
+
auth_enabled = auth.is_enterprise_mode() or auth.is_oidc_mode()
|
|
245
|
+
is_global_admin = bool(token_info) and auth.has_scope(token_info, "admin")
|
|
246
|
+
tenant_id = _tenant_id_from_token(token_info)
|
|
247
|
+
return TenantContext(
|
|
248
|
+
tenant_id=tenant_id,
|
|
249
|
+
is_global_admin=is_global_admin,
|
|
250
|
+
auth_enabled=auth_enabled,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _enforce_project_tenant(
|
|
255
|
+
db: AsyncSession, tenant_ctx: TenantContext, project_id: int
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Enforce the tenant boundary for a project referenced by id.
|
|
258
|
+
|
|
259
|
+
Loads the project's tenant_id and applies tenant_ctx.enforce. A missing
|
|
260
|
+
project yields 404 (so existence is not leaked across tenants any more
|
|
261
|
+
than the boundary already allows). For a global admin / auth-disabled
|
|
262
|
+
caller this is a cheap pass-through that does not need the lookup.
|
|
263
|
+
"""
|
|
264
|
+
if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
|
|
265
|
+
return
|
|
266
|
+
result = await db.execute(
|
|
267
|
+
select(Project.tenant_id).where(Project.id == project_id)
|
|
268
|
+
)
|
|
269
|
+
owner_tenant_id = result.scalar_one_or_none()
|
|
270
|
+
if owner_tenant_id is None:
|
|
271
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
272
|
+
tenant_ctx.enforce(owner_tenant_id)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _enforce_run_tenant(
|
|
276
|
+
db: AsyncSession, tenant_ctx: TenantContext, run_id: int
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Enforce the tenant boundary for a run referenced by id.
|
|
279
|
+
|
|
280
|
+
A run belongs to a project, which belongs to a tenant. We resolve the
|
|
281
|
+
run -> project -> tenant chain and apply the boundary. A missing run
|
|
282
|
+
yields 404.
|
|
283
|
+
"""
|
|
284
|
+
if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
|
|
285
|
+
return
|
|
286
|
+
result = await db.execute(
|
|
287
|
+
select(Project.tenant_id)
|
|
288
|
+
.join(Run, Run.project_id == Project.id)
|
|
289
|
+
.where(Run.id == run_id)
|
|
290
|
+
)
|
|
291
|
+
owner_tenant_id = result.scalar_one_or_none()
|
|
292
|
+
if owner_tenant_id is None:
|
|
293
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
294
|
+
tenant_ctx.enforce(owner_tenant_id)
|
|
295
|
+
|
|
296
|
+
|
|
123
297
|
# ===========================================================================
|
|
124
298
|
# TENANT ENDPOINTS
|
|
125
299
|
# ===========================================================================
|
|
@@ -149,18 +323,33 @@ async def create_tenant(
|
|
|
149
323
|
@router.get("/tenants", dependencies=[Depends(auth.require_scope("read"))])
|
|
150
324
|
async def list_tenants(
|
|
151
325
|
db: AsyncSession = Depends(get_db),
|
|
326
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
152
327
|
):
|
|
153
|
-
"""List
|
|
328
|
+
"""List tenants visible to the caller.
|
|
329
|
+
|
|
330
|
+
A global admin sees every tenant. A tenant-scoped caller sees only their
|
|
331
|
+
own tenant (and an empty list if the token carries no `tenant:<id>`
|
|
332
|
+
scope). When auth is disabled, all tenants are returned (single-user
|
|
333
|
+
local mode).
|
|
334
|
+
"""
|
|
154
335
|
items = await tenants_mod.list_tenants(db)
|
|
155
|
-
|
|
336
|
+
if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
|
|
337
|
+
return [tenants_mod._tenant_to_response(t) for t in items]
|
|
338
|
+
return [
|
|
339
|
+
tenants_mod._tenant_to_response(t)
|
|
340
|
+
for t in items
|
|
341
|
+
if t.id == tenant_ctx.tenant_id
|
|
342
|
+
]
|
|
156
343
|
|
|
157
344
|
|
|
158
345
|
@router.get("/tenants/{tenant_id}", dependencies=[Depends(auth.require_scope("read"))])
|
|
159
346
|
async def get_tenant(
|
|
160
347
|
tenant_id: int,
|
|
161
348
|
db: AsyncSession = Depends(get_db),
|
|
349
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
162
350
|
):
|
|
163
|
-
"""Get a tenant by ID."""
|
|
351
|
+
"""Get a tenant by ID, scoped to the caller's tenant boundary."""
|
|
352
|
+
tenant_ctx.enforce(tenant_id)
|
|
164
353
|
tenant = await tenants_mod.get_tenant(db, tenant_id)
|
|
165
354
|
if tenant is None:
|
|
166
355
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
@@ -175,8 +364,10 @@ async def update_tenant(
|
|
|
175
364
|
db: AsyncSession = Depends(get_db),
|
|
176
365
|
_auth: None = Depends(auth.require_scope("control")),
|
|
177
366
|
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
367
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
178
368
|
):
|
|
179
369
|
"""Update an existing tenant."""
|
|
370
|
+
tenant_ctx.enforce(tenant_id)
|
|
180
371
|
tenant = await tenants_mod.update_tenant(
|
|
181
372
|
db, tenant_id,
|
|
182
373
|
name=body.name, description=body.description, settings=body.settings,
|
|
@@ -199,8 +390,10 @@ async def delete_tenant(
|
|
|
199
390
|
db: AsyncSession = Depends(get_db),
|
|
200
391
|
_auth: None = Depends(auth.require_scope("control")),
|
|
201
392
|
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
393
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
202
394
|
):
|
|
203
395
|
"""Delete a tenant."""
|
|
396
|
+
tenant_ctx.enforce(tenant_id)
|
|
204
397
|
deleted = await tenants_mod.delete_tenant(db, tenant_id)
|
|
205
398
|
if not deleted:
|
|
206
399
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
@@ -216,8 +409,10 @@ async def delete_tenant(
|
|
|
216
409
|
async def get_tenant_projects(
|
|
217
410
|
tenant_id: int,
|
|
218
411
|
db: AsyncSession = Depends(get_db),
|
|
412
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
219
413
|
):
|
|
220
|
-
"""List all projects for a tenant."""
|
|
414
|
+
"""List all projects for a tenant, scoped to the caller's tenant boundary."""
|
|
415
|
+
tenant_ctx.enforce(tenant_id)
|
|
221
416
|
tenant = await tenants_mod.get_tenant(db, tenant_id)
|
|
222
417
|
if tenant is None:
|
|
223
418
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
@@ -248,8 +443,10 @@ async def create_run(
|
|
|
248
443
|
db: AsyncSession = Depends(get_db),
|
|
249
444
|
_auth: None = Depends(auth.require_scope("control")),
|
|
250
445
|
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
446
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
251
447
|
):
|
|
252
|
-
"""Create a new run."""
|
|
448
|
+
"""Create a new run, scoped to the caller's tenant boundary."""
|
|
449
|
+
await _enforce_project_tenant(db, tenant_ctx, body.project_id)
|
|
253
450
|
run_resp = await runs_mod.create_run(
|
|
254
451
|
db, project_id=body.project_id, trigger=body.trigger, config=body.config,
|
|
255
452
|
)
|
|
@@ -268,19 +465,62 @@ async def list_runs(
|
|
|
268
465
|
limit: int = Query(50, ge=1, le=1000),
|
|
269
466
|
offset: int = Query(0, ge=0),
|
|
270
467
|
db: AsyncSession = Depends(get_db),
|
|
468
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
271
469
|
):
|
|
272
|
-
"""List runs with optional filters.
|
|
273
|
-
|
|
274
|
-
|
|
470
|
+
"""List runs with optional filters, scoped to the caller's tenant.
|
|
471
|
+
|
|
472
|
+
A global admin / auth-disabled caller sees all runs. A tenant-scoped
|
|
473
|
+
caller only ever sees runs whose project belongs to their tenant: an
|
|
474
|
+
explicit project_id is enforced against the boundary, and an unscoped
|
|
475
|
+
listing is narrowed to the caller's own projects.
|
|
476
|
+
"""
|
|
477
|
+
if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
|
|
478
|
+
return await runs_mod.list_runs(
|
|
479
|
+
db, project_id=project_id, status=status, limit=limit, offset=offset,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
if project_id is not None:
|
|
483
|
+
# Targeting a specific project: enforce it belongs to the caller.
|
|
484
|
+
await _enforce_project_tenant(db, tenant_ctx, project_id)
|
|
485
|
+
return await runs_mod.list_runs(
|
|
486
|
+
db, project_id=project_id, status=status, limit=limit, offset=offset,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# No project filter: restrict to the caller's own projects. A caller with
|
|
490
|
+
# no tenant binding sees nothing (fail-closed).
|
|
491
|
+
if tenant_ctx.tenant_id is None:
|
|
492
|
+
return []
|
|
493
|
+
proj_result = await db.execute(
|
|
494
|
+
select(Project.id).where(Project.tenant_id == tenant_ctx.tenant_id)
|
|
275
495
|
)
|
|
496
|
+
owned_project_ids = [row[0] for row in proj_result]
|
|
497
|
+
if not owned_project_ids:
|
|
498
|
+
return []
|
|
499
|
+
# Collect this tenant's runs across all its projects, then apply the
|
|
500
|
+
# caller's limit/offset ONCE over the globally-sorted result so pagination
|
|
501
|
+
# is correct (not per-project). We over-fetch up to limit+offset per
|
|
502
|
+
# project to guarantee the merged top window is complete, then sort by
|
|
503
|
+
# created_at desc and slice. Isolation is unconditional regardless.
|
|
504
|
+
fetch_cap = limit + offset
|
|
505
|
+
collected: list = []
|
|
506
|
+
for pid in owned_project_ids:
|
|
507
|
+
collected.extend(
|
|
508
|
+
await runs_mod.list_runs(
|
|
509
|
+
db, project_id=pid, status=status, limit=fetch_cap, offset=0,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
collected.sort(key=lambda r: r.created_at, reverse=True)
|
|
513
|
+
return collected[offset:offset + limit]
|
|
276
514
|
|
|
277
515
|
|
|
278
516
|
@router.get("/runs/{run_id}", dependencies=[Depends(auth.require_scope("read"))])
|
|
279
517
|
async def get_run(
|
|
280
518
|
run_id: int,
|
|
281
519
|
db: AsyncSession = Depends(get_db),
|
|
520
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
282
521
|
):
|
|
283
|
-
"""Get run details by ID."""
|
|
522
|
+
"""Get run details by ID, scoped to the caller's tenant boundary."""
|
|
523
|
+
await _enforce_run_tenant(db, tenant_ctx, run_id)
|
|
284
524
|
run_resp = await runs_mod.get_run(db, run_id)
|
|
285
525
|
if run_resp is None:
|
|
286
526
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
@@ -294,8 +534,10 @@ async def cancel_run(
|
|
|
294
534
|
db: AsyncSession = Depends(get_db),
|
|
295
535
|
_auth: None = Depends(auth.require_scope("control")),
|
|
296
536
|
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
537
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
297
538
|
):
|
|
298
|
-
"""Cancel a running run."""
|
|
539
|
+
"""Cancel a running run, scoped to the caller's tenant boundary."""
|
|
540
|
+
await _enforce_run_tenant(db, tenant_ctx, run_id)
|
|
299
541
|
run_resp = await runs_mod.cancel_run(db, run_id)
|
|
300
542
|
if run_resp is None:
|
|
301
543
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
@@ -313,8 +555,10 @@ async def replay_run(
|
|
|
313
555
|
db: AsyncSession = Depends(get_db),
|
|
314
556
|
_auth: None = Depends(auth.require_scope("control")),
|
|
315
557
|
token_info: Optional[dict] = Depends(auth.get_current_token),
|
|
558
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
316
559
|
):
|
|
317
|
-
"""Replay a run."""
|
|
560
|
+
"""Replay a run, scoped to the caller's tenant boundary."""
|
|
561
|
+
await _enforce_run_tenant(db, tenant_ctx, run_id)
|
|
318
562
|
run_resp = await runs_mod.replay_run(db, run_id)
|
|
319
563
|
if run_resp is None:
|
|
320
564
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
@@ -329,8 +573,10 @@ async def replay_run(
|
|
|
329
573
|
async def get_run_timeline(
|
|
330
574
|
run_id: int,
|
|
331
575
|
db: AsyncSession = Depends(get_db),
|
|
576
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
332
577
|
):
|
|
333
|
-
"""Get the timeline of events for a run."""
|
|
578
|
+
"""Get the timeline of events for a run, scoped to the caller's tenant."""
|
|
579
|
+
await _enforce_run_tenant(db, tenant_ctx, run_id)
|
|
334
580
|
timeline = await runs_mod.get_run_timeline(db, run_id)
|
|
335
581
|
if timeline is None:
|
|
336
582
|
return {"run_id": run_id, "phases": [], "current_phase": None, "events": []}
|
package/dashboard/server.py
CHANGED
|
@@ -55,6 +55,11 @@ from . import app_secrets as secrets_mod
|
|
|
55
55
|
from . import telemetry as _telemetry
|
|
56
56
|
from .control import atomic_write_json, find_skill_dir, is_process_running
|
|
57
57
|
from .activity_logger import get_activity_logger
|
|
58
|
+
from .api_v2 import (
|
|
59
|
+
TenantContext,
|
|
60
|
+
_enforce_project_tenant,
|
|
61
|
+
resolve_tenant_context,
|
|
62
|
+
)
|
|
58
63
|
|
|
59
64
|
try:
|
|
60
65
|
from . import __version__ as _version
|
|
@@ -267,6 +272,7 @@ class ProjectResponse(BaseModel):
|
|
|
267
272
|
description: Optional[str]
|
|
268
273
|
prd_path: Optional[str]
|
|
269
274
|
status: str
|
|
275
|
+
tenant_id: int
|
|
270
276
|
created_at: datetime
|
|
271
277
|
updated_at: datetime
|
|
272
278
|
task_count: int = 0
|
|
@@ -1255,14 +1261,25 @@ async def list_projects(
|
|
|
1255
1261
|
limit: int = Query(default=50, ge=1, le=500),
|
|
1256
1262
|
offset: int = Query(default=0, ge=0),
|
|
1257
1263
|
db: AsyncSession = Depends(get_db),
|
|
1264
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1258
1265
|
) -> list[ProjectResponse]:
|
|
1259
|
-
"""List projects with pagination. Does not eager-load tasks for efficiency.
|
|
1266
|
+
"""List projects with pagination. Does not eager-load tasks for efficiency.
|
|
1267
|
+
|
|
1268
|
+
Tenant isolation (P3-7): a non-admin authenticated caller only sees
|
|
1269
|
+
projects belonging to their declared tenant (X-Loki-Tenant-ID). A global
|
|
1270
|
+
admin and single-user local mode (auth disabled) see all projects.
|
|
1271
|
+
"""
|
|
1260
1272
|
try:
|
|
1261
1273
|
from sqlalchemy import func as sa_func
|
|
1262
1274
|
|
|
1263
1275
|
query = select(Project)
|
|
1264
1276
|
if status:
|
|
1265
1277
|
query = query.where(Project.status == status)
|
|
1278
|
+
if not tenant_ctx.is_global_admin and tenant_ctx.auth_enabled:
|
|
1279
|
+
# Pin to the caller's tenant. A None tenant_id yields no matches,
|
|
1280
|
+
# which is the correct fail-closed behaviour for a scoped caller
|
|
1281
|
+
# that did not declare a tenant.
|
|
1282
|
+
query = query.where(Project.tenant_id == tenant_ctx.tenant_id)
|
|
1266
1283
|
query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
|
|
1267
1284
|
|
|
1268
1285
|
result = await db.execute(query)
|
|
@@ -1295,6 +1312,7 @@ async def list_projects(
|
|
|
1295
1312
|
description=project.description,
|
|
1296
1313
|
prd_path=project.prd_path,
|
|
1297
1314
|
status=project.status,
|
|
1315
|
+
tenant_id=project.tenant_id,
|
|
1298
1316
|
created_at=project.created_at,
|
|
1299
1317
|
updated_at=project.updated_at,
|
|
1300
1318
|
task_count=total,
|
|
@@ -1311,8 +1329,14 @@ async def list_projects(
|
|
|
1311
1329
|
async def create_project(
|
|
1312
1330
|
project: ProjectCreate,
|
|
1313
1331
|
db: AsyncSession = Depends(get_db),
|
|
1332
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1314
1333
|
) -> ProjectResponse:
|
|
1315
|
-
"""Create a new project.
|
|
1334
|
+
"""Create a new project.
|
|
1335
|
+
|
|
1336
|
+
Tenant isolation (P3-7): a non-admin caller may only create projects in
|
|
1337
|
+
their own declared tenant; targeting another tenant returns 403.
|
|
1338
|
+
"""
|
|
1339
|
+
tenant_ctx.enforce(project.tenant_id)
|
|
1316
1340
|
# Validate tenant exists
|
|
1317
1341
|
tenant_result = await db.execute(
|
|
1318
1342
|
select(Tenant).where(Tenant.id == project.tenant_id)
|
|
@@ -1342,6 +1366,7 @@ async def create_project(
|
|
|
1342
1366
|
description=db_project.description,
|
|
1343
1367
|
prd_path=db_project.prd_path,
|
|
1344
1368
|
status=db_project.status,
|
|
1369
|
+
tenant_id=db_project.tenant_id,
|
|
1345
1370
|
created_at=db_project.created_at,
|
|
1346
1371
|
updated_at=db_project.updated_at,
|
|
1347
1372
|
task_count=0,
|
|
@@ -1353,8 +1378,9 @@ async def create_project(
|
|
|
1353
1378
|
async def get_project(
|
|
1354
1379
|
project_id: int,
|
|
1355
1380
|
db: AsyncSession = Depends(get_db),
|
|
1381
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1356
1382
|
) -> ProjectResponse:
|
|
1357
|
-
"""Get a project by ID."""
|
|
1383
|
+
"""Get a project by ID, scoped to the caller's tenant boundary."""
|
|
1358
1384
|
result = await db.execute(
|
|
1359
1385
|
select(Project)
|
|
1360
1386
|
.options(selectinload(Project.tasks))
|
|
@@ -1365,6 +1391,8 @@ async def get_project(
|
|
|
1365
1391
|
if not project:
|
|
1366
1392
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
1367
1393
|
|
|
1394
|
+
tenant_ctx.enforce(project.tenant_id)
|
|
1395
|
+
|
|
1368
1396
|
task_count = len(project.tasks)
|
|
1369
1397
|
completed_count = len([t for t in project.tasks if t.status == TaskStatus.DONE])
|
|
1370
1398
|
|
|
@@ -1374,6 +1402,7 @@ async def get_project(
|
|
|
1374
1402
|
description=project.description,
|
|
1375
1403
|
prd_path=project.prd_path,
|
|
1376
1404
|
status=project.status,
|
|
1405
|
+
tenant_id=project.tenant_id,
|
|
1377
1406
|
created_at=project.created_at,
|
|
1378
1407
|
updated_at=project.updated_at,
|
|
1379
1408
|
task_count=task_count,
|
|
@@ -1386,8 +1415,9 @@ async def update_project(
|
|
|
1386
1415
|
project_id: int,
|
|
1387
1416
|
project_update: ProjectUpdate,
|
|
1388
1417
|
db: AsyncSession = Depends(get_db),
|
|
1418
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1389
1419
|
) -> ProjectResponse:
|
|
1390
|
-
"""Update a project."""
|
|
1420
|
+
"""Update a project, scoped to the caller's tenant boundary."""
|
|
1391
1421
|
result = await db.execute(
|
|
1392
1422
|
select(Project)
|
|
1393
1423
|
.options(selectinload(Project.tasks))
|
|
@@ -1398,6 +1428,8 @@ async def update_project(
|
|
|
1398
1428
|
if not project:
|
|
1399
1429
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
1400
1430
|
|
|
1431
|
+
tenant_ctx.enforce(project.tenant_id)
|
|
1432
|
+
|
|
1401
1433
|
update_data = project_update.model_dump(exclude_unset=True)
|
|
1402
1434
|
for field, value in update_data.items():
|
|
1403
1435
|
setattr(project, field, value)
|
|
@@ -1420,6 +1452,7 @@ async def update_project(
|
|
|
1420
1452
|
description=project.description,
|
|
1421
1453
|
prd_path=project.prd_path,
|
|
1422
1454
|
status=project.status,
|
|
1455
|
+
tenant_id=project.tenant_id,
|
|
1423
1456
|
created_at=project.created_at,
|
|
1424
1457
|
updated_at=project.updated_at,
|
|
1425
1458
|
task_count=task_count,
|
|
@@ -1432,8 +1465,9 @@ async def delete_project(
|
|
|
1432
1465
|
project_id: int,
|
|
1433
1466
|
request: Request,
|
|
1434
1467
|
db: AsyncSession = Depends(get_db),
|
|
1468
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1435
1469
|
) -> None:
|
|
1436
|
-
"""Delete a project."""
|
|
1470
|
+
"""Delete a project, scoped to the caller's tenant boundary."""
|
|
1437
1471
|
result = await db.execute(
|
|
1438
1472
|
select(Project).where(Project.id == project_id)
|
|
1439
1473
|
)
|
|
@@ -1442,6 +1476,8 @@ async def delete_project(
|
|
|
1442
1476
|
if not project:
|
|
1443
1477
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
1444
1478
|
|
|
1479
|
+
tenant_ctx.enforce(project.tenant_id)
|
|
1480
|
+
|
|
1445
1481
|
audit.log_event(
|
|
1446
1482
|
action="delete",
|
|
1447
1483
|
resource_type="project",
|
|
@@ -1702,8 +1738,11 @@ async def list_tasks(
|
|
|
1702
1738
|
async def create_task(
|
|
1703
1739
|
task: TaskCreate,
|
|
1704
1740
|
db: AsyncSession = Depends(get_db),
|
|
1741
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1705
1742
|
) -> TaskResponse:
|
|
1706
|
-
"""Create a new task."""
|
|
1743
|
+
"""Create a new task, scoped to the caller's tenant boundary."""
|
|
1744
|
+
# Enforce the tenant boundary on the target project (also 404s if missing).
|
|
1745
|
+
await _enforce_project_tenant(db, tenant_ctx, task.project_id)
|
|
1707
1746
|
# Verify project exists
|
|
1708
1747
|
result = await db.execute(
|
|
1709
1748
|
select(Project).where(Project.id == task.project_id)
|
|
@@ -1777,8 +1816,9 @@ async def create_task(
|
|
|
1777
1816
|
async def get_task(
|
|
1778
1817
|
task_id: int,
|
|
1779
1818
|
db: AsyncSession = Depends(get_db),
|
|
1819
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1780
1820
|
) -> TaskResponse:
|
|
1781
|
-
"""Get a task by ID."""
|
|
1821
|
+
"""Get a task by ID, scoped to the caller's tenant boundary."""
|
|
1782
1822
|
result = await db.execute(
|
|
1783
1823
|
select(Task).where(Task.id == task_id)
|
|
1784
1824
|
)
|
|
@@ -1787,6 +1827,8 @@ async def get_task(
|
|
|
1787
1827
|
if not task:
|
|
1788
1828
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
1789
1829
|
|
|
1830
|
+
await _enforce_project_tenant(db, tenant_ctx, task.project_id)
|
|
1831
|
+
|
|
1790
1832
|
return _task_response_from_db(task)
|
|
1791
1833
|
|
|
1792
1834
|
|
|
@@ -1795,8 +1837,9 @@ async def update_task(
|
|
|
1795
1837
|
task_id: int,
|
|
1796
1838
|
task_update: TaskUpdate,
|
|
1797
1839
|
db: AsyncSession = Depends(get_db),
|
|
1840
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1798
1841
|
) -> TaskResponse:
|
|
1799
|
-
"""Update a task."""
|
|
1842
|
+
"""Update a task, scoped to the caller's tenant boundary."""
|
|
1800
1843
|
result = await db.execute(
|
|
1801
1844
|
select(Task).where(Task.id == task_id)
|
|
1802
1845
|
)
|
|
@@ -1805,6 +1848,8 @@ async def update_task(
|
|
|
1805
1848
|
if not task:
|
|
1806
1849
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
1807
1850
|
|
|
1851
|
+
await _enforce_project_tenant(db, tenant_ctx, task.project_id)
|
|
1852
|
+
|
|
1808
1853
|
update_data = task_update.model_dump(exclude_unset=True)
|
|
1809
1854
|
|
|
1810
1855
|
# Handle status change to/from completed
|
|
@@ -1844,8 +1889,9 @@ async def delete_task(
|
|
|
1844
1889
|
task_id: int,
|
|
1845
1890
|
request: Request,
|
|
1846
1891
|
db: AsyncSession = Depends(get_db),
|
|
1892
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1847
1893
|
) -> None:
|
|
1848
|
-
"""Delete a task."""
|
|
1894
|
+
"""Delete a task, scoped to the caller's tenant boundary."""
|
|
1849
1895
|
result = await db.execute(
|
|
1850
1896
|
select(Task).where(Task.id == task_id)
|
|
1851
1897
|
)
|
|
@@ -1854,6 +1900,8 @@ async def delete_task(
|
|
|
1854
1900
|
if not task:
|
|
1855
1901
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
1856
1902
|
|
|
1903
|
+
await _enforce_project_tenant(db, tenant_ctx, task.project_id)
|
|
1904
|
+
|
|
1857
1905
|
project_id = task.project_id
|
|
1858
1906
|
|
|
1859
1907
|
audit.log_event(
|
|
@@ -1888,8 +1936,12 @@ async def move_task(
|
|
|
1888
1936
|
task_id: int,
|
|
1889
1937
|
move: TaskMove,
|
|
1890
1938
|
db: AsyncSession = Depends(get_db),
|
|
1939
|
+
tenant_ctx: TenantContext = Depends(resolve_tenant_context),
|
|
1891
1940
|
) -> TaskResponse:
|
|
1892
|
-
"""Move a task to a new status/position (for Kanban drag-and-drop).
|
|
1941
|
+
"""Move a task to a new status/position (for Kanban drag-and-drop).
|
|
1942
|
+
|
|
1943
|
+
Scoped to the caller's tenant boundary.
|
|
1944
|
+
"""
|
|
1893
1945
|
result = await db.execute(
|
|
1894
1946
|
select(Task).where(Task.id == task_id)
|
|
1895
1947
|
)
|
|
@@ -1898,6 +1950,8 @@ async def move_task(
|
|
|
1898
1950
|
if not task:
|
|
1899
1951
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
1900
1952
|
|
|
1953
|
+
await _enforce_project_tenant(db, tenant_ctx, task.project_id)
|
|
1954
|
+
|
|
1901
1955
|
old_status = task.status
|
|
1902
1956
|
|
|
1903
1957
|
# Validate status transition
|
package/dashboard/telemetry.py
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"""Anonymous usage telemetry for Loki Mode dashboard.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Collection is OPT-IN and OFF by default. Nothing is sent unless the user
|
|
4
|
+
explicitly opts in, so a default install (including air-gapped, GDPR, and
|
|
5
|
+
FedRAMP deployments) never phones home.
|
|
6
|
+
|
|
7
|
+
Opt-in (one required): LOKI_TELEMETRY=on OR ~/.loki/config: TELEMETRY_ENABLED=true
|
|
8
|
+
Opt-out (always wins): LOKI_TELEMETRY=off / LOKI_TELEMETRY_DISABLED=true /
|
|
9
|
+
DO_NOT_TRACK=1 / ~/.loki/config: TELEMETRY_DISABLED=true
|
|
10
|
+
|
|
4
11
|
All calls are fire-and-forget, silent on failure, non-blocking.
|
|
5
12
|
"""
|
|
6
13
|
|
|
@@ -19,10 +26,20 @@ _POSTHOG_KEY = "phc_ya0vGBru41AJWtGNfZZ8H9W4yjoZy4KON0nnayS7s87"
|
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def _is_enabled():
|
|
22
|
-
# Unified
|
|
23
|
-
#
|
|
24
|
-
# crash
|
|
25
|
-
|
|
29
|
+
# Unified OPT-IN gate. Collection is OFF by default; enabled ONLY when the
|
|
30
|
+
# user has opted in AND has not also opted out. This precedence MUST mirror
|
|
31
|
+
# loki_collection_enabled in autonomy/crash.sh and _loki_telemetry_enabled
|
|
32
|
+
# in autonomy/telemetry.sh so one model gates BOTH PostHog usage telemetry
|
|
33
|
+
# and crash reporting.
|
|
34
|
+
#
|
|
35
|
+
# Precedence:
|
|
36
|
+
# 1. Any opt-out flag present -> False (hard kill, always wins)
|
|
37
|
+
# 2. Else any opt-in flag present -> True
|
|
38
|
+
# 3. Else (default) -> False (no egress)
|
|
39
|
+
telem = os.environ.get("LOKI_TELEMETRY", "").lower()
|
|
40
|
+
|
|
41
|
+
# --- 1. Opt-out always wins ---
|
|
42
|
+
if telem == "off":
|
|
26
43
|
return False
|
|
27
44
|
if os.environ.get("LOKI_TELEMETRY_DISABLED") == "true":
|
|
28
45
|
return False
|
|
@@ -30,15 +47,26 @@ def _is_enabled():
|
|
|
30
47
|
return False
|
|
31
48
|
# Persistent opt-out in ~/.loki/config (matches the bash grep prefix
|
|
32
49
|
# semantics: any line beginning with TELEMETRY_DISABLED=true).
|
|
50
|
+
config_enabled = False
|
|
33
51
|
try:
|
|
34
52
|
config_path = Path.home() / ".loki" / "config"
|
|
35
53
|
if config_path.is_file():
|
|
36
54
|
for line in config_path.read_text().splitlines():
|
|
37
55
|
if line.startswith("TELEMETRY_DISABLED=true"):
|
|
38
56
|
return False
|
|
57
|
+
if line.startswith("TELEMETRY_ENABLED=true"):
|
|
58
|
+
config_enabled = True
|
|
39
59
|
except Exception:
|
|
40
60
|
pass
|
|
41
|
-
|
|
61
|
+
|
|
62
|
+
# --- 2. Opt-in required to enable ---
|
|
63
|
+
if telem == "on":
|
|
64
|
+
return True
|
|
65
|
+
if config_enabled:
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
# --- 3. Default: OFF ---
|
|
69
|
+
return False
|
|
42
70
|
|
|
43
71
|
|
|
44
72
|
def _get_distinct_id():
|