Add time-limited external access for optional services and database.
Users can open temporary NodePort access with auto-revoke via Bull jobs and a dashboard UI to manage active grants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { AccessService } from './access.service';
|
||||
import { AccessProcessor } from './access.processor';
|
||||
import { ServiceAccessGrant } from './entities/service-access-grant.entity';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ServiceAccessGrant, Application, PlatformSetting]),
|
||||
BullModule.registerQueue({ name: 'access-revoke' }),
|
||||
KubernetesModule,
|
||||
],
|
||||
providers: [AccessService, AccessProcessor],
|
||||
exports: [AccessService],
|
||||
})
|
||||
export class AccessModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { AccessService, RevokeAccessJobData } from './access.service';
|
||||
|
||||
@Processor('access-revoke')
|
||||
export class AccessProcessor {
|
||||
private readonly logger = new Logger(AccessProcessor.name);
|
||||
|
||||
constructor(private readonly accessService: AccessService) {}
|
||||
|
||||
@Process('revoke')
|
||||
async handleRevoke(job: Job<RevokeAccessJobData>) {
|
||||
this.logger.log(`Revoking access grant ${job.data.grantId}`);
|
||||
await this.accessService.revokeGrant(job.data.grantId, undefined, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { Queue } from 'bull';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServiceAccessGrant } from './entities/service-access-grant.entity';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import {
|
||||
ServiceAccessTarget,
|
||||
ServiceAccessGrantStatus,
|
||||
DatabaseType,
|
||||
AppLifecycleStatus,
|
||||
} from '../common/enums';
|
||||
import { CreateServiceAccessDto } from './dto/service-access.dto';
|
||||
|
||||
export interface RevokeAccessJobData {
|
||||
grantId: string;
|
||||
}
|
||||
|
||||
const MIN_DURATION_MINUTES = 15;
|
||||
const DEFAULT_MAX_DURATION_MINUTES = 240;
|
||||
|
||||
@Injectable()
|
||||
export class AccessService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AccessService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServiceAccessGrant)
|
||||
private readonly grantsRepo: Repository<ServiceAccessGrant>,
|
||||
@InjectRepository(Application)
|
||||
private readonly appsRepo: Repository<Application>,
|
||||
@InjectRepository(PlatformSetting)
|
||||
private readonly settingsRepo: Repository<PlatformSetting>,
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
@InjectQueue('access-revoke')
|
||||
private readonly revokeQueue: Queue<RevokeAccessJobData>,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const expired = await this.grantsRepo.find({
|
||||
where: {
|
||||
status: ServiceAccessGrantStatus.ACTIVE,
|
||||
expiresAt: LessThan(new Date()),
|
||||
},
|
||||
});
|
||||
for (const grant of expired) {
|
||||
try {
|
||||
await this.revokeGrant(grant.id, grant.userId, true);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Startup expiry failed for grant ${grant.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (expired.length > 0) {
|
||||
this.logger.log(`Expired ${expired.length} stale access grant(s) on startup`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMaxDurationMinutes(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'access_max_duration_minutes' },
|
||||
});
|
||||
if (setting) {
|
||||
const parsed = parseInt(setting.value, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= MIN_DURATION_MINUTES) return parsed;
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_MAX_DURATION_MINUTES;
|
||||
}
|
||||
|
||||
private async validateApplicationForAccess(app: Application): Promise<void> {
|
||||
if (!app.clusterId) {
|
||||
throw new BadRequestException('Application must be deployed to a cluster first');
|
||||
}
|
||||
if (!app.latestImageTag) {
|
||||
throw new BadRequestException('Application must be deployed before requesting external access');
|
||||
}
|
||||
if (app.lifecycleStatus === AppLifecycleStatus.SUSPENDED) {
|
||||
throw new BadRequestException('Application is suspended');
|
||||
}
|
||||
if (app.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION) {
|
||||
throw new BadRequestException('Application is pending deletion');
|
||||
}
|
||||
}
|
||||
|
||||
private validateTargetEnabled(app: Application, target: ServiceAccessTarget): void {
|
||||
switch (target) {
|
||||
case ServiceAccessTarget.DATABASE:
|
||||
if (!app.databaseType || app.databaseType === DatabaseType.NONE) {
|
||||
throw new BadRequestException('Application has no database');
|
||||
}
|
||||
break;
|
||||
case ServiceAccessTarget.REDIS:
|
||||
if (!app.enableRedis) throw new BadRequestException('Redis is not enabled');
|
||||
break;
|
||||
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
|
||||
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private buildConnection(
|
||||
target: ServiceAccessTarget,
|
||||
host: string,
|
||||
port: number,
|
||||
creds: Record<string, string | number | undefined>,
|
||||
app: Application,
|
||||
): Record<string, string | number | undefined> {
|
||||
const connection: Record<string, string | number | undefined> = {
|
||||
host,
|
||||
port,
|
||||
};
|
||||
|
||||
if (creds.username) connection.username = creds.username as string;
|
||||
if (creds.password) connection.password = creds.password as string;
|
||||
if (creds.database) connection.database = creds.database as string;
|
||||
|
||||
switch (target) {
|
||||
case ServiceAccessTarget.DATABASE: {
|
||||
const dbType = app.databaseType;
|
||||
const dbName = app.name.replace(/-/g, '_');
|
||||
const user = (creds.username as string) || 'appuser';
|
||||
const pass = creds.password as string;
|
||||
if (dbType === DatabaseType.POSTGRESQL) {
|
||||
connection.url = `postgresql://${user}:${pass}@${host}:${port}/${dbName}`;
|
||||
} else if (dbType === DatabaseType.MONGODB) {
|
||||
connection.url = `mongodb://${user}:${pass}@${host}:${port}/${dbName}?authSource=admin`;
|
||||
} else {
|
||||
connection.url = `mysql://${user}:${pass}@${host}:${port}/${dbName}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ServiceAccessTarget.REDIS:
|
||||
connection.url = creds.password
|
||||
? `redis://:${creds.password}@${host}:${port}`
|
||||
: `redis://${host}:${port}`;
|
||||
break;
|
||||
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||
connection.url = `amqp://${creds.username || 'appuser'}:${creds.password}@${host}:${port}`;
|
||||
break;
|
||||
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
|
||||
connection.url = `http://${host}:${port}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async findApp(applicationId: string, userId?: string): Promise<Application> {
|
||||
const where: { id: string; userId?: string } = { id: applicationId };
|
||||
if (userId) where.userId = userId;
|
||||
const app = await this.appsRepo.findOne({ where });
|
||||
if (!app) throw new NotFoundException('Application not found');
|
||||
return app;
|
||||
}
|
||||
|
||||
async createGrant(
|
||||
applicationId: string,
|
||||
userId: string | undefined,
|
||||
dto: CreateServiceAccessDto,
|
||||
) {
|
||||
const app = await this.findApp(applicationId, userId);
|
||||
|
||||
await this.validateApplicationForAccess(app);
|
||||
this.validateTargetEnabled(app, dto.target);
|
||||
|
||||
const maxDuration = await this.getMaxDurationMinutes();
|
||||
if (dto.durationMinutes > maxDuration) {
|
||||
throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`);
|
||||
}
|
||||
|
||||
const existing = await this.grantsRepo.findOne({
|
||||
where: {
|
||||
applicationId,
|
||||
target: dto.target,
|
||||
status: ServiceAccessGrantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Active access already exists for ${dto.target}. Revoke it first or wait until it expires.`,
|
||||
);
|
||||
}
|
||||
|
||||
const grantId = uuidv4();
|
||||
const namespace = this.kubernetesService.getUserNamespace(app.userId);
|
||||
const expiresAt = new Date(Date.now() + dto.durationMinutes * 60 * 1000);
|
||||
|
||||
let k8sResult: { host: string; nodePort: number; k8sServiceName: string; targetPort: number };
|
||||
try {
|
||||
k8sResult = await this.kubernetesService.createTemporaryAccess(app, dto.target, grantId);
|
||||
} catch (e: any) {
|
||||
throw new BadRequestException(e.message || 'Failed to open external access');
|
||||
}
|
||||
|
||||
const grant = this.grantsRepo.create({
|
||||
id: grantId,
|
||||
applicationId,
|
||||
userId: app.userId,
|
||||
clusterId: app.clusterId!,
|
||||
namespace,
|
||||
target: dto.target,
|
||||
nodePort: k8sResult.nodePort,
|
||||
targetPort: k8sResult.targetPort,
|
||||
host: k8sResult.host,
|
||||
k8sServiceName: k8sResult.k8sServiceName,
|
||||
status: ServiceAccessGrantStatus.ACTIVE,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await this.grantsRepo.save(grant);
|
||||
|
||||
const delayMs = Math.max(0, expiresAt.getTime() - Date.now());
|
||||
await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId });
|
||||
|
||||
const creds = await this.kubernetesService.readAccessCredentials(app, dto.target);
|
||||
const connection = this.buildConnection(
|
||||
dto.target,
|
||||
k8sResult.host,
|
||||
k8sResult.nodePort,
|
||||
creds,
|
||||
app,
|
||||
);
|
||||
|
||||
return {
|
||||
id: grant.id,
|
||||
target: grant.target,
|
||||
host: grant.host,
|
||||
port: grant.nodePort,
|
||||
targetPort: grant.targetPort,
|
||||
expiresAt: grant.expiresAt,
|
||||
status: grant.status,
|
||||
connection,
|
||||
};
|
||||
}
|
||||
|
||||
async listGrants(applicationId: string, userId: string | undefined, includeRecent = false) {
|
||||
const app = await this.findApp(applicationId, userId);
|
||||
|
||||
const where: any = { applicationId };
|
||||
if (!includeRecent) {
|
||||
where.status = ServiceAccessGrantStatus.ACTIVE;
|
||||
}
|
||||
|
||||
const grants = await this.grantsRepo.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
const filtered = includeRecent
|
||||
? grants
|
||||
: grants.filter((g) => g.status === ServiceAccessGrantStatus.ACTIVE);
|
||||
|
||||
return Promise.all(
|
||||
filtered.map(async (grant) => {
|
||||
const creds = await this.kubernetesService.readAccessCredentials(app, grant.target);
|
||||
const connection = this.buildConnection(
|
||||
grant.target,
|
||||
grant.host,
|
||||
grant.nodePort,
|
||||
creds,
|
||||
app,
|
||||
);
|
||||
return {
|
||||
id: grant.id,
|
||||
target: grant.target,
|
||||
host: grant.host,
|
||||
port: grant.nodePort,
|
||||
targetPort: grant.targetPort,
|
||||
expiresAt: grant.expiresAt,
|
||||
status: grant.status,
|
||||
connection,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise<void> {
|
||||
const grant = await this.grantsRepo.findOne({
|
||||
where: { id: grantId },
|
||||
relations: ['application'],
|
||||
});
|
||||
if (!grant) throw new NotFoundException('Access grant not found');
|
||||
if (!system && userId !== undefined && grant.userId !== userId) {
|
||||
throw new NotFoundException('Access grant not found');
|
||||
}
|
||||
if (grant.status !== ServiceAccessGrantStatus.ACTIVE) return;
|
||||
|
||||
await this.kubernetesService.revokeTemporaryAccess(
|
||||
grant.clusterId,
|
||||
grant.namespace,
|
||||
grant.k8sServiceName,
|
||||
);
|
||||
|
||||
grant.status =
|
||||
system && grant.expiresAt.getTime() <= Date.now()
|
||||
? ServiceAccessGrantStatus.EXPIRED
|
||||
: ServiceAccessGrantStatus.REVOKED;
|
||||
await this.grantsRepo.save(grant);
|
||||
|
||||
try {
|
||||
const job = await this.revokeQueue.getJob(grantId);
|
||||
if (job) await job.remove();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async revokeAllForApplication(applicationId: string): Promise<void> {
|
||||
const grants = await this.grantsRepo.find({
|
||||
where: { applicationId, status: ServiceAccessGrantStatus.ACTIVE },
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await this.revokeGrant(grant.id, undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
async findGrantForApp(grantId: string, applicationId: string, userId?: string) {
|
||||
const where: any = { id: grantId, applicationId };
|
||||
if (userId) where.userId = userId;
|
||||
const grant = await this.grantsRepo.findOne({ where });
|
||||
if (!grant) throw new NotFoundException('Access grant not found');
|
||||
return grant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { ServiceAccessTarget } from '../../common/enums';
|
||||
|
||||
export class CreateServiceAccessDto {
|
||||
@ApiProperty({ enum: ServiceAccessTarget })
|
||||
@IsEnum(ServiceAccessTarget)
|
||||
target: ServiceAccessTarget;
|
||||
|
||||
@ApiProperty({ example: 60, description: 'Access duration in minutes (min 15)' })
|
||||
@IsInt()
|
||||
@Min(15)
|
||||
@Max(1440)
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
export class ServiceAccessConnectionDto {
|
||||
@ApiPropertyOptional()
|
||||
host?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
port?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
database?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class ServiceAccessGrantResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ enum: ServiceAccessTarget })
|
||||
target: ServiceAccessTarget;
|
||||
|
||||
@ApiProperty()
|
||||
host: string;
|
||||
|
||||
@ApiProperty()
|
||||
port: number;
|
||||
|
||||
@ApiProperty()
|
||||
targetPort: number;
|
||||
|
||||
@ApiProperty()
|
||||
expiresAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ type: ServiceAccessConnectionDto })
|
||||
connection: ServiceAccessConnectionDto;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ServiceAccessTarget, ServiceAccessGrantStatus } from '../../common/enums';
|
||||
import { Application } from '../../applications/entities/application.entity';
|
||||
|
||||
@Entity('service_access_grants')
|
||||
@Index(['applicationId', 'target', 'status'])
|
||||
export class ServiceAccessGrant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
applicationId: string;
|
||||
|
||||
@ManyToOne(() => Application, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Application;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column()
|
||||
clusterId: string;
|
||||
|
||||
@Column()
|
||||
namespace: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ServiceAccessTarget })
|
||||
target: ServiceAccessTarget;
|
||||
|
||||
@Column()
|
||||
nodePort: number;
|
||||
|
||||
@Column()
|
||||
targetPort: number;
|
||||
|
||||
@Column()
|
||||
host: string;
|
||||
|
||||
@Column()
|
||||
k8sServiceName: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ServiceAccessGrantStatus, default: ServiceAccessGrantStatus.ACTIVE })
|
||||
status: ServiceAccessGrantStatus;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user