Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/api/review-summation/review-summation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,15 @@ export class ReviewSummationController {
`Getting review summations with filters - ${JSON.stringify(queryDto)}`,
);
const authUser: JwtUser = req['user'] as JwtUser;
const wantsTabSeparated = this.requestWantsTabSeparated(req);
const results = await this.service.searchSummation(
authUser,
queryDto,
paginationDto,
sortDto,
);

if (!this.requestWantsTabSeparated(req)) {
if (!wantsTabSeparated) {
return results;
}

Expand All @@ -199,7 +200,13 @@ export class ReviewSummationController {
});
}

const payload = this.buildReviewSummationTsv(results);
const completeResults = await this.loadAllReviewSummationsForExport(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The method loadAllReviewSummationsForExport is called without checking if completeResults is needed. Consider checking if totalCount is greater than currentCount before calling this method to avoid unnecessary database queries, which could improve performance.

authUser,
queryDto,
sortDto,
results,
);
const payload = this.buildReviewSummationTsv(completeResults);
const safeChallengeSlug = this.buildFilenameSlug(challengeId);
const filename = `${ReviewSummationController.TSV_FILENAME_PREFIX}-${safeChallengeSlug}.tsv`;
res.setHeader(
Expand Down Expand Up @@ -321,6 +328,28 @@ export class ReviewSummationController {
};
}

private async loadAllReviewSummationsForExport(
authUser: JwtUser,
queryDto: ReviewSummationQueryDto,
sortDto: SortDto | undefined,
initialResults: PaginatedResponse<ReviewSummationResponseDto>,
): Promise<PaginatedResponse<ReviewSummationResponseDto>> {
const totalCount =
initialResults.meta?.totalCount ?? initialResults.data.length;
const currentCount = initialResults.data.length;

if (!Number.isFinite(totalCount) || totalCount <= currentCount) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The check if (!Number.isFinite(totalCount) || totalCount <= currentCount) is used to determine if additional data should be fetched. Ensure that totalCount is correctly set in all cases, as relying on initialResults.data.length might not always reflect the total available records, potentially leading to incomplete data exports.

return initialResults;
}

return this.service.searchSummation(
authUser,
queryDto,
{ page: 1, perPage: totalCount },
sortDto,
);
}

