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:
3
apps/api/drizzle/0005_past_elektra.sql
Normal file
3
apps/api/drizzle/0005_past_elektra.sql
Normal 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");
|
||||
5354
apps/api/drizzle/meta/0005_snapshot.json
Normal file
5354
apps/api/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1778709949622,
|
||||
"tag": "0004_hot_quicksilver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1779054231051,
|
||||
"tag": "0005_past_elektra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
54
apps/api/src/common/guards/impersonation-readonly.guard.ts
Normal file
54
apps/api/src/common/guards/impersonation-readonly.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
91
apps/api/src/internal-admin/impersonation.service.ts
Normal file
91
apps/api/src/internal-admin/impersonation.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
9
apps/api/src/internal-admin/internal-admin.module.ts
Normal file
9
apps/api/src/internal-admin/internal-admin.module.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user