feat(internal-admin): readonly impersonation for Süper Panel
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lets the founder open a target user's session in a new tab from the
panel for debugging. Read-only enforced server-side — any non-GET
request from an impersonated session returns 403.
Schema
- sessions.impersonated_by (uuid, nullable) — founder Better Auth user id
- sessions.impersonation_readonly (bool, default false)
- index on impersonated_by
Service
- ImpersonationService.createReadonlySession({ targetUserId, founderId,
ttlMinutes, reason, ipAddress, userAgent }):
- Random sessionId + token (32 bytes hex each)
- TTL clamped 1..60 min, default 15
- Refuses to impersonate admin users
- Inserts sessions row; signs cookie value with HMAC-SHA256(BETTER_AUTH_SECRET)
matching better-call's signCookieValue format
- Returns { cookieName, cookieValue, expiresAt, sessionId }
Guard
- ImpersonationReadonlyGuard runs after AuthGuard, before RolesGuard.
- GET/HEAD/OPTIONS pass through.
- For other methods: looks up sessions.impersonated_by + impersonation_readonly
by request.session.id; throws ForbiddenException if both truthy.
Endpoints (InternalAdminModule)
- POST /internal/admin/users/:id/impersonate-readonly [InternalTokenGuard]
body: { ttlMinutes, reason, founderId }
returns: { redirectUrl, expiresAt, sessionIdPrefix }
Hand-off is via signed consume URL (cross-origin Set-Cookie limitations).
- GET /admin/impersonate/consume?t=<signed> [@Public]
Verifies HMAC-signed payload (<=60s validity), sets the Better Auth session
cookie on sase.tr, redirects to /. One-shot.
Wiring
- InternalAdminModule imported in AppModule.
- ImpersonationReadonlyGuard registered as APP_GUARD between Auth and Roles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
164
apps/api/src/internal-admin/impersonation.controller.ts
Normal file
164
apps/api/src/internal-admin/impersonation.controller.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Request, Response } from "express";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||
import { ImpersonationService } from "./impersonation.service";
|
||||
|
||||
const CONSUME_TOKEN_TTL_SECONDS = 60;
|
||||
|
||||
type ConsumePayload = {
|
||||
cookieName: string;
|
||||
cookieValue: string;
|
||||
redirect: string;
|
||||
expiresAtMs: number;
|
||||
notBeforeMs: number;
|
||||
};
|
||||
|
||||
@Controller()
|
||||
export class ImpersonationController {
|
||||
private readonly logger = new Logger(ImpersonationController.name);
|
||||
|
||||
constructor(
|
||||
private impersonation: ImpersonationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
// Internal: panel → spoke. Returns a short-lived "consume URL" the panel
|
||||
// opens in a new tab. Hand-off via a signed payload avoids cross-site
|
||||
// Set-Cookie restrictions (panel domain ≠ sase.tr domain).
|
||||
@Post("internal/admin/users/:id/impersonate-readonly")
|
||||
@Public()
|
||||
@UseGuards(InternalTokenGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async impersonateReadonly(
|
||||
@Param("id") userId: string,
|
||||
@Body() body: { ttlMinutes?: number; reason?: string; founderId?: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
if (!body.founderId) {
|
||||
throw new UnauthorizedException("founderId required");
|
||||
}
|
||||
const reason = (body.reason ?? "").slice(0, 500);
|
||||
if (!reason) {
|
||||
throw new UnauthorizedException("reason required");
|
||||
}
|
||||
|
||||
const { cookieName, cookieValue, expiresAt, sessionId } =
|
||||
await this.impersonation.createReadonlySession({
|
||||
targetUserId: userId,
|
||||
founderId: body.founderId,
|
||||
ttlMinutes: body.ttlMinutes ?? 15,
|
||||
reason,
|
||||
ipAddress: this.getIp(req),
|
||||
userAgent: typeof req.headers["user-agent"] === "string"
|
||||
? req.headers["user-agent"]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
||||
const payload: ConsumePayload = {
|
||||
cookieName,
|
||||
cookieValue,
|
||||
redirect: "/",
|
||||
expiresAtMs: expiresAt.getTime(),
|
||||
notBeforeMs: Date.now() - 1000,
|
||||
};
|
||||
const consumeToken = this.signConsumePayload(payload);
|
||||
const redirectUrl = `${baseUrl}/api/admin/impersonate/consume?t=${encodeURIComponent(consumeToken)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redirectUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
sessionIdPrefix: sessionId.slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
// Public: founder's browser GET. Verifies the signed payload, sets the
|
||||
// Better Auth session cookie, redirects to the spoke. One-shot, time-boxed.
|
||||
@Get("admin/impersonate/consume")
|
||||
@Public()
|
||||
async consume(@Query("t") token: string, @Res() res: Response) {
|
||||
if (!token) {
|
||||
res.status(HttpStatus.BAD_REQUEST).send("missing token");
|
||||
return;
|
||||
}
|
||||
const payload = this.verifyConsumePayload(token);
|
||||
if (!payload) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).send("invalid or expired consume token");
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now < payload.notBeforeMs || now > payload.expiresAtMs) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).send("consume token outside validity window");
|
||||
return;
|
||||
}
|
||||
|
||||
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
||||
res.cookie(payload.cookieName, payload.cookieValue, {
|
||||
httpOnly: true,
|
||||
secure: isHttps,
|
||||
sameSite: "lax",
|
||||
// Path scope: site-wide
|
||||
path: "/",
|
||||
// maxAge in ms, browser will expire alongside server-side session row
|
||||
maxAge: payload.expiresAtMs - now,
|
||||
});
|
||||
res.redirect(payload.redirect || "/");
|
||||
}
|
||||
|
||||
// Sign a JSON payload with HMAC-SHA256(secret) and embed: base64(json).base64(sig)
|
||||
private signConsumePayload(payload: ConsumePayload): string {
|
||||
const secret = this.consumeSecret();
|
||||
const json = JSON.stringify(payload);
|
||||
const body = Buffer.from(json, "utf8").toString("base64url");
|
||||
const sig = createHmac("sha256", secret).update(body).digest("base64url");
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
private verifyConsumePayload(token: string): ConsumePayload | null {
|
||||
const [body, sig] = token.split(".");
|
||||
if (!body || !sig) return null;
|
||||
const secret = this.consumeSecret();
|
||||
const expected = createHmac("sha256", secret).update(body).digest("base64url");
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private consumeSecret(): string {
|
||||
const secret =
|
||||
process.env.INTERNAL_IMPERSONATION_SECRET ??
|
||||
this.configService.get<string>("auth.secret");
|
||||
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
||||
return secret;
|
||||
}
|
||||
|
||||
private getIp(req: Request): string | undefined {
|
||||
const xff = req.headers["x-forwarded-for"];
|
||||
if (typeof xff === "string") return xff.split(",")[0]?.trim();
|
||||
if (req.socket?.remoteAddress) return req.socket.remoteAddress;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user