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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user