feat: owner read-only view-as from team users table

Allow org owners to inspect any member's data scope via dual JWT; server blocks mutations and team page stays reachable for return.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 19:57:26 +03:30
co-authored by Cursor
parent 52c7306e78
commit f7b07f6b7a
13 changed files with 435 additions and 49 deletions
+28 -12
View File
@@ -26,8 +26,7 @@ import {
} from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
type AuthActor = { userId: string; organizationId: string };
import type { Actor } from '../common/record-scope';
@Controller('auth')
export class AuthController {
@@ -69,7 +68,7 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Post('switch-organization')
switchOrganization(
@CurrentUser() user: AuthActor,
@CurrentUser() user: Actor,
@Body() dto: SwitchOrganizationDto,
) {
return this.authService.switchOrganization(user.userId, dto.organizationId);
@@ -77,38 +76,55 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Get('organizations')
listOrganizations(@CurrentUser() user: AuthActor) {
listOrganizations(@CurrentUser() user: Actor) {
return this.authService.listMyOrganizations(user.userId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthActor) {
return this.authService.me(user.userId, user.organizationId);
me(@CurrentUser() user: Actor) {
return this.authService.me(user.userId, user.organizationId, user);
}
@UseGuards(JwtAuthGuard)
@Post('view-as')
viewAs(
@CurrentUser() user: Actor,
@Body() body: { targetUserId?: string },
) {
if (!body.targetUserId?.trim()) {
throw new BadRequestException('کاربر هدف مشخص نیست.');
}
return this.authService.viewAs(user, body.targetUserId.trim());
}
@UseGuards(JwtAuthGuard)
@Patch('me')
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
updateMe(@CurrentUser() user: Actor, @Body() dto: UpdateProfileDto) {
return this.authService.updateMe(user.userId, user.organizationId, dto);
}
@UseGuards(JwtAuthGuard)
@Patch('me/prior-archive')
setPriorLens(
@CurrentUser() user: AuthActor,
@CurrentUser() user: Actor,
@Body() body: { lens?: string },
) {
if (body.lens !== 'archive' && body.lens !== 'current') {
throw new BadRequestException('حالت بایگانی نامعتبر است');
}
return this.authService.setPriorLens(user.userId, user.organizationId, body.lens);
return this.authService.setPriorLens(
user.userId,
user.organizationId,
body.lens,
user,
);
}
/** عکس پروفایل کاربر جاری */
@UseGuards(JwtAuthGuard)
@Get('me/avatar')
async avatar(@CurrentUser() user: AuthActor) {
async avatar(@CurrentUser() user: Actor) {
const file = await this.authService.openAvatar(user.userId);
return new StreamableFile(file.stream, { type: file.mimeType });
}
@@ -122,7 +138,7 @@ export class AuthController {
}),
)
uploadAvatar(
@CurrentUser() user: AuthActor,
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.authService.saveAvatar(user.userId, user.organizationId, file);
@@ -130,7 +146,7 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Delete('me/avatar')
removeAvatar(@CurrentUser() user: AuthActor) {
removeAvatar(@CurrentUser() user: Actor) {
return this.authService.removeAvatar(user.userId, user.organizationId);
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -8,6 +9,7 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard';
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
import { SmsService } from '../common/sms.service';
@Module({
@@ -28,7 +30,16 @@ import { SmsService } from '../common/sms.service';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, RolesGuard, SmsService],
providers: [
AuthService,
JwtStrategy,
RolesGuard,
SmsService,
{
provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor,
},
],
exports: [AuthService, RolesGuard, JwtModule],
})
export class AuthModule {}
+118 -7
View File
@@ -26,6 +26,7 @@ import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import type { Actor } from '../common/record-scope';
import {
LoginDto,
LoginOtpRequestDto,
@@ -406,19 +407,113 @@ export class AuthService {
return this.finishLogin(user.id);
}
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUserDto(user, organizationId);
let impersonatorFullName: string | undefined;
if (actor?.viewAsReadOnly && actor.impersonatorUserId) {
const owner = await this.prisma.user.findUnique({
where: { id: actor.impersonatorUserId },
select: { fullName: true },
});
impersonatorFullName = owner?.fullName;
}
return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName);
}
/**
* مالک محیط: مشاهده فقط‌خواندنی به‌جای کاربر هدف، یا بازگشت به حساب خودش.
*/
async viewAs(actor: Actor, targetUserId: string): Promise<LoginResponse> {
const ownerId = actor.impersonatorUserId ?? actor.userId;
const ownerMembership = await this.prisma.membership.findFirst({
where: {
userId: ownerId,
organizationId: actor.organizationId,
isActive: true,
role: 'owner',
},
select: { userId: true, organizationId: true },
});
if (!ownerMembership) {
throw new ForbiddenException('فقط مالک محیط می‌تواند به‌جای کاربر دیگر مشاهده کند.');
}
if (!targetUserId?.trim()) {
throw new BadRequestException('کاربر هدف مشخص نیست.');
}
if (targetUserId === ownerId) {
return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true });
}
const target = await this.prisma.user.findFirst({
where: {
id: targetUserId,
isActive: true,
memberships: {
some: {
organizationId: actor.organizationId,
isActive: true,
},
},
},
select: {
id: true,
email: true,
mobile: true,
fullName: true,
memberships: {
where: { organizationId: actor.organizationId, isActive: true },
select: { role: true },
take: 1,
},
},
});
if (!target?.memberships[0]) {
throw new NotFoundException('کاربر در این محیط پیدا نشد.');
}
const role = target.memberships[0].role as AuthUserDto['role'];
const subscription = await this.billing.requireActiveForOrganization(
actor.organizationId,
);
const accessToken = this.jwt.sign({
sub: target.id,
organizationId: actor.organizationId,
role,
impersonatorUserId: ownerId,
viewAsReadOnly: true,
});
const ownerUser = await this.prisma.user.findUnique({
where: { id: ownerId },
select: { fullName: true },
});
const userDto = await this.toAuthUserDto(
{ ...target, role, organizationId: actor.organizationId },
actor.organizationId,
subscription,
{
userId: target.id,
role,
organizationId: actor.organizationId,
impersonatorUserId: ownerId,
impersonatorRole: 'owner',
viewAsReadOnly: true,
},
ownerUser?.fullName ?? 'مالک',
);
return { accessToken, user: userDto };
}
async setPriorLens(
userId: string,
organizationId: string,
lens: 'current' | 'archive',
actor?: Actor,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
@@ -435,7 +530,7 @@ export class AuthService {
where: { id: membership.id },
data: { priorArchiveLens: lens },
});
return this.me(userId, organizationId);
return this.me(userId, organizationId, actor);
}
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
@@ -612,6 +707,7 @@ export class AuthService {
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
@@ -626,10 +722,12 @@ export class AuthService {
organizationId,
membership.role,
);
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
if (!opts?.skipLastLogin) {
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
}
const accessToken = this.jwt.sign({
sub: userId,
organizationId,
@@ -656,6 +754,8 @@ export class AuthService {
},
organizationId: string,
subscription?: SubscriptionSummaryDto | null,
actor?: Actor,
impersonatorFullName?: string,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true },
@@ -670,6 +770,14 @@ export class AuthService {
const sub =
subscription ??
(await this.billing.requireActiveForOrganization(organizationId));
const viewAs =
actor?.viewAsReadOnly && actor.impersonatorUserId
? {
impersonatorId: actor.impersonatorUserId,
impersonatorFullName: impersonatorFullName?.trim() || 'مالک',
canOpenTeamSettings: true,
}
: undefined;
return this.toAuthUser({
...user,
role,
@@ -682,6 +790,7 @@ export class AuthService {
subscription: sub,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
...(viewAs ? { viewAs } : {}),
});
}
@@ -787,6 +896,7 @@ export class AuthService {
subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
}): AuthUserDto {
return {
id: user.id,
@@ -801,6 +911,7 @@ export class AuthService {
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
};
}
}
+31
View File
@@ -16,6 +16,8 @@ export type JwtPayload = {
sub: string;
organizationId: string;
role: string;
impersonatorUserId?: string;
viewAsReadOnly?: boolean;
};
@Injectable()
@@ -43,6 +45,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
if (!payload.sub || !payload.organizationId) {
throw new UnauthorizedException();
}
let impersonatorUserId: string | undefined;
let impersonatorRole: string | undefined;
const viewAsReadOnly = Boolean(payload.viewAsReadOnly && payload.impersonatorUserId);
if (viewAsReadOnly && payload.impersonatorUserId) {
const ownerMembership = await this.prisma.membership.findFirst({
where: {
userId: payload.impersonatorUserId,
organizationId: payload.organizationId,
isActive: true,
role: 'owner',
organization: { isActive: true },
},
select: { userId: true, role: true },
});
if (!ownerMembership) {
throw new UnauthorizedException();
}
impersonatorUserId = ownerMembership.userId;
impersonatorRole = ownerMembership.role;
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, isActive: true },
@@ -111,6 +135,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
priorGranted && membership.organization.priorArchiveEnabledAt
? membership.organization.priorArchiveEnabledAt
: null,
...(viewAsReadOnly && impersonatorUserId
? {
impersonatorUserId,
impersonatorRole: impersonatorRole ?? 'owner',
viewAsReadOnly: true,
}
: {}),
};
}
+60 -7
View File
@@ -6,26 +6,79 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { UserRole } from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { ROLES_KEY } from './roles.decorator';
const VIEW_AS_MESSAGE =
'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.';
/** خواندن‌های صفحهٔ تیم وقتی مالک در حالت مشاهده است. */
function isTeamSettingsRead(method: string, url: string): boolean {
if (method.toUpperCase() !== 'GET') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return (
/\/users(\/|$)/.test(path) ||
/\/org-roles(\/|$)/.test(path) ||
/\/teams(\/|$)/.test(path) ||
/\/organizations\/current/.test(path)
);
}
function isViewAsAllowedMutation(method: string, url: string): boolean {
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
const m = method.toUpperCase();
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
return true;
}
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
return true;
}
return false;
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const method = (request.method || 'GET').toUpperCase();
const url = request.originalUrl || request.url || '';
const actor = request.user;
if (
actor?.viewAsReadOnly &&
method !== 'GET' &&
method !== 'HEAD' &&
method !== 'OPTIONS' &&
!isViewAsAllowedMutation(method, url)
) {
throw new ForbiddenException(VIEW_AS_MESSAGE);
}
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!roles?.length) return true;
const request = context.switchToHttp().getRequest<{
user?: { role?: string };
}>();
const role = request.user?.role as UserRole | undefined;
if (!role || !roles.includes(role)) {
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
const role = actor?.role as UserRole | undefined;
if (role && roles.includes(role)) return true;
if (
actor?.viewAsReadOnly &&
actor.impersonatorRole === 'owner' &&
roles.includes('owner') &&
isTeamSettingsRead(method, url)
) {
return true;
}
return true;
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
}
}
@@ -0,0 +1,51 @@
import {
CallHandler,
ExecutionContext,
ForbiddenException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import type { Observable } from 'rxjs';
import type { Actor } from '../common/record-scope';
const VIEW_AS_MESSAGE =
'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.';
function normalizePath(url: string): string {
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
return path;
}
/** مسیرهایی که در حالت مشاهده مجاز به تغییرند (خروج از مشاهده و لنز بایگانی). */
function isAllowedWhileViewing(method: string, url: string): boolean {
const path = normalizePath(url);
const m = method.toUpperCase();
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
return true;
}
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
return true;
}
return false;
}
@Injectable()
export class ViewAsReadOnlyInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request & { user?: Actor }>();
const method = (req.method || 'GET').toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return next.handle();
}
const actor = req.user;
if (!actor?.viewAsReadOnly) {
return next.handle();
}
const url = req.originalUrl || req.url || '';
if (isAllowedWhileViewing(method, url)) {
return next.handle();
}
throw new ForbiddenException(VIEW_AS_MESSAGE);
}
}
+5
View File
@@ -24,6 +24,11 @@ export type Actor = {
priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
priorCutoff?: Date | null;
/** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */
impersonatorUserId?: string;
impersonatorRole?: string;
/** در حالت مشاهده، سرور تغییر را رد می‌کند */
viewAsReadOnly?: boolean;
};
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;