feat: per-tenant server access kill switch
Add Tenant.serverAccessBlocked, toggle API with appVersion bump and settings push, and expose stealth server_reachable in get_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
-- Per-tenant app/admin access kill switch (shared physical servers stay up)
|
||||||
|
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD COLUMN IF NOT EXISTS "server_access_blocked" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD COLUMN IF NOT EXISTS "server_access_blocked_at" TIMESTAMP(3);
|
||||||
@@ -76,6 +76,9 @@ model Tenant {
|
|||||||
/// Client cache-bust / force-reload signal for this sold version (GET /get_version).
|
/// Client cache-bust / force-reload signal for this sold version (GET /get_version).
|
||||||
appVersion String @default("1") @map("app_version")
|
appVersion String @default("1") @map("app_version")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
/// Kill switch: block this sold version's app+admin (server stays up). Not the same as ServerAccount billing.
|
||||||
|
serverAccessBlocked Boolean @default(false) @map("server_access_blocked")
|
||||||
|
serverAccessBlockedAt DateTime? @map("server_access_blocked_at")
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export class GetUserService {
|
|||||||
product: tenant.product,
|
product: tenant.product,
|
||||||
theme: themeMode,
|
theme: themeMode,
|
||||||
mode: themeMode,
|
mode: themeMode,
|
||||||
|
/** Stealth cold-boot gate — false looks like product server unreachable */
|
||||||
|
server_reachable: !Boolean(tenant.serverAccessBlocked),
|
||||||
/** Khanegi front-end family for consumers */
|
/** Khanegi front-end family for consumers */
|
||||||
ui_variant: isKhanegi ? uiVariant : undefined,
|
ui_variant: isKhanegi ? uiVariant : undefined,
|
||||||
app_ui_config:
|
app_ui_config:
|
||||||
|
|||||||
@@ -78,4 +78,9 @@ export class RenewServerDto {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetServerAccessDto {
|
||||||
|
@IsBoolean()
|
||||||
|
blocked!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export { PLAN_MONTHS };
|
export { PLAN_MONTHS };
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto';
|
import { RenewServerDto, SetServerAccessDto, UpdateServerAccountDto } from './dto/server-account.dto';
|
||||||
import { ServersService } from './servers.service';
|
import { ServersService } from './servers.service';
|
||||||
|
|
||||||
@Controller('servers')
|
@Controller('servers')
|
||||||
@@ -45,6 +45,20 @@ export class ServersController {
|
|||||||
return this.serversService.getTenantServers(id);
|
return this.serversService.getTenantServers(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('tenants/:id/server-access')
|
||||||
|
@RequirePermissions('servers:write')
|
||||||
|
setServerAccess(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: SetServerAccessDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
return this.serversService.setServerAccessBlocked(
|
||||||
|
id,
|
||||||
|
dto.blocked,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('tenants/:id/servers')
|
@Post('tenants/:id/servers')
|
||||||
@RequirePermissions('servers:write')
|
@RequirePermissions('servers:write')
|
||||||
createServer(
|
createServer(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { SettingsProxyModule } from '../settings-proxy/settings-proxy.module';
|
||||||
import { ServersController } from './servers.controller';
|
import { ServersController } from './servers.controller';
|
||||||
import { ServersService } from './servers.service';
|
import { ServersService } from './servers.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AuthModule],
|
imports: [NotificationsModule, AuthModule, SettingsProxyModule],
|
||||||
controllers: [ServersController],
|
controllers: [ServersController],
|
||||||
providers: [ServersService],
|
providers: [ServersService],
|
||||||
exports: [ServersService],
|
exports: [ServersService],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Prisma, ServerAccount } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||||
|
import { SettingsProxyService } from '../settings-proxy/settings-proxy.service';
|
||||||
import {
|
import {
|
||||||
PLAN_MONTHS,
|
PLAN_MONTHS,
|
||||||
RenewServerDto,
|
RenewServerDto,
|
||||||
@@ -45,6 +46,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly notify: AdminNotifyService,
|
private readonly notify: AdminNotifyService,
|
||||||
|
private readonly settingsProxy: SettingsProxyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -141,6 +143,8 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
nameEn: tenant.nameEn,
|
nameEn: tenant.nameEn,
|
||||||
domain: tenant.domain,
|
domain: tenant.domain,
|
||||||
avatarUrl: tenant.avatarUrl,
|
avatarUrl: tenant.avatarUrl,
|
||||||
|
serverAccessBlocked: Boolean(tenant.serverAccessBlocked),
|
||||||
|
serverAccessBlockedAt: tenant.serverAccessBlockedAt,
|
||||||
},
|
},
|
||||||
servers: accounts.map((account) => ({
|
servers: accounts.map((account) => ({
|
||||||
account: this.publicAccount(account),
|
account: this.publicAccount(account),
|
||||||
@@ -669,4 +673,110 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
|
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
|
||||||
return tenant;
|
return tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill switch for this sold version's app+admin (physical server stays up).
|
||||||
|
* Bumps appVersion and pushes flag to product /super/settings for immediate effect.
|
||||||
|
*/
|
||||||
|
async setServerAccessBlocked(
|
||||||
|
tenantId: string,
|
||||||
|
blocked: boolean,
|
||||||
|
userId?: string,
|
||||||
|
) {
|
||||||
|
const before = await this.requireTenant(tenantId);
|
||||||
|
if (Boolean(before.serverAccessBlocked) === blocked) {
|
||||||
|
return {
|
||||||
|
tenant: {
|
||||||
|
id: before.id,
|
||||||
|
serverAccessBlocked: Boolean(before.serverAccessBlocked),
|
||||||
|
serverAccessBlockedAt: before.serverAccessBlockedAt,
|
||||||
|
appVersion: before.appVersion,
|
||||||
|
},
|
||||||
|
push: { skipped: true, reason: 'unchanged' as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = this.bumpAppVersion(before.appVersion);
|
||||||
|
const updated = await this.prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: {
|
||||||
|
serverAccessBlocked: blocked,
|
||||||
|
serverAccessBlockedAt: blocked ? new Date() : null,
|
||||||
|
appVersion: nextVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
action: blocked
|
||||||
|
? 'tenant.server_access.block'
|
||||||
|
: 'tenant.server_access.unblock',
|
||||||
|
entityType: 'tenant',
|
||||||
|
entityId: tenantId,
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
before: {
|
||||||
|
serverAccessBlocked: before.serverAccessBlocked,
|
||||||
|
appVersion: before.appVersion,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
after: {
|
||||||
|
serverAccessBlocked: updated.serverAccessBlocked,
|
||||||
|
appVersion: updated.appVersion,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notify.notifyAdmins(
|
||||||
|
blocked
|
||||||
|
? `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» قطع شد (ظاهر قطعی سرور).`
|
||||||
|
: `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» وصل شد.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let push: {
|
||||||
|
ok: boolean;
|
||||||
|
stub?: boolean;
|
||||||
|
message?: string;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await this.settingsProxy.updateSettings(
|
||||||
|
tenantId,
|
||||||
|
{ server_access_blocked: blocked },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
push = {
|
||||||
|
ok: true,
|
||||||
|
stub: Boolean((result as { stub?: boolean })?.stub),
|
||||||
|
message: (result as { message?: string })?.message,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
push = {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
error?.message ||
|
||||||
|
'قفل در رجیستری ثبت شد ولی push فوری به سرور نسخه ناموفق بود',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: {
|
||||||
|
id: updated.id,
|
||||||
|
serverAccessBlocked: updated.serverAccessBlocked,
|
||||||
|
serverAccessBlockedAt: updated.serverAccessBlockedAt,
|
||||||
|
appVersion: updated.appVersion,
|
||||||
|
},
|
||||||
|
push,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpAppVersion(current?: string | null): string {
|
||||||
|
const v = String(current || '1').trim() || '1';
|
||||||
|
if (/^\d+$/.test(v)) {
|
||||||
|
return String(Number(v) + 1);
|
||||||
|
}
|
||||||
|
const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (semver) {
|
||||||
|
return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`;
|
||||||
|
}
|
||||||
|
return `${Date.now()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import { SettingsProxyService } from './settings-proxy.service';
|
|||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [SettingsProxyController],
|
controllers: [SettingsProxyController],
|
||||||
providers: [SettingsProxyService],
|
providers: [SettingsProxyService],
|
||||||
|
exports: [SettingsProxyService],
|
||||||
})
|
})
|
||||||
export class SettingsProxyModule {}
|
export class SettingsProxyModule {}
|
||||||
|
|||||||
@@ -33,16 +33,13 @@ export class SettingsProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(
|
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
|
||||||
this.joinUrl(tenant.nodeServer, '/super/settings'),
|
headers: {
|
||||||
{
|
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
||||||
headers: {
|
Accept: 'application/json',
|
||||||
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
|
||||||
},
|
},
|
||||||
);
|
timeout: 15000,
|
||||||
|
});
|
||||||
return { stub: false, settings: data };
|
return { stub: false, settings: data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
@@ -79,7 +76,7 @@ export class SettingsProxyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
this.joinUrl(tenant.nodeServer, '/super/settings'),
|
this.resolveSettingsUrl(tenant.nodeServer),
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -122,6 +119,18 @@ export class SettingsProxyService {
|
|||||||
return `${base.replace(/\/$/, '')}${path}`;
|
return `${base.replace(/\/$/, '')}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nodeServer may be `https://host` or `https://host/api`.
|
||||||
|
* Laravel exposes both `/super/settings` (web) and `/api/super/settings`.
|
||||||
|
*/
|
||||||
|
private resolveSettingsUrl(nodeServer: string): string {
|
||||||
|
const base = String(nodeServer || '').replace(/\/$/, '');
|
||||||
|
if (/\/api$/i.test(base)) {
|
||||||
|
return this.joinUrl(base, '/super/settings');
|
||||||
|
}
|
||||||
|
return this.joinUrl(base, '/api/super/settings');
|
||||||
|
}
|
||||||
|
|
||||||
private axiosMessage(error: unknown, fallback: string) {
|
private axiosMessage(error: unknown, fallback: string) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
|||||||
Reference in New Issue
Block a user