mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
4b29f6d293
commit
74bbfda212
10 changed files with 950 additions and 10 deletions
77
services/mana-ai/src/db/audit-read.ts
Normal file
77
services/mana-ai/src/db/audit-read.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue