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