feat(sase): read-only impersonation from user detail page

Adım 3 — Süper Panel side of the readonly impersonation flow.

- admin-sdk/sase.ts: impersonateReadonly(userId, founderId, ttlMinutes,
  reason) — POSTs to /internal/admin/users/:id/impersonate-readonly.
  notWiredSdk stub returns same shape so type contract holds when env vars
  are absent.
- POST /api/sase/users/[id]/impersonate-readonly — auth-checked panel
  endpoint. Validates ttl ∈ {15,30,60} + reason ≥ 5 chars. Calls spoke SDK,
  writes audit (double-audit: AdminClient also logs at the wire), returns
  { redirectUrl, expiresAt, sessionIdPrefix }.
- _impersonate-button.tsx (client): shadcn Dialog. TTL pills (15/30/60) +
  reason textarea + submit. On success opens spoke redirectUrl in new
  window/_blank/noopener.
- Detail header gains [Impersonate (read-only)] button — gated on
  saseAdminWired() so it stays hidden until SASE_ADMIN_API_BASE +
  INTERNAL_API_TOKEN_SASE land in Coolify env.

Spoke side (sase.tr@79a2616 → … → next release): InternalAdminModule +
ImpersonationReadonlyGuard already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 00:51:38 +03:00
parent 0b31685e95
commit d8c2bdd22f
4 changed files with 244 additions and 4 deletions

View File

@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
const VALID_TTLS = new Set([15, 30, 60]);
export async function POST(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json(
{ ok: false, error: "spoke_not_wired" },
{ status: 503 },
);
}
const { id } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
ttlMinutes?: number;
reason?: string;
};
const ttlMinutes = Number(body.ttlMinutes);
if (!VALID_TTLS.has(ttlMinutes)) {
return NextResponse.json(
{ ok: false, error: "invalid_ttl" },
{ status: 400 },
);
}
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json(
{ ok: false, error: "reason_required" },
{ status: 400 },
);
}
const endpoint = `/api/sase/users/${id}/impersonate-readonly`;
try {
const sdk = createSaseAdmin();
const result = await sdk.impersonateReadonly({
userId: id,
founderId: session.user.id,
ttlMinutes,
reason,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { userId: id, ttlMinutes, reasonLen: reason.length },
responseStatus: 200,
});
return NextResponse.json({
ok: true,
redirectUrl: result.redirectUrl,
expiresAt: result.expiresAt,
sessionIdPrefix: result.sessionIdPrefix,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { userId: id, ttlMinutes, reasonLen: reason.length },
responseStatus: 500,
});
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}