loki-mode 7.48.0 → 7.50.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.
@@ -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 all tenants."""
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
- return [tenants_mod._tenant_to_response(t) for t in items]
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
- return await runs_mod.list_runs(
274
- db, project_id=project_id, status=status, limit=limit, offset=offset,
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": []}
@@ -513,30 +513,25 @@ def _file_has_integrity(log_file: str) -> bool:
513
513
  return False
514
514
 
515
515
 
516
- def verify_all_logs() -> dict:
517
- """v7.7.15: verify the entire audit chain across all rotated log files.
516
+ def verify_all_logs_in_dir(audit_dir) -> dict:
517
+ """Verify the entire audit chain across all rotated log files in
518
+ an explicit directory.
518
519
 
519
- Walks `AUDIT_DIR/audit-*.jsonl` in chronological order, threading
520
- the chain hash from one file to the next via `start_hash`. Skips
521
- files from the pre-integrity era (files whose first entry has no
522
- `_integrity_hash` field, because integrity hashing was introduced
523
- after some audit logs already existed).
520
+ This is the directory-parameterized core of :func:`verify_all_logs`.
521
+ The default :func:`verify_all_logs` delegates here with the module
522
+ ``AUDIT_DIR`` so existing callers are unaffected. The explicit-dir
523
+ form lets the unified cross-chain verifier (see ``src/audit/crosslink.js``)
524
+ validate an arbitrary audit directory without mutating module globals.
525
+
526
+ Args:
527
+ audit_dir: Path (str or pathlib.Path) to a directory containing
528
+ ``audit-*.jsonl`` files.
524
529
 
525
530
  Returns:
526
- A dict with:
527
- - valid (bool): True if the entire cross-file chain is intact.
528
- - files_checked (int): Count of integrity-bearing files inspected.
529
- - files_skipped (int): Count of pre-integrity files skipped.
530
- - entries_checked (int): Total entries verified across all files.
531
- - first_tampered_file (str | None): Path to the first file
532
- whose chain broke, or None if valid.
533
- - first_tampered_line (int | None): 1-based line number in
534
- that file where the chain broke, or None if valid.
535
- - genesis_file (str | None): Path to the first integrity-bearing
536
- log file (the chain's genesis on this machine), or None if
537
- no integrity-bearing files exist.
531
+ Same shape as :func:`verify_all_logs`.
538
532
  """
539
- if not AUDIT_DIR.exists():
533
+ audit_dir = Path(audit_dir)
534
+ if not audit_dir.exists():
540
535
  return {"valid": True, "files_checked": 0, "files_skipped": 0,
541
536
  "entries_checked": 0, "first_tampered_file": None,
542
537
  "first_tampered_line": None, "genesis_file": None}
@@ -547,7 +542,7 @@ def verify_all_logs() -> dict:
547
542
  # would break chain ordering and false-negative on any user who hit
548
543
  # size-based rotation. Sort by mtime instead -- mirrors what
549
544
  # `_cleanup_old_logs` already does at line 178.
550
- files = sorted(AUDIT_DIR.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
545
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
551
546
  prev_hash = "0" * 64
552
547
  total_entries = 0
553
548
  files_checked = 0
@@ -582,3 +577,189 @@ def verify_all_logs() -> dict:
582
577
  "first_tampered_line": None,
583
578
  "genesis_file": genesis_file,
584
579
  }
580
+
581
+
582
+ def verify_all_logs() -> dict:
583
+ """v7.7.15: verify the entire audit chain across all rotated log files.
584
+
585
+ Walks `AUDIT_DIR/audit-*.jsonl` in chronological order, threading
586
+ the chain hash from one file to the next via `start_hash`. Skips
587
+ files from the pre-integrity era (files whose first entry has no
588
+ `_integrity_hash` field, because integrity hashing was introduced
589
+ after some audit logs already existed).
590
+
591
+ Returns:
592
+ A dict with:
593
+ - valid (bool): True if the entire cross-file chain is intact.
594
+ - files_checked (int): Count of integrity-bearing files inspected.
595
+ - files_skipped (int): Count of pre-integrity files skipped.
596
+ - entries_checked (int): Total entries verified across all files.
597
+ - first_tampered_file (str | None): Path to the first file
598
+ whose chain broke, or None if valid.
599
+ - first_tampered_line (int | None): 1-based line number in
600
+ that file where the chain broke, or None if valid.
601
+ - genesis_file (str | None): Path to the first integrity-bearing
602
+ log file (the chain's genesis on this machine), or None if
603
+ no integrity-bearing files exist.
604
+ """
605
+ return verify_all_logs_in_dir(AUDIT_DIR)
606
+
607
+
608
+ def compute_chain_tip_in_dir(audit_dir) -> dict:
609
+ """Return the current tip (last hash) of the Python audit chain in
610
+ an explicit directory, plus a verification verdict for that chain.
611
+
612
+ Used by the unified cross-chain verifier to anchor the Python chain
613
+ state into the JS (``src/audit/log.js``) tamper-evident chain via a
614
+ cross-link record, and to reconcile a previously recorded anchor
615
+ against the live chain.
616
+
617
+ Args:
618
+ audit_dir: Path (str or pathlib.Path) to the Python audit dir.
619
+
620
+ Returns:
621
+ A dict with:
622
+ - genesis (str): The genesis hash for this chain ("0"*64).
623
+ - tip_hash (str): The last integrity hash, or the genesis hash
624
+ if the chain is empty.
625
+ - entries (int): Total integrity-bearing entries in the chain.
626
+ - valid (bool): Whether the chain verifies end-to-end.
627
+ - chain_id (str): Stable identifier for this chain family.
628
+ """
629
+ result = verify_all_logs_in_dir(audit_dir)
630
+ audit_dir = Path(audit_dir)
631
+ genesis = "0" * 64
632
+ tip_hash = genesis
633
+ if audit_dir.exists():
634
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
635
+ prev = genesis
636
+ for log_file in files:
637
+ if not _file_has_integrity(str(log_file)):
638
+ continue
639
+ r = verify_log_integrity(str(log_file), start_hash=prev)
640
+ prev = r.get("last_hash", prev)
641
+ tip_hash = prev
642
+ return {
643
+ "genesis": genesis,
644
+ "tip_hash": tip_hash,
645
+ "entries": result.get("entries_checked", 0),
646
+ "valid": bool(result.get("valid", False)),
647
+ "chain_id": "loki-dashboard-audit",
648
+ }
649
+
650
+
651
+ def compute_prefix_hash_in_dir(audit_dir, n_entries: int) -> dict:
652
+ """Recompute the dashboard chain hash after exactly the first
653
+ ``n_entries`` integrity-bearing entries, walking files in mtime
654
+ order across rotations.
655
+
656
+ This is what lets the unified cross-chain verifier distinguish
657
+ legitimate append-only GROWTH from TAMPER. A cross-link anchor pins
658
+ ``(tip_hash, entries)`` at link time; later the live chain may have
659
+ grown. Reconciliation recomputes the hash of the first ``n_entries``
660
+ (the anchored prefix) and checks it still reproduces the anchored
661
+ ``tip_hash``. Growth keeps the prefix intact (reproducible); any
662
+ mutation at-or-before the anchor, or truncation below it, makes the
663
+ prefix unreproducible.
664
+
665
+ Args:
666
+ audit_dir: Path (str or pathlib.Path) to the Python audit dir.
667
+ n_entries: Number of leading integrity-bearing entries to hash.
668
+
669
+ Returns:
670
+ A dict with:
671
+ - found (bool): True if at least ``n_entries`` entries exist
672
+ and the prefix was hashed without a chain break inside it.
673
+ - prefix_hash (str): The chain hash after ``n_entries`` entries
674
+ (or the running hash reached if fewer entries exist / a break
675
+ occurred -- in which case ``found`` is False).
676
+ - entries_available (int): Total integrity-bearing entries seen.
677
+ """
678
+ audit_dir = Path(audit_dir)
679
+ genesis = "0" * 64
680
+ if n_entries <= 0:
681
+ return {"found": True, "prefix_hash": genesis, "entries_available": 0}
682
+ if not audit_dir.exists():
683
+ return {"found": False, "prefix_hash": genesis, "entries_available": 0}
684
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
685
+ prev_hash = genesis
686
+ seen = 0
687
+ started = False
688
+ for log_file in files:
689
+ if not started and not _file_has_integrity(str(log_file)):
690
+ continue
691
+ started = True
692
+ try:
693
+ with open(log_file, "r") as f:
694
+ for line in f:
695
+ line = line.strip()
696
+ if not line:
697
+ continue
698
+ try:
699
+ entry = json.loads(line)
700
+ except json.JSONDecodeError:
701
+ return {"found": False, "prefix_hash": prev_hash,
702
+ "entries_available": seen}
703
+ stored_hash = entry.pop("_integrity_hash", None)
704
+ if stored_hash is None:
705
+ return {"found": False, "prefix_hash": prev_hash,
706
+ "entries_available": seen}
707
+ entry_json = json.dumps(entry, sort_keys=True, default=str)
708
+ expected = _compute_chain_hash(entry_json, prev_hash)
709
+ if stored_hash != expected:
710
+ # Chain broke inside the prefix -> not reproducible.
711
+ return {"found": False, "prefix_hash": prev_hash,
712
+ "entries_available": seen}
713
+ prev_hash = stored_hash
714
+ seen += 1
715
+ if seen == n_entries:
716
+ return {"found": True, "prefix_hash": prev_hash,
717
+ "entries_available": seen}
718
+ except OSError:
719
+ return {"found": False, "prefix_hash": prev_hash,
720
+ "entries_available": seen}
721
+ # Fewer than n_entries entries exist (truncation below the anchor).
722
+ return {"found": False, "prefix_hash": prev_hash, "entries_available": seen}
723
+
724
+
725
+ def _unified_cli() -> int:
726
+ """Tiny CLI shim so the Node-side unified verifier (or an operator)
727
+ can fetch the Python chain tip / verdict for a given directory as
728
+ JSON. Invoked as:
729
+
730
+ python3 dashboard/audit.py tip <audit_dir>
731
+ python3 dashboard/audit.py verify <audit_dir>
732
+
733
+ Prints a single JSON object to stdout. Returns process exit code 0
734
+ on a valid chain, 1 on an invalid chain, 2 on usage error.
735
+ """
736
+ argv = sys.argv[1:]
737
+ if len(argv) < 2 or argv[0] not in ("tip", "verify", "prefix"):
738
+ print(json.dumps(
739
+ {"error": "usage: audit.py {tip|verify} <audit_dir> "
740
+ "| prefix <audit_dir> <n_entries>"}))
741
+ return 2
742
+ cmd, audit_dir = argv[0], argv[1]
743
+ if cmd == "tip":
744
+ out = compute_chain_tip_in_dir(audit_dir)
745
+ print(json.dumps(out))
746
+ return 0 if out.get("valid", False) else 1
747
+ if cmd == "prefix":
748
+ if len(argv) < 3:
749
+ print(json.dumps({"error": "prefix requires <n_entries>"}))
750
+ return 2
751
+ try:
752
+ n = int(argv[2])
753
+ except ValueError:
754
+ print(json.dumps({"error": "n_entries must be an integer"}))
755
+ return 2
756
+ out = compute_prefix_hash_in_dir(audit_dir, n)
757
+ print(json.dumps(out))
758
+ return 0 if out.get("found", False) else 1
759
+ out = verify_all_logs_in_dir(audit_dir)
760
+ print(json.dumps(out))
761
+ return 0 if out.get("valid", False) else 1
762
+
763
+
764
+ if __name__ == "__main__":
765
+ sys.exit(_unified_cli())