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,3 @@
ALTER TABLE "sessions" ADD COLUMN "impersonated_by" uuid;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "impersonation_readonly" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX "sessions_impersonated_by_idx" ON "sessions" USING btree ("impersonated_by");

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1778709949622,
"tag": "0004_hot_quicksilver",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779054231051,
"tag": "0005_past_elektra",
"breakpoints": true
}
]
}

View File

@@ -14,6 +14,7 @@ import { CategoriesModule } from "./categories/categories.module";
import { ChangelogModule } from "./changelog/changelog.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
import { RolesGuard } from "./common/guards/roles.guard";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
@@ -23,6 +24,7 @@ import { validate } from "./config/env.validation";
import { DatabaseModule } from "./database/database.module";
import { EmailModule } from "./email/email.module";
import { HealthController } from "./health.controller";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { EmexModule } from "./integrations/emex/emex.module";
import { JobsModule } from "./jobs/jobs.module";
import { PartsModule } from "./parts/parts.module";
@@ -85,11 +87,13 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
ChangelogModule,
PostHogModule,
TelemetryModule,
InternalAdminModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: ImpersonationReadonlyGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },

View File

@@ -0,0 +1,54 @@
import {
type CanActivate,
type ExecutionContext,
ForbiddenException,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../../database/database.provider";
import { sessions } from "../../database/schema/core";
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Blocks mutating requests (non-GET/HEAD/OPTIONS) when the current Better Auth
* session was created via the Süper Panel impersonate-readonly flow.
*
* Runs after AuthGuard (which loads request.session); for public endpoints that
* have no session, this guard is a no-op.
*/
@Injectable()
export class ImpersonationReadonlyGuard implements CanActivate {
private readonly logger = new Logger(ImpersonationReadonlyGuard.name);
constructor(@Inject(DATABASE) private db: Database) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
if (READ_ONLY_METHODS.has(req.method)) return true;
const sessionId: string | undefined = req.session?.id;
if (!sessionId) return true;
const [row] = await this.db
.select({
impersonatedBy: sessions.impersonatedBy,
impersonationReadonly: sessions.impersonationReadonly,
})
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (!row) return true;
if (row.impersonatedBy && row.impersonationReadonly) {
this.logger.warn(
`Blocked ${req.method} ${req.url} — readonly impersonation session (founder=${row.impersonatedBy})`,
);
throw new ForbiddenException(
"Bu oturum salt-okunur impersonation modunda; mutasyon yapılamaz.",
);
}
return true;
}
}

View File

@@ -45,10 +45,18 @@ export const sessions = pgTable(
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
ipAddress: varchar("ip_address", { length: 45 }),
userAgent: text("user_agent"),
// Süper Panel impersonation: when set, this session was created by the
// founder via /internal/admin/users/:id/impersonate-readonly.
// ImpersonationReadonlyGuard enforces the readonly contract.
impersonatedBy: uuid("impersonated_by"),
impersonationReadonly: boolean("impersonation_readonly").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("sessions_token_idx").on(table.token)],
(table) => [
uniqueIndex("sessions_token_idx").on(table.token),
index("sessions_impersonated_by_idx").on(table.impersonatedBy),
],
);
// ─── Better Auth: Accounts ──────────────────────────

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

View File

@@ -0,0 +1,91 @@
import { createHmac, randomBytes } from "node:crypto";
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { sessions, users } from "../database/schema/core";
const SESSION_COOKIE_NAME_INSECURE = "better-auth.session_token";
const SESSION_COOKIE_NAME_SECURE = "__Secure-better-auth.session_token";
const DEFAULT_TTL_MINUTES = 15;
const MAX_TTL_MINUTES = 60;
@Injectable()
export class ImpersonationService {
private readonly logger = new Logger(ImpersonationService.name);
constructor(
@Inject(DATABASE) private db: Database,
private configService: ConfigService,
) {}
async createReadonlySession(params: {
targetUserId: string;
founderId: string;
ttlMinutes: number;
reason: string;
ipAddress?: string;
userAgent?: string;
}): Promise<{
cookieName: string;
cookieValue: string;
expiresAt: Date;
sessionId: string;
}> {
const ttl = Math.min(
Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1),
MAX_TTL_MINUTES,
);
// Verify target exists (and isn't an admin — defense in depth)
const [target] = await this.db
.select({ id: users.id, role: users.role })
.from(users)
.where(eq(users.id, params.targetUserId))
.limit(1);
if (!target) throw new NotFoundException("Kullanıcı bulunamadı");
if (target.role === "admin") {
throw new NotFoundException("Admin kullanıcılar impersonate edilemez");
}
const secret = this.configService.get<string>("auth.secret");
if (!secret) throw new Error("BETTER_AUTH_SECRET not configured");
// Better Auth session shape: id is a text random id, token is text random.
// Both must be unique. Use 32 random bytes hex for plenty of entropy.
const sessionId = randomBytes(32).toString("hex");
const token = randomBytes(32).toString("hex");
const now = new Date();
const expiresAt = new Date(now.getTime() + ttl * 60_000);
await this.db.insert(sessions).values({
id: sessionId,
userId: params.targetUserId,
token,
expiresAt,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent
? `${params.userAgent} (impersonated:readonly)`
: "impersonated:readonly",
impersonatedBy: params.founderId,
impersonationReadonly: true,
});
// Cookie format matches better-call's signCookieValue:
// urlEncode(`${token}.${standardBase64(HMAC_SHA256(token, secret))}`)
const signature = createHmac("sha256", secret).update(token).digest("base64");
const signedValue = `${token}.${signature}`;
const cookieValue = encodeURIComponent(signedValue);
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
const cookieName = isHttps
? SESSION_COOKIE_NAME_SECURE
: SESSION_COOKIE_NAME_INSECURE;
this.logger.log(
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}`,
);
return { cookieName, cookieValue, expiresAt, sessionId };
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ImpersonationController } from "./impersonation.controller";
import { ImpersonationService } from "./impersonation.service";
@Module({
controllers: [ImpersonationController],
providers: [ImpersonationService],
})
export class InternalAdminModule {}