Files
sase.tr/apps/api/src/internal-admin/impersonation.service.ts
Semih Yesilyurt 2d5e2d04a9
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(canonical): 2-level serving — taksonomi endpoint + araç sub-gruplama
GET /categories/canonical/taxonomy: kova→alt-yaprak + populasyon sayısı (B2C API
kontratı). getCanonicalTree (vehicle + catalog) artık subLeaves geçip her kovayı
alt-gruplara böler (materialize + on-the-fly sub fallback). jant-tire slug fix
(jant-lastik üst-kova ile çakışıyordu). Kategori sub-kapsama: Filtre %88, Fren/
Aydınlatma %74, Motor/Süsp/Elektrik %60-66. Eşleşmeyen → Genel/SP manuel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:40:39 +03:00

87 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
}