feat(internal-admin): readonly impersonation for Süper Panel
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:
2026-05-18 00:48:39 +03:00
parent d1c78f7b89
commit 2583b781ec
9 changed files with 5695 additions and 1 deletions

View 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;
}
}