feat(ai): Mission Grant consent UI + Workbench audit tab

Phase 3 — user-facing side of the Mission Key-Grant rollout. Users
can now opt into server-side execution, revoke it, and inspect every
decrypt the runner has performed.

Webapp:
- MissionGrantDialog explains the scope (record count, tables, TTL,
  audit visibility, revocation) and calls requestMissionGrant. Error
  paths render distinctly for ZK, not-configured, missing vault.
- Mission detail shows a Server-Zugriff box with status pill
  (aktiv/abgelaufen/nicht erteilt), Neu-erteilen + Zurückziehen
  buttons. Only renders for missions with at least one encrypted-
  table input.
- store.ts: setMissionGrant / revokeMissionGrant helpers, Proxy-
  stripped like the rest of the store's writes.
- Workbench adds a Timeline/Datenzugriff tab switch. Audit tab queries
  the new GET /api/v1/me/ai-audit endpoint, renders decrypt events
  with color-coded status pills (ok/failed/scope-violation) and
  stable reason strings.
- getManaAiUrl() added to api/config for the audit fetch.

mana-ai:
- GET /api/v1/me/ai-audit (JWT-gated via shared-hono authMiddleware)
  backed by readDecryptAudit() — withUser + RLS double-gate so a user
  can only read their own rows.
- Limit capped at 1000, newest-first.

Missions without a grant continue to work exactly as before; the
grant UI is purely additive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:53:11 +02:00
parent 4b29f6d293
commit 74bbfda212
10 changed files with 950 additions and 10 deletions

View file

@ -0,0 +1,77 @@
/**
* Read side of `mana_ai.decrypt_audit`. Returns rows for one user +
* (optionally) one mission, newest first.
*
* RLS-scoped via `withUser` even if the caller forgets the WHERE
* clause, the policy wouldn't expose another user's rows. The explicit
* `user_id = $1` clause is belt + braces.
*/
import type { Sql } from './connection';
import { withUser } from './connection';
export interface DecryptAuditRow {
id: string;
missionId: string;
iterationId: string | null;
tableName: string;
recordId: string;
status: 'ok' | 'failed' | 'scope-violation';
reason: string | null;
ts: string;
}
export interface ReadDecryptAuditParams {
missionId?: string;
/** Caller-controlled upper bound. Hard-capped at 1000 so a runaway
* client can't OOM us. */
limit?: number;
}
export async function readDecryptAudit(
sql: Sql,
userId: string,
params: ReadDecryptAuditParams = {}
): Promise<DecryptAuditRow[]> {
const limit = Math.max(1, Math.min(1000, params.limit ?? 200));
return withUser(sql, userId, async (tx) => {
const rows = params.missionId
? await tx<AuditRowRaw[]>`
SELECT id, mission_id, iteration_id, table_name, record_id, status, reason, ts
FROM mana_ai.decrypt_audit
WHERE user_id = ${userId} AND mission_id = ${params.missionId}
ORDER BY ts DESC
LIMIT ${limit}
`
: await tx<AuditRowRaw[]>`
SELECT id, mission_id, iteration_id, table_name, record_id, status, reason, ts
FROM mana_ai.decrypt_audit
WHERE user_id = ${userId}
ORDER BY ts DESC
LIMIT ${limit}
`;
return rows.map((r) => ({
id: String(r.id),
missionId: r.mission_id,
iterationId: r.iteration_id,
tableName: r.table_name,
recordId: r.record_id,
status: r.status as DecryptAuditRow['status'],
reason: r.reason,
ts: r.ts instanceof Date ? r.ts.toISOString() : String(r.ts),
}));
});
}
interface AuditRowRaw {
id: number | string;
mission_id: string;
iteration_id: string | null;
table_name: string;
record_id: string;
status: string;
reason: string | null;
ts: Date | string;
}

View file

@ -11,6 +11,7 @@
*/
import { Hono } from 'hono';
import { authMiddleware } from '@mana/shared-hono';
import { loadConfig } from './config';
import { closeSql, getSql } from './db/connection';
import { migrate } from './db/migrate';
@ -18,6 +19,7 @@ import { runTickOnce, startTick, stopTick, isTickRunning } from './cron/tick';
import { serviceAuth } from './middleware/service-auth';
import { register, httpRequestsTotal, httpRequestDuration } from './metrics';
import { configureMissionGrantKey } from './crypto/unwrap-grant';
import { readDecryptAudit } from './db/audit-read';
const config = loadConfig();
@ -67,6 +69,24 @@ app.post('/internal/tick', async (c) => {
return c.json(stats);
});
// ─── User-facing audit read ─────────────────────────────────
// JWT-gated. Powers the "Mission → Datenzugriff" tab in the webapp
// Workbench. RLS + withUser ensure the caller only ever sees their own
// rows even if they try to swap in another userId client-side.
app.use('/api/v1/me/ai-audit', authMiddleware());
app.get('/api/v1/me/ai-audit', async (c) => {
const userId = (c.get as (key: string) => string)('userId');
const missionId = c.req.query('missionId') ?? undefined;
const limitRaw = c.req.query('limit');
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined;
const rows = await readDecryptAudit(getSql(config.syncDatabaseUrl), userId, {
missionId,
limit: Number.isFinite(limit) ? limit : undefined,
});
return c.json({ rows });
});
const stopScheduledTick = startTick(config);
const server = Bun.serve({