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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'>;
|
||||
|
||||
Reference in New Issue
Block a user