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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user