private requestWantsTabSeparated(req: Request): boolean {
const acceptHeader = Array.isArray(req.headers.accept)
? req.headers.accept.join(',')
Expand Down
72 changes: 48 additions & 24 deletions src/api/submission/submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3085,34 +3085,58 @@ export class SubmissionService {
return;
}

const latestIds = new Set<string>();
await Promise.all(
Array.from(uniquePairs.values()).map(
async ({ challengeId, memberId }) => {
const latest = await this.prisma.submission.findFirst({
where: {
challengeId,
memberId,
},
orderBy: [
{ submittedDate: 'desc' },
{ createdAt: 'desc' },
{ updatedAt: 'desc' },
],
select: { id: true },
});

if (latest?.id) {
latestIds.add(latest.id);
}
},
const challengeIds = Array.from(
new Set(
Array.from(uniquePairs.values()).map((entry) => entry.challengeId),
),
);
const memberIds = Array.from(
new Set(Array.from(uniquePairs.values()).map((entry) => entry.memberId)),
);

for (const submission of submissions) {
if (latestIds.has(submission.id)) {
(submission as any).isLatest = true;
if (!challengeIds.length || !memberIds.length) {
return;
}

try {
// Use a single windowed query to locate the latest submission per (challengeId, memberId)
const latestEntries = await this.prisma.$queryRaw<
Array<{ id: string }>
>(Prisma.sql`
SELECT "id"
FROM (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "challengeId", "memberId"
ORDER BY "submittedDate" DESC NULLS LAST,
"createdAt" DESC,
"updatedAt" DESC NULLS LAST
) AS row_num
FROM "submission"
WHERE "challengeId" IN (${Prisma.join(challengeIds)})
AND "memberId" IN (${Prisma.join(memberIds)})
) ranked
WHERE row_num = 1
`);

const latestIds = new Set(
latestEntries
.map((entry) => String(entry.id ?? '').trim())
.filter((id) => id.length > 0),
);

for (const submission of submissions) {
if (latestIds.has(submission.id)) {
(submission as any).isLatest = true;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[populateLatestSubmissionFlags] Failed to resolve latest submissions via bulk query: ${message}`,
);
throw error;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/shared/config/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const AuthConfig = {
// JSON array string of allowed issuers
validIssuers:
process.env.VALID_ISSUERS ||
'["https://testsachin.topcoder-dev.com/","https://test-sachin-rs256.auth0.com/","https://api.topcoder.com","https://api.topcoder-dev.com","https://topcoder-dev.auth0.com/", "https://auth.topcoder-dev.com/"]',
'["https://testsachin.topcoder-dev.com/","https://test-sachin-rs256.auth0.com/","https://api.topcoder.com","https://api.topcoder-dev.com","https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/"]',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
Consider validating the new issuers added to the validIssuers list to ensure they are trusted and necessary. Adding unnecessary or untrusted issuers could pose a security risk.


// Legacy JWT configuration (kept for backward compatibility)
jwt: {
Expand Down
122 changes: 122 additions & 0 deletions src/shared/guards/tokenRoles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,30 @@ import {
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { JwtService } from '../modules/global/jwt.service';
import { SCOPES_KEY } from '../decorators/scopes.decorator';
import { LoggerService } from '../modules/global/logger.service';
import { UserRole } from '../enums/userRole.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

@Injectable()
export class TokenRolesGuard implements CanActivate {
private readonly logger = LoggerService.forRoot(TokenRolesGuard.name);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ design]
Consider using dependency injection for LoggerService instead of calling forRoot. This would improve testability and adhere to the dependency inversion principle.


constructor(
private reflector: Reflector,
private jwtService: JwtService,
) {}

canActivate(context: ExecutionContext): boolean {
const handler = context.getHandler?.();
const controllerClass = context.getClass?.();
const controllerName = controllerClass?.name || 'UnknownController';
const handlerName = handler?.name || 'UnknownHandler';

// Get required roles and scopes from decorators
const requiredRoles =
this.reflector.get<UserRole[]>(ROLES_KEY, context.getHandler()) || [];
Expand All @@ -31,18 +40,49 @@ export class TokenRolesGuard implements CanActivate {

// If no roles or scopes are required, allow access
if (requiredRoles.length === 0 && requiredScopes.length === 0) {
this.logger.log({
message: 'No roles or scopes required, allowing request',
controllerName,
handlerName,
});
return true;
}

const request = context.switchToHttp().getRequest<Request>();
const requestMeta = this.buildRequestLogMeta(
request,
controllerName,
handlerName,
);

this.logger.log({
message: 'Evaluating token roles guard',
...requestMeta,
requiredRoles,
requiredScopes,
});

try {
const user = request['user'];

if (!user && (requiredRoles.length || requiredScopes.length)) {
this.logger.warn({
message: 'Rejecting request due to missing user payload',
...requestMeta,
hasUser: false,
});
throw new UnauthorizedException('Missing or invalid token!');
}

this.logger.log({
message: 'User payload extracted from request',
...requestMeta,
userId: user?.userId,
isMachine: Boolean(user?.isMachine),
roles: user?.roles,
scopes: user?.scopes,
});

const normalizedRequiredRoles = requiredRoles.map((role) =>
String(role).trim().toLowerCase(),
);
Expand All @@ -51,10 +91,22 @@ export class TokenRolesGuard implements CanActivate {
if (normalizedRequiredRoles.length > 0) {
const normalizedUserRoles = this.normalizeUserRoles(user.roles);

this.logger.log({
message: 'Checking role-based permissions',
...requestMeta,
normalizedRequiredRoles,
normalizedUserRoles,
});

const hasRole = normalizedRequiredRoles.some((role) =>
normalizedUserRoles.includes(role),
);
if (hasRole) {
this.logger.log({
message: 'Access granted via role-based authorization',
...requestMeta,
normalizedRequiredRoles,
});
return true;
}

Expand All @@ -66,6 +118,13 @@ export class TokenRolesGuard implements CanActivate {
user,
)
) {
this.logger.log({
message:
'Access granted via submission list challenge fallback rule',
...requestMeta,
challengeId: request?.query?.challengeId,
userId: user?.userId,
});
return true;
}
}
Expand All @@ -75,7 +134,20 @@ export class TokenRolesGuard implements CanActivate {
const hasScope = requiredScopes.some((scope) =>
user.scopes ? user.scopes.includes(scope) : false,
);

this.logger.log({
message: 'Checking scope-based permissions',
...requestMeta,
requiredScopes,
tokenScopes: user.scopes,
hasScope,
});

if (hasScope) {
this.logger.log({
message: 'Access granted via scope-based authorization',
...requestMeta,
});
return true;
}
}
Expand All @@ -89,10 +161,22 @@ export class TokenRolesGuard implements CanActivate {
requiredRoles.length > 0 &&
requiredScopes.length === 0
) {
this.logger.warn({
message:
'M2M token detected without role permissions for role-only endpoint',
...requestMeta,
tokenScopes: user.scopes,
});
throw new ForbiddenException('M2M token not allowed for this endpoint');
}

// Access denied - neither roles nor scopes match
this.logger.warn({
message: 'Access denied due to insufficient permissions',
...requestMeta,
requiredRoles,
requiredScopes,
});
throw new ForbiddenException('Insufficient permissions');
} catch (error) {
if (
Expand All @@ -101,6 +185,15 @@ export class TokenRolesGuard implements CanActivate {
) {
throw error;
}
this.logger.error(
{
message: 'Unexpected error validating token roles',
...requestMeta,
error:
error instanceof Error ? error.message : String(error ?? 'error'),
},
error instanceof Error ? error.stack : undefined,
);
throw new UnauthorizedException('Invalid token');
}
}
Expand Down Expand Up @@ -192,4 +285,33 @@ export class TokenRolesGuard implements CanActivate {

return Array.from(normalizedRoles);
}

private buildRequestLogMeta(
request: any,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The buildRequestLogMeta method uses any for the request parameter. Consider using a more specific type to improve type safety and maintainability.

controllerName: string,
handlerName: string,
) {
const headers = (request?.headers || {}) as Record<
string,
string | string[] | undefined
>;
const correlationIdCandidate =
headers['x-request-id'] ||
headers['x-correlation-id'] ||
headers['x-trace-id'];
const method =
typeof request?.method === 'string'
? request.method.toUpperCase()
: undefined;

return {
controllerName,
handlerName,
method,
path: request?.originalUrl || request?.url || request?.path,
correlationId: Array.isArray(correlationIdCandidate)
? correlationIdCandidate[0]
: correlationIdCandidate,
};
}
}
Loading
Loading