Security Review Report — Admin Service
Reviewer: Security Agent Date: 2026-03-21 Branch: feat/admin-service Status: Complete — signed off
Review Results Summary
Заголовок раздела «Review Results Summary»| ID | Task | Status | Notes |
|---|---|---|---|
| SEC-001 | Endpoint guard coverage audit | PASS | All 15 endpoints verified |
| SEC-002 | JWT secret isolation | PASS | |
| SEC-003 | Admin cannot block admin | PASS | |
| SEC-004 | DTO validators audit | PASS (with note) | See description |
| SEC-005 | ValidationPipe config | PASS | |
| SEC-006 | Verification code never logged | PASS | |
| SEC-007 | Code @Length(6,6) | PASS | |
| SEC-008 | Domain allowlist exact match | PASS | |
| SEC-009 | No raw SQL | PASS | |
| SEC-010 | Customer JWT rejected by admin endpoints | PASS | |
| SEC-011 | Employee JWT rejected by admin endpoints | PASS | |
| SEC-012 | Blocked user token rejected (access) | PASS | |
| SEC-013 | isBlocked in refresh token strategies | PASS | SEC-F-002 resolved |
| SEC-014 | HighVerificationFailureRate alert | FINDING | SEC-F-003 |
| SEC-015 | npm audit | FINDING | SEC-F-004 |
Detailed Review Notes
Заголовок раздела «Detailed Review Notes»SEC-001: Endpoint Guard Coverage Audit — PASS
Заголовок раздела «SEC-001: Endpoint Guard Coverage Audit — PASS»All 15 endpoints verified across 5 controllers. Auth controllers served by auth-api app, project/user controllers served by admin-api app.
| # | Endpoint | Required Guard | Controller | Actual Guard | ParseUUIDPipe | Status |
|---|---|---|---|---|---|---|
| 1 | POST /admin/auth/register | None | AdminAuthController:14 | None | N/A | PASS |
| 2 | POST /admin/auth/register/verify | None | AdminAuthVerificationController:17 | None | N/A | PASS |
| 3 | POST /admin/auth/login | None | AdminAuthController:22 | None | N/A | PASS |
| 4 | POST /admin/auth/login/verify | None | AdminAuthVerificationController:37 | None | N/A | PASS |
| 5 | GET /admin/auth/refresh/token | AdminRefreshTokenGuard | AdminAuthRefreshTokenController:26-27 | @UseGuards(AdminRefreshTokenGuard) | N/A | PASS |
| 6 | GET /admin/projects | AdminAccessTokenGuard | AdminProjectController:30-31 | @UseGuards(AdminAccessTokenGuard) (controller-level, line 23) | N/A | PASS |
| 7 | GET /admin/projects/:id | AdminAccessTokenGuard | AdminProjectController:37-38 | Controller-level guard | ParseUUIDPipe | PASS |
| 8 | PATCH /admin/projects/:id | AdminAccessTokenGuard | AdminProjectController:44-46 | Controller-level guard | ParseUUIDPipe | PASS |
| 9 | DELETE /admin/projects/:id | AdminAccessTokenGuard | AdminProjectController:55-57 | Controller-level guard | ParseUUIDPipe | PASS |
| 10 | PATCH /admin/projects/:id/restore | AdminAccessTokenGuard | AdminProjectController:65-66 | Controller-level guard | ParseUUIDPipe | PASS |
| 11 | GET /admin/users | AdminAccessTokenGuard | AdminUserController:28-29 | @UseGuards(AdminAccessTokenGuard) (controller-level, line 21) | N/A | PASS |
| 12 | GET /admin/users/:id | AdminAccessTokenGuard | AdminUserController:42-43 | Controller-level guard | ParseUUIDPipe | PASS |
| 13 | PATCH /admin/users/:id/block | AdminAccessTokenGuard | AdminUserController:49-51 | Controller-level guard | ParseUUIDPipe | PASS |
| 14 | PATCH /admin/users/:id/unblock | AdminAccessTokenGuard | AdminUserController:59-60 | Controller-level guard | ParseUUIDPipe | PASS |
| 15 | GET /admin/users/me | AdminAccessTokenGuard | AdminUserController:35-36 | Controller-level guard | N/A (uses token payload) | PASS |
Key observations:
AdminProjectControllerandAdminUserControllerboth applyAdminAccessTokenGuardat the controller level via@UseGuards(AdminAccessTokenGuard), covering all their endpointsAdminAuthRefreshTokenControllerappliesAdminRefreshTokenGuardat the method level (line 26)- Auth endpoints (register, login, verify) are intentionally unguarded — by design
- All
:idpath params useParseUUIDPipe— prevents injection of arbitrary strings as record IDs GET /admin/users/meis correctly placed beforeGET /admin/users/:id(line 35 vs 42) — avoids route shadowing where “me” would be interpreted as a UUID
Note on auth-api ValidationPipe: The auth-api main.ts configures ValidationPipe with { transform: true, transformOptions: { strategy: 'excludeAll' } } but does NOT include whitelist: true or forbidNonWhitelisted: true. This means extra fields in auth endpoint request bodies are silently accepted (not stripped or rejected). However, since the DTOs only destructure known fields and the excludeAll serialization strategy prevents unknown fields from appearing in responses, the practical security impact is Low. The admin-api correctly has all three settings.
SEC-002: JWT Secret Isolation — PASS
Заголовок раздела «SEC-002: JWT Secret Isolation — PASS»Evidence:
AdminAccessTokenStrategy(admin-access-token.strategy.ts:21) usesjwtAdminConfigService.accessSecretKeywhich resolves to env varJWT_ADMIN_ACCESS_SECRET_KEY(jwt-admin.config.ts:9)AdminRefreshTokenStrategy(admin-refresh-token.strategy.ts:23) usesjwtAdminConfigService.refreshSecretKeywhich resolves to env varJWT_ADMIN_REFRESH_SECRET_KEY(jwt-admin.config.ts:11)- These are completely separate from customer (
JwtCustomerConfigService) and employee (JwtEmployeeConfigService) secrets - Joi validation schema (jwt-admin.validation.ts) requires both secrets as non-empty strings
- No cross-contamination detected
SEC-003: Admin Cannot Block Admin — PASS
Заголовок раздела «SEC-003: Admin Cannot Block Admin — PASS»Evidence:
AdminUserService.blockUser()(admin-user.service.ts:47-69) fetches user with roles (includeRoles=trueat line 48)- Lines 53-58: checks
userOnRolefor ADMIN role type and throwsForbiddenExceptionif target has ADMIN role - This runs at the service layer (defense in depth)
- Self-block case: if an admin tries to block themselves, they have ADMIN role, so the check catches it
- Logging at warn level when attempt is rejected (line 57)
SEC-004: DTO Validators Audit — PASS (with note)
Заголовок раздела «SEC-004: DTO Validators Audit — PASS (with note)»| DTO | Field | Expected | Actual | Status |
|---|---|---|---|---|
| AdminRegisterDto | @IsEmail, @Trim | @IsEmail, @Trim | PASS | |
| AdminLoginDto | @IsEmail, @Trim | @IsEmail, @Trim | PASS | |
| AdminRegisterVerifyDto | code | @Length(6,6) | @IsString, @Length(6,6) | PASS |
| AdminRegisterVerifyDto | @IsEmail | @IsEmail, @Trim | PASS | |
| AdminRegisterVerifyDto | fingerprint | @IsString | @IsString | PASS |
| AdminLoginVerifyDto | code | @Length(6,6) | @IsString, @Length(6,6) | PASS |
| AdminLoginVerifyDto | @IsEmail | @IsEmail, @Trim | PASS | |
| AdminLoginVerifyDto | fingerprint | @IsString | @IsString | PASS |
| AdminProjectUpdateDto | title | @MaxLength(255) | @MinLength(3), @MaxLength(255), @Trim | PASS |
| AdminProjectUpdateDto | description | @MaxLength | @MinLength(3), @Trim (no @MaxLength) | NOTE |
| AdminProjectUpdateDto | status | @IsEnum | @IsEnum(ProjectStatus) | PASS |
| AdminProjectUpdateDto | category | @IsEnum | @IsEnum(ProjectCategoryType) | PASS |
| AdminProjectQueryDto | page | @IsInt, @Min(1) | @IsInt, @Min(1), @Type(() => Number) | PASS |
| AdminProjectQueryDto | perPage | @IsInt, @Min(1), @Max(100) | @IsInt, @Min(1), @Max(100), @Type(() => Number) | PASS |
| AdminProjectQueryDto | search | @MaxLength(255) | @MaxLength(255), @Trim | PASS |
| AdminUserQueryDto | page | @IsInt, @Min(1) | @IsInt, @Min(1), @Type(() => Number) | PASS |
| AdminUserQueryDto | perPage | @IsInt, @Min(1), @Max(100) | @IsInt, @Min(1), @Max(100), @Type(() => Number) | PASS |
| AdminUserQueryDto | search | @MaxLength(255) | @MaxLength(255), @Trim | PASS |
Note: AdminProjectUpdateDto.description has no @MaxLength decorator. While Prisma’s TEXT type has no strict length limit, adding a reasonable max (e.g., 10000) would prevent abuse. This is Low severity — the database will handle it, but it could allow very large payloads.
Response DTOs:
AdminUserResponseDtodoes NOT include passwordHash or passwordSalt — PASSAdminUserDetailResponseDtoextends AdminUserResponseDto — PASSAdminProjectResponseDtoincludesdeletedAt(intentional for admin context) — PASSAdminAuthResponseDtoonly includes accessToken and refreshToken, no PII — PASS
SEC-005: ValidationPipe Config — PASS
Заголовок раздела «SEC-005: ValidationPipe Config — PASS»Evidence: admin-api/main.ts:33-38:
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true,})All three required options are present and correctly configured.
SEC-006: Verification Code Never Logged — PASS
Заголовок раздела «SEC-006: Verification Code Never Logged — PASS»Evidence: Grepped all files in admin-auth/src/ for logger calls containing code variables. No matches found.
AdminAuthService: logsemailanddomainbut nevercodeAdminAuthVerificationService.verifyCode(): logsemailandreasonon failure but never the stored or submitted code valuesendAdminVerificationCode(email, code)passes code to email service but does not log it
SEC-007: Code @Length(6,6) — PASS
Заголовок раздела «SEC-007: Code @Length(6,6) — PASS»Evidence:
AdminRegisterVerifyDto(admin-register-verify.dto.ts:13):@Length(6, 6)oncodefieldAdminLoginVerifyDto(admin-login-verify.dto.ts:13):@Length(6, 6)oncodefield- Uses
@Length(6, 6)— NOT@MinLength(6)which would allow longer inputs
SEC-008: Domain Allowlist Exact Match — PASS
Заголовок раздела «SEC-008: Domain Allowlist Exact Match — PASS»Evidence:
AdminAuthService.register()(admin-auth.service.ts:36-40):const domain = email.split('@')[1];if (!this.jwtAdminConfigService.allowedDomains.includes(domain))allowedDomainsis astring[]parsed from comma-separated env var (jwt-admin.config.ts:13-14)Array.prototype.includes()performs strict equality (===) comparison'evil.crewsforge.com' === 'crewsforge.com'isfalse— subdomain attack blocked- Same logic in
login()at line 76 - Domain comparison is case-insensitive: email is lowercased at line 35, domains are lowercased at config parse time (jwt-admin.config.ts:14:
.map((d) => d.trim().toLowerCase()))
SEC-009: No Raw SQL — PASS
Заголовок раздела «SEC-009: No Raw SQL — PASS»Evidence: Grepped entire codebase for $queryRaw and $executeRaw. Only match is in plan-security.md (the review plan itself). No raw SQL in any application code. All Prisma calls use the standard ORM API with parameterized queries.
SEC-010: Customer JWT Rejected by Admin Endpoints — PASS
Заголовок раздела «SEC-010: Customer JWT Rejected by Admin Endpoints — PASS»Evidence:
AdminAccessTokenStrategyregisters as'jwt-admin-access'(admin-auth.constants.ts:1)CustomerAccessTokenStrategyregisters as'CustomerAccessToken'(token.constants.ts:5)- These are different Passport strategy names, so a Customer JWT signed with the customer secret will fail validation against the admin secret
- Even if secrets were somehow the same, the strategy names ensure Passport routes to the correct strategy
SEC-011: Employee JWT Rejected by Admin Endpoints — PASS
Заголовок раздела «SEC-011: Employee JWT Rejected by Admin Endpoints — PASS»Evidence:
EmployeeAccessTokenStrategyregisters as'EmployeeAccessToken'(token.constants.ts:1)- Different from admin strategy name
'jwt-admin-access' - Different JWT secret (
JwtEmployeeConfigServicevsJwtAdminConfigService) - Employee tokens will be rejected with 401 at admin endpoints
SEC-012: Blocked User Token Rejected (Access Strategies) — PASS
Заголовок раздела «SEC-012: Blocked User Token Rejected (Access Strategies) — PASS»Evidence:
CustomerAccessTokenStrategy.validate()(customer-access-token.strategy.ts:32-34): checksuser.isBlocked, throwsUnauthorizedExceptionEmployeeAccessTokenStrategy.validate()(employee-access-token.strategy.ts:32-34): checksuser.isBlocked, throwsUnauthorizedExceptionAdminAccessTokenStrategy.validate()(admin-access-token.strategy.ts:31-34): checksuser.isBlocked, throwsUnauthorizedException- All three access token strategies correctly enforce isBlocked check
Findings
Заголовок раздела «Findings»FINDING: SEC-F-001 (Accepted Risk)
Заголовок раздела «FINDING: SEC-F-001 (Accepted Risk)»Severity: High
Category: Auth
Review task: SEC-014
Location: AdminAuthVerificationService — code verification endpoint
Description: The 6-digit numeric verification code (1,000,000 combinations, 900-second TTL) has no per-attempt rate limit beyond the resend cooldown. A brute-force attacker with access to the verification endpoint could attempt all combinations within the TTL window.
Impact: An attacker who can call the verify endpoint rapidly could guess the admin’s verification code and gain admin access. Requires the attacker to know a valid admin email address and be able to enumerate the verify endpoint.
Evidence:
04_data_flow.mdsection 1.2, 2.2: no attempt counter referenced11_open_questions.mdOQ-006: Decision was Option A (no additional rate limiter)- Risk R-008 in
11_open_questions.md
Recommendation: Accept for initial release. Mitigate via:
- Ensure
HighVerificationFailureRatealert is operational (see SEC-F-003) - Consider migrating to 8-digit codes or Redis attempt counter in a follow-up sprint
- Ensure ADMIN_ALLOWED_DOMAINS is restricted
Resolved: [x] Accepted as known risk Resolved by: OQ-006 architectural decision, Risk R-008 accepted
FINDING: SEC-F-002 (Resolved — High)
Заголовок раздела «FINDING: SEC-F-002 (Resolved — High)»Severity: High
Category: Auth
Review task: SEC-013
Location: CustomerRefreshTokenStrategy.validate() and EmployeeRefreshTokenStrategy.validate()
Description:
Neither CustomerRefreshTokenStrategy nor EmployeeRefreshTokenStrategy checks user.isBlocked in their validate() methods. Both strategies only verify the token exists in the database but do not look up the user or check their blocked status.
Impact: A blocked customer or employee can use their existing refresh token to obtain new access tokens, bypassing the block. While the access token strategies DO check isBlocked (preventing immediate API access), the refresh strategies allow a blocked user to continuously obtain new access tokens. This creates a race condition where blocked users may retain access until their current refresh token expires.
Evidence:
CustomerRefreshTokenStrategy.validate()(customer-refresh-token.strategy.ts:29-36): only checks token, returns payloadEmployeeRefreshTokenStrategy.validate()(employee-refresh-token.strategy.ts:29-36): only checks token, returns payloadAdminRefreshTokenStrategy.validate()(admin-refresh-token.strategy.ts:28-40): correctly checksuser.isBlocked— shows intended pattern- plan-security.md section 2.1 explicitly requires isBlocked check in all refresh token strategies
Recommendation:
Inject UserService into both CustomerRefreshTokenStrategy and EmployeeRefreshTokenStrategy and add isBlocked check matching the pattern in AdminRefreshTokenStrategy:
const user = await this.userService.findOneById(payload.userId);if (!user || user.isBlocked) { throw new UnauthorizedException('Account is blocked');}Resolved: [x] Yes — verified 2026-03-21 Resolved by: Developer fix — both strategies now inject UserService and check user.isBlocked (customer-refresh-token.strategy.ts:36-39, employee-refresh-token.strategy.ts:37-40)
FINDING: SEC-F-003 (Open — Medium)
Заголовок раздела «FINDING: SEC-F-003 (Open — Medium)»Severity: Medium Category: Auth Review task: SEC-014 Location: Application code — missing implementation
Description:
The admin_verification_failures Prometheus counter referenced in 09_metrics.md section 4 is not implemented in the application code. The HighVerificationFailureRate alert depends on this counter, but grepping the entire libs/ directory for admin_verification_failures returns zero matches in TypeScript files.
Impact:
The primary detection mechanism for verification code brute-force attacks (SEC-F-001 mitigation) is non-functional. Without this counter, the HighVerificationFailureRate alert will never fire, leaving brute-force attempts undetected.
Evidence:
- Grep for
admin_verification_failuresin libs/ returns 0 TypeScript file matches AdminAuthVerificationService.verifyCode()logs failures but does not increment any Prometheus counter09_metrics.mdsection 4 specifies:rate(admin_verification_failures[5m]) / rate(admin_verification_codes_sent[5m]) > 0.4
Recommendation:
Implement the admin_verification_failures counter in AdminAuthVerificationService.verifyCode() when code mismatch or expiration occurs. Also implement admin_verification_codes_sent counter in AdminAuthService.register() and login(). Both are required for the alert ratio to work.
Resolved: [ ] No Resolved by: Awaiting implementation
FINDING: SEC-F-004 (Open — Info)
Заголовок раздела «FINDING: SEC-F-004 (Open — Info)»Severity: Info Category: Dependency Review task: SEC-015 Location: package-lock.json / node_modules
Description:
npm audit reports 104 vulnerabilities: 87 high and 1 critical. Notable high-severity packages:
validator(<=13.15.20): URL validation bypass (GHSA-9965-vmph-33xx) + incomplete filtering (GHSA-vghf-hv5q-vc2g)svgo(3.0.0-3.3.2): DoS via entity expansion (GHSA-xpqw-6gx7-v673)webpack(5.49.0-5.104.0): SSRF via buildHttp (GHSA-8fgc-7cc6-rx7x, GHSA-38r7-794h-5758)socket.io-parser: unbounded binary attachments (GHSA-677m-j7p3-52f9)
All report fix available via npm audit fix.
Impact:
Most of these are in build/dev tooling (webpack, svgo) rather than production runtime. The validator package is noteworthy since it could affect URL validation if used. The socket.io-parser issue could affect WebSocket handling if socket.io is used in production.
Recommendation:
Run npm audit fix to resolve auto-fixable issues. Review validator and socket.io-parser packages to determine if they are production dependencies. These are likely pre-existing issues not introduced by the admin service.
Resolved: [ ] No Resolved by: Awaiting triage
FINDING: SEC-F-005 (Open — Medium)
Заголовок раздела «FINDING: SEC-F-005 (Open — Medium)»Severity: Medium
Category: Exposure
Review task: General review
Location: libs/apis/shared/src/lib/filters/prisma-exception.filter.ts
Description:
The PrismaExceptionFilter includes raw Prisma error messages in HTTP responses. For P2002 (unique constraint) errors, it returns exception.message and exception.meta which can contain table names, column names, and constraint names. For the fallback case (line 27-28), the raw message is also returned.
Impact: Internal database schema details (table names, column names, constraint names) are leaked to API consumers through error responses. This information disclosure aids attackers in understanding the database structure.
Evidence:
- Line 11:
const message = exception.message.replace(/\n/g, '');— strips newlines but retains full Prisma error text - Line 15: Returns
{ statusCode, message, meta: exception.meta }for P2002 - Line 28: Returns
{ statusCode, message, meta: exception.meta }for other errors - P2025 (line 20-23) is handled better with a generic “Record not found” fallback
Recommendation:
Replace raw exception.message with generic messages for each error code:
- P2002:
"Unique constraint violation"(or"Resource already exists") - Default:
"Bad request"or"Database error"Removemetafrom all responses. Log the full error internally at error level.
Note: This is a pre-existing issue, not introduced by the admin service, but affects admin endpoints.
Resolved: [ ] No Resolved by: Awaiting fix
Additional Observations
Заголовок раздела «Additional Observations»Soft-delete filtering — PASS
Заголовок раздела «Soft-delete filtering — PASS»- Customer-facing
ProjectRepository(project.repository.ts) appliesdeletedAt: nullinfindUnique(line 24),findMany(line 31), andfindManyAndCount(line 36) - Admin
AdminProjectRepositoryappliesdeletedAt: nullinfindAll(line 24) andfindOneById(line 42) findOneByIdIncludingDeleted(line 47-52) intentionally omits the filter — correctly named and used for restore operations
Error handling safety — PASS (with note on PrismaExceptionFilter above)
Заголовок раздела «Error handling safety — PASS (with note on PrismaExceptionFilter above)»- No empty
catch(e) {}blocks found in admin service code - Business exceptions use appropriate HTTP status codes
UUID path params — PASS
Заголовок раздела «UUID path params — PASS»- All
:idpath params useParseUUIDPipe(verified in SEC-001 audit above)
Sign-off Status
Заголовок раздела «Sign-off Status»- SEC-001 completed — all 15 endpoints verified
- SEC-002 through SEC-012 completed
- SEC-013 completed — SEC-F-002 filed and RESOLVED (verified 2026-03-21)
- SEC-014 completed — SEC-F-003 filed (MEDIUM, open — metrics counter not implemented)
- SEC-015 completed — SEC-F-004 filed (INFO, open — pre-existing npm audit issues)
- SEC-F-001 documented as accepted risk
- SEC-F-005 filed for PrismaExceptionFilter data leakage (MEDIUM, pre-existing)
- No Critical findings open
- No High findings open (SEC-F-001 accepted, SEC-F-002 resolved)
- Medium findings documented with mitigations (SEC-F-003, SEC-F-005)
SIGNED OFF — 2026-03-21