diff --git a/packages/api-gateway/src/middleware/api_key.middleware.ts b/packages/api-gateway/src/middleware/api_key.middleware.ts index ca649728..8be0cd15 100644 --- a/packages/api-gateway/src/middleware/api_key.middleware.ts +++ b/packages/api-gateway/src/middleware/api_key.middleware.ts @@ -1,5 +1,6 @@ import { Injectable, + Logger, NestMiddleware, Inject, OnModuleInit, @@ -20,6 +21,7 @@ const { JWT_SERVICE_NAME } = JwtProto; @Injectable() export class ApiKeyMiddleware implements NestMiddleware, OnModuleInit { + private readonly logger = new Logger(ApiKeyMiddleware.name); private apiKeyService: ApiKeyProto.ApiKeyServiceClient; private jwtService: JwtProto.JwtServiceClient; @@ -58,19 +60,7 @@ export class ApiKeyMiddleware implements NestMiddleware, OnModuleInit { }); const res = await lastValueFrom(apiKeyValidation); req.apiKey = res.key; - - const userJwt = req.headers['x-user-jwt'] as string | undefined; - if (userJwt) { - try { - const userJwtValidation = this.jwtService.validateUserJwt({ - jwt: userJwt, - }); - const userJwtRes = await lastValueFrom(userJwtValidation); - if (userJwtRes.valid && userJwtRes.user) { - req.user = userJwtRes.user; - } - } catch (error) {} - } + await this.resolveUserFromJwt(req); next(); } catch (error) { @@ -85,18 +75,7 @@ export class ApiKeyMiddleware implements NestMiddleware, OnModuleInit { req.apiKey = jwtRes.apiKey; - const userJwt = req.headers['x-user-jwt'] as string | undefined; - if (userJwt) { - try { - const userJwtValidation = this.jwtService.validateUserJwt({ - jwt: userJwt, - }); - const userJwtRes = await lastValueFrom(userJwtValidation); - if (userJwtRes.valid && userJwtRes.user) { - req.user = userJwtRes.user; - } - } catch (error) {} - } + await this.resolveUserFromJwt(req); next(); } catch (error) { @@ -105,6 +84,24 @@ export class ApiKeyMiddleware implements NestMiddleware, OnModuleInit { } } + private async resolveUserFromJwt(req: ApiKeyReq): Promise { + const userJwt = req.headers['x-user-jwt'] as string | undefined; + if (!userJwt) return; + + try { + const result = await lastValueFrom( + this.jwtService.validateUserJwt({ jwt: userJwt }), + ); + if (result.valid && result.user) { + req.user = result.user; + } + } catch (error) { + this.logger.warn( + `x-user-jwt validation failed: ${(error as Error).message}`, + ); + } + } + private extractTokenFromHeader(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; diff --git a/packages/api-gateway/src/models/auth.dto.ts b/packages/api-gateway/src/models/auth.dto.ts index 7f2d2d26..6b9be669 100644 --- a/packages/api-gateway/src/models/auth.dto.ts +++ b/packages/api-gateway/src/models/auth.dto.ts @@ -1,26 +1,124 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNotEmpty } from 'class-validator'; -import { ApiKeyProto, IdentifierProto, JwtProto } from 'juno-proto'; +import { + ApiKeyProto, + AuthCommonProto, + IdentifierProto, + JwtProto, +} from 'juno-proto'; export class IssueApiKeyRequest { - @ApiProperty({ description: 'Optional description for key' }) + @ApiPropertyOptional({ + description: 'Optional description for key', + example: 'Production API key for mobile app', + }) description?: string | undefined; - @ApiProperty({ description: 'Environment the key will be tied to' }) + @ApiProperty({ + description: 'Environment the key will be tied to', + example: 'production', + }) @IsNotEmpty() environment: string; @IsNotEmpty() - @ApiProperty({ description: 'Project identifier' }) + @ApiProperty({ + description: 'Project identifier', + example: { name: 'my-project' }, + }) project: IdentifierProto.ProjectIdentifier; } export class IssueApiKeyResponse { - @ApiProperty({ type: 'string', description: 'The generated API key' }) + @ApiProperty({ + description: + 'The generated API key value (store immediately, not retrievable again)', + example: 'a1b2c3d4e5f6...', + }) apiKey: string; + @ApiProperty({ + description: 'Environment this key was issued for', + example: 'production', + }) + environment: string; + + @ApiProperty({ + description: 'Description provided at creation', + example: 'Production API key for mobile app', + }) + description: string; + + @ApiProperty({ + description: 'ISO timestamp of key creation', + example: '2026-01-01T00:00:00.000Z', + }) + createdAt: string; + + @ApiProperty({ description: 'project identifier for the API key' }) + project: string; + constructor(res: ApiKeyProto.IssueApiKeyResponse) { this.apiKey = res.apiKey; + this.environment = res.info?.environment; + this.description = res.info?.description; + this.createdAt = res.info?.createdAt; + this.project = res.info.project.id.toString(); + } +} + +export class ApiKey { + @ApiProperty({ + description: "The API key's ID in the databse", + example: '5', + }) + id: string; + + @ApiProperty({ + description: + 'The generated API key value (store immediately, not retrievable again)', + example: 'a1b2c3d4e5f6...', + }) + hash: string; + + @ApiProperty({ + description: 'Description provided at creation', + example: 'Production API key for mobile app', + }) + description: string; + + @ApiProperty({ + description: 'Scopes tied to this API key', + example: ['read:projects', 'write:analytics'], + type: [String], + }) + scopes: string[]; + + @ApiProperty({ description: 'project identifier for the API key' }) + project: string; + + @ApiProperty({ + description: 'Environment this key was issued for', + example: 'production', + }) + environment: string; + + @ApiProperty({ + description: 'ISO timestamp of key creation', + example: '2026-01-01T00:00:00.000Z', + }) + createdAt: string; + + constructor(res: AuthCommonProto.ApiKey) { + this.id = res.id; + this.hash = res.hash; + this.description = res.description; + this.scopes = res.scopes + ? res.scopes.map((scope) => scope.toString()) + : ['']; + this.environment = res.environment; + this.project = res.project.id.toString(); + this.createdAt = res.createdAt; } } @@ -32,3 +130,60 @@ export class IssueJWTResponse { this.token = res.jwt; } } + +export class PaginationParams { + @ApiProperty({ + description: 'first page of results', + example: '/auth/key/?offset=0&limit=5', + }) + first: string; + @ApiProperty({ + description: 'previous page of results', + example: '/auth/key/?offset=15&limit=5', + }) + prev: string; + @ApiProperty({ + description: 'next page of results', + example: '/auth/key/?offset=20&limit=5', + }) + next: string; + @ApiProperty({ + description: 'last page of results', + example: '/auth/key/?offset=25&limit=5', + }) + last: string; + + constructor(res: { + first: string; + prev: string; + next: string; + last: string; + }) { + this.first = res.first; + this.prev = res.prev; + this.next = res.next; + this.last = res.last; + } +} +export class GetAllApiKeysResponse { + @ApiProperty({ + type: ApiKey, + isArray: true, + description: 'List of API keys belonging to a project', + }) + keys: ApiKey[]; + + @ApiProperty({ + type: PaginationParams, + description: 'Pagination parameters', + }) + links: PaginationParams; + + constructor(res: { + keys: AuthCommonProto.ApiKey[]; + links: PaginationParams; + }) { + this.keys = (res.keys ?? []).map((key) => new ApiKey(key)); + this.links = res.links; + } +} diff --git a/packages/api-gateway/src/modules/auth/auth.controller.ts b/packages/api-gateway/src/modules/auth/auth.controller.ts index d6878fd8..0aed91af 100644 --- a/packages/api-gateway/src/modules/auth/auth.controller.ts +++ b/packages/api-gateway/src/modules/auth/auth.controller.ts @@ -1,15 +1,19 @@ import { Body, Controller, + DefaultValuePipe, Delete, Headers, + HttpCode, HttpException, HttpStatus, Inject, OnModuleInit, Param, + ParseIntPipe, Post, Get, + Query, UnauthorizedException, } from '@nestjs/common'; import { ClientGrpc } from '@nestjs/microservices'; @@ -22,17 +26,21 @@ import { ApiResponse, ApiBody, ApiParam, + ApiOkResponse, + ApiQuery, } from '@nestjs/swagger'; import { ApiKeyProto, + AuthCommonProto, CommonProto, JwtProto, ProjectProto, UserProto, } from 'juno-proto'; -import { lastValueFrom } from 'rxjs'; +import { lastValueFrom, Observable } from 'rxjs'; import { User } from 'src/decorators/user.decorator'; import { + GetAllApiKeysResponse, IssueApiKeyRequest, IssueApiKeyResponse, IssueJWTResponse, @@ -118,7 +126,7 @@ export class AuthController implements OnModuleInit { @ApiOperation({ summary: 'Generates a temporary JWT tied to a specified user.', description: - 'JSON Web Tokens are used for the vast majority of API-gateway calls. The Juno SDK provides the means of automatically authenticating through this route given valid user credentials.', + 'Generates a user identity token that can be used to authenticate admin and management endpoints in place of email/password credentials.', }) @ApiCreatedResponse({ description: 'Successfully created a JWT.', @@ -162,18 +170,26 @@ export class AuthController implements OnModuleInit { description: 'The API Key has been successfully created', type: IssueApiKeyResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Invalid User Credentials', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Bad request', + }) @ApiHeader({ name: 'X-User-Email', - description: 'Email of an admin or superadmin user', - required: true, + description: 'Email of the user', + required: false, schema: { type: 'string', }, }) @ApiHeader({ name: 'X-User-Password', - description: 'Password of the admin or superadmin user', - required: true, + description: 'Password of the user', + required: false, schema: { type: 'string', }, @@ -184,6 +200,11 @@ export class AuthController implements OnModuleInit { @User() user: CommonProto.User, @Body() issueApiKeyRequest: IssueApiKeyRequest, ) { + if (!user) { + throw new UnauthorizedException( + "You must provide the user's email/password or use an ID token", + ); + } const linked = await userLinkedToProject({ project: issueApiKeyRequest.project, user, @@ -204,44 +225,208 @@ export class AuthController implements OnModuleInit { } @ApiOperation({ - summary: 'Deletes an API key, detaching it from its project.', + summary: 'Revokes an API key, detaching it from its project.', }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: 'Invalid API Key', + description: 'Invalid API Key or insufficient permissions', }) @ApiResponse({ - status: HttpStatus.OK, - description: 'Successful API Key deletion', + status: HttpStatus.NO_CONTENT, + description: 'Successful API Key revocation', }) @ApiHeader({ - name: 'Authorization', - description: 'A valid API key', - required: true, + name: 'X-User-Email', + description: 'Email of the user', + required: false, schema: { type: 'string', }, }) + @ApiHeader({ + name: 'X-User-Password', + description: 'Password of the user', + required: false, + schema: { + type: 'string', + }, + }) + @ApiHeader({ + name: 'x-user-jwt', + description: "The user's ID token", + required: false, + schema: { + type: 'string', + }, + }) + @ApiParam({ + name: 'id', + required: true, + description: 'ID of the API key to delete', + type: String, + }) @ApiBearerAuth('API_Key') - @Delete('/key') - async deleteApiKey(@Headers('Authorization') apiKey?: string) { - const key = apiKey?.replace('Bearer ', ''); - if (key === undefined) { - throw new UnauthorizedException('API Key is required'); + @HttpCode(HttpStatus.NO_CONTENT) + @Delete('/key/:id') + async deleteApiKeyById( + @User() user: CommonProto.User, + @Param('id') idStr: string, + ) { + const id = +idStr; + if (Number.isNaN(id)) { + throw new HttpException('Invalid API Key ID', HttpStatus.BAD_REQUEST); } - const response = await lastValueFrom( - this.apiKeyService.revokeApiKey({ - apiKey: key, - }), - ); - if (!response.success) { - throw new HttpException('API Key revoke failed', 500); + if (!user) { + throw new UnauthorizedException( + "You must provide the user's email/password or use an ID token", + ); } + const response = await lastValueFrom(this.apiKeyService.getApiKey({ id })); + + if (!response.key) { + throw new HttpException('API Key not found', HttpStatus.NOT_FOUND); + } + + if (!response.key.project) { + throw new HttpException( + 'API Key has no associated project', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const projectId = response.key.project.id; + const linked = await userLinkedToProject({ + project: { id: projectId }, + user, + projectClient: this.projectService, + }); + + if (!linked || user.type == CommonProto.UserType.USER) { + throw new UnauthorizedException( + 'Only Superadmins & Linked Admins can delete API Keys', + ); + } + const deleteResponse = await lastValueFrom( + this.apiKeyService.deleteApiKey({ id }), + ); + if (!deleteResponse.success) { + throw new HttpException( + 'API Key deletion failed', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } return; } + @ApiOperation({ + summary: 'Lists all API keys', + }) + @ApiOkResponse({ + description: 'Paginated list of all API keys successfully returned', + type: GetAllApiKeysResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Invalid API Key or insufficient permissions', + }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Number of records to skip', + example: 0, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum records to return (default 10)', + example: 10, + }) + @ApiHeader({ + name: 'X-User-Email', + description: 'Email of the user', + required: false, + schema: { + type: 'string', + }, + }) + @ApiHeader({ + name: 'X-User-Password', + description: 'Password of the user', + required: false, + schema: { + type: 'string', + }, + }) + @Get('key/all') + async getAllApiKeys( + @User() user: CommonProto.User, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + if (limit <= 0) { + throw new HttpException( + 'limit must be a positive integer', + HttpStatus.BAD_REQUEST, + ); + } + + if (!user) { + throw new UnauthorizedException('User ID token is required'); + } + + let obs: Observable; + if (user.type == CommonProto.UserType.SUPERADMIN) { + // superadmins can list all keys — pass empty projects to skip filtering + obs = this.apiKeyService.getAllApiKeys({ + offset, + limit, + projects: [], + }); + } else if (user.type == CommonProto.UserType.ADMIN && user.projectIds) { + // regular users can only list keys for projects which they are an admin for + obs = this.apiKeyService.getAllApiKeys({ + offset, + limit, + projects: user.projectIds.map((projId) => ({ + id: projId, + })), + }); + } else { + return new GetAllApiKeysResponse({ + keys: [], + links: { + first: encodeURI(`/auth/key/all?offset=0&limit=0`), + prev: encodeURI(`/auth/key/all?offset=0&limit=0`), + next: encodeURI(`/auth/key/all?offset=0&limit=0`), + last: encodeURI(`/auth/key/all?offset=0&limit=0`), + }, + }); + } + + const data: { keys: AuthCommonProto.ApiKey[]; count: number } = + await lastValueFrom(obs); + + const lastOffset = + data.count > 0 ? Math.floor((data.count - 1) / limit) * limit : 0; + const nextOffset = Math.min(lastOffset, offset + limit); + const links = { + first: encodeURI(`/auth/key/all?offset=${0}&limit=${limit}`), + prev: encodeURI( + `/auth/key/all?offset=${Math.max(offset - limit, 0)}&limit=${limit}`, + ), + next: encodeURI(`/auth/key/all?offset=${nextOffset}&limit=${limit}`), + last: encodeURI(`/auth/key/all?offset=${lastOffset}&limit=${limit}`), + }; + return new GetAllApiKeysResponse({ + keys: data.keys, + links, + }); + } + @Get('/test-auth') @ApiOperation({ summary: 'Validates user JWT and returns user data', diff --git a/packages/api-gateway/src/modules/auth/auth.module.ts b/packages/api-gateway/src/modules/auth/auth.module.ts index acec11b8..ec06f926 100644 --- a/packages/api-gateway/src/modules/auth/auth.module.ts +++ b/packages/api-gateway/src/modules/auth/auth.module.ts @@ -89,11 +89,13 @@ export class AuthModule implements NestModule { consumer .apply(CredentialsMiddleware) .forRoutes( - { path: 'auth/key', method: RequestMethod.POST }, { path: 'auth/user/jwt', method: RequestMethod.POST }, { path: 'auth/test-auth', method: RequestMethod.GET }, { path: 'auth/account-request', method: RequestMethod.GET }, { path: 'auth/account-request/:id', method: RequestMethod.DELETE }, + { path: 'auth/key', method: RequestMethod.POST }, + { path: 'auth/key/all', method: RequestMethod.GET }, + { path: 'auth/key/:id', method: RequestMethod.DELETE }, ); } } diff --git a/packages/api-gateway/src/modules/project/project.controller.ts b/packages/api-gateway/src/modules/project/project.controller.ts index d4505dc3..e640cb52 100644 --- a/packages/api-gateway/src/modules/project/project.controller.ts +++ b/packages/api-gateway/src/modules/project/project.controller.ts @@ -13,14 +13,6 @@ import { Put, } from '@nestjs/common'; import { ClientGrpc } from '@nestjs/microservices'; -import { lastValueFrom } from 'rxjs'; -import { - CreateProjectModel, - LinkUserModel, - ProjectResponse, - ProjectResponses, -} from 'src/models/project.dto'; -import { AuthCommonProto, CommonProto, ProjectProto } from 'juno-proto'; import { ApiBearerAuth, ApiHeader, @@ -29,10 +21,17 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { User } from 'src/decorators/user.decorator'; +import { AuthCommonProto, CommonProto, ProjectProto } from 'juno-proto'; +import { lastValueFrom } from 'rxjs'; import { ApiKey } from 'src/decorators/api_key.decorator'; +import { User } from 'src/decorators/user.decorator'; +import { + CreateProjectModel, + LinkUserModel, + ProjectResponse, + ProjectResponses, +} from 'src/models/project.dto'; import { UserResponses } from 'src/models/user.dto'; - const { PROJECT_SERVICE_NAME } = ProjectProto; @ApiBearerAuth('API_Key') @@ -40,6 +39,7 @@ const { PROJECT_SERVICE_NAME } = ProjectProto; @Controller('project') export class ProjectController implements OnModuleInit { private projectService: ProjectProto.ProjectServiceClient; + constructor( @Inject(PROJECT_SERVICE_NAME) private projectClient: ClientGrpc, ) {} diff --git a/packages/api-gateway/test/auth.e2e-spec.ts b/packages/api-gateway/test/auth.e2e-spec.ts index c5c2492d..b1de76f1 100644 --- a/packages/api-gateway/test/auth.e2e-spec.ts +++ b/packages/api-gateway/test/auth.e2e-spec.ts @@ -18,6 +18,21 @@ let app: INestApplication; const ADMIN_EMAIL = 'test-superadmin@test.com'; const ADMIN_PASSWORD = 'test-password'; +/** + * Obtain a user JWT by authenticating with email/password at the login endpoint. + */ +async function getJwtForUser( + userEmail: string, + userPassword: string, +): Promise { + const resp = await request(app.getHttpServer()) + .post('/auth/user/jwt') + .set('X-User-Email', userEmail) + .set('X-User-Password', userPassword) + .send(); + return resp.body['token']; +} + beforeAll(async () => { const proto = ProtoLoader.loadSync([ResetProtoFile]) as any; @@ -56,11 +71,10 @@ beforeEach(async () => { }); describe('Auth Key Verification Routes', () => { - it('Invalid email parameter when getting auth key', () => { + it('Invalid user JWT when creating auth key', async () => { return request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', 'invalid-email@gmail.com') - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', 'Bearer invalid.jwt.token') .send({ environment: 'prod', project: { @@ -70,11 +84,9 @@ describe('Auth Key Verification Routes', () => { .expect(401); }); - it('Invalid password parameter when generating auth key', () => { + it('Missing user JWT when creating auth key', async () => { return request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', 'invalid-password') .send({ environment: 'prod', project: { @@ -84,11 +96,11 @@ describe('Auth Key Verification Routes', () => { .expect(401); }); - it('Different environment parameter to /auth/key', () => { + it('Different environment parameter to /auth/key', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); return request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .send({ environment: 'staging', project: { @@ -98,11 +110,11 @@ describe('Auth Key Verification Routes', () => { .expect(201); }); - it('Invalid project name when generating auth key', () => { + it('Invalid project name when generating auth key', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); return request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .send({ environment: 'prod', project: { @@ -138,10 +150,10 @@ describe('API Key JWT Verification Routes', () => { }); it('Expired JWT api key', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); const key = await request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .send({ environment: 'prod', project: { @@ -166,10 +178,10 @@ describe('API Key JWT Verification Routes', () => { }); it('Expected valid request for auth key and jwt generation', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); const key = await request(app.getHttpServer()) .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .send({ environment: 'prod', project: { @@ -243,6 +255,214 @@ describe('User JWT Verification Routes', () => { }); }); +describe('List API Keys - GET /auth/key/all', () => { + it('should list API keys for a project as a superadmin', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + + // First create an API key for the project + await request(app.getHttpServer()) + .post('/auth/key') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + environment: 'prod', + project: { name: 'test-seed-project' }, + description: 'list-test-key', + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get('/auth/key/all') + .set('Authorization', `Bearer ${adminJwt}`) + .expect(200); + + expect(response.body.keys).toBeDefined(); + expect(Array.isArray(response.body.keys)).toBe(true); + expect(response.body.keys.length).toBeGreaterThanOrEqual(1); + }); + + it('should reject unauthenticated requests', () => { + return request(app.getHttpServer()).get('/auth/key/all').expect(401); + }); + + it('should not list any API keys for a regular USER not linked to any projects', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create a regular user + await request(app.getHttpServer()) + .post('/user') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + email: 'regularuser-listkeys@example.com', + password: 'userpass', + name: 'Regular User', + }) + .expect(201); + + const regularUserJwt = await getJwtForUser( + 'regularuser-listkeys@example.com', + 'userpass', + ); + + await request(app.getHttpServer()) + .post('/auth/key') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + environment: 'prod', + project: { name: 'test-seed-project' }, + description: 'list-test-key', + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get('/auth/key/all') + .set('Authorization', `Bearer ${regularUserJwt}`) + .expect(200); + + expect(response.body.keys).toBeDefined(); + expect(Array.isArray(response.body.keys)).toBe(true); + expect(response.body.keys.length).toEqual(0); + }); + + it('should allow a linked ADMIN to list API keys for their project', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create a project + const projectResp = await request(app.getHttpServer()) + .post('/project') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ name: 'admin-linked-project' }) + .expect(201); + + const projectId = projectResp.body.id; + + // Create a user (defaults to USER type) + const userResp = await request(app.getHttpServer()) + .post('/user') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + email: 'linked-admin@example.com', + password: 'adminpass', + name: 'Linked Admin', + }) + .expect(201); + + const userId = userResp.body.id; + + // Promote user to ADMIN + await request(app.getHttpServer()) + .post('/user/type') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + id: userId, + type: 'ADMIN', + }) + .expect(201); + + // Link the admin to the project + await request(app.getHttpServer()) + .put(`/user/id/${userId}/project`) + .set('Authorization', `Bearer ${adminJwt}`) + .send({ name: 'admin-linked-project' }) + .expect(200); + + // Create an API key for the project + const key = await request(app.getHttpServer()) + .post('/auth/key') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + environment: 'prod', + project: { id: projectId }, + description: 'admin-linked-key', + }) + .expect(201); + + expect(key.body['apiKey']).toBeDefined(); + + // The linked admin should be able to list keys + const response = await request(app.getHttpServer()) + .get(`/auth/key/all`) + .set('Authorization', `Bearer ${adminJwt}`) + .expect(200); + + expect(response.body.keys).toBeDefined(); + expect(Array.isArray(response.body.keys)).toBe(true); + expect(response.body.keys.length).toBeGreaterThanOrEqual(1); + }); + + it('should support offset and limit query parameters', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + // Create multiple API keys + for (let i = 0; i < 3; i++) { + await request(app.getHttpServer()) + .post('/auth/key') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + environment: 'prod', + project: { name: 'test-seed-project' }, + description: `pagination-key-${i}`, + }) + .expect(201); + } + + const response = await request(app.getHttpServer()) + .get('/auth/key/all?offset=0&limit=2') + .set('Authorization', `Bearer ${adminJwt}`) + .expect(200); + + expect(response.body.keys).toBeDefined(); + expect(response.body.keys.length).toBeLessThanOrEqual(2); + }); +}); + +describe('Delete API Key by ID - DELETE /auth/key/:id', () => { + it('should delete an existing API key by its ID', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create an API key + const createResp = await request(app.getHttpServer()) + .post('/auth/key') + .set('Authorization', `Bearer ${adminJwt}`) + .send({ + environment: 'prod', + project: { name: 'test-seed-project' }, + description: 'delete-by-id-test', + }) + .expect(201); + expect(createResp.body.apiKey).toBeDefined(); + + // List keys to find the created key's ID + const listResp = await request(app.getHttpServer()) + .get('/auth/key/all') + .set('Authorization', `Bearer ${adminJwt}`) + .expect(200); + + const createdKey = listResp.body.keys.find( + (k: any) => k.description === 'delete-by-id-test', + ); + expect(createdKey).toBeDefined(); + const keyId = createdKey.id; + + // Delete the key + await request(app.getHttpServer()) + .delete(`/auth/key/${keyId}`) + .set('Authorization', `Bearer ${adminJwt}`) + .expect(204); + }); + + it('should fail for non-existent API key IDs', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + + await request(app.getHttpServer()) + .delete(`/auth/key/999`) + .set('Authorization', `Bearer ${adminJwt}`) + .expect(404); + }); + + it('should not be accessible without authentication', () => { + return request(app.getHttpServer()).delete('/auth/key/0').expect(401); + }); +}); + describe('Account Request - POST /auth/account-request', () => { it('should create a new account request with valid data', async () => { const response = await request(app.getHttpServer()) @@ -324,10 +544,10 @@ describe('Account Request - GET /auth/account-request', () => { }) .expect(201); + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); const response = await request(app.getHttpServer()) .get('/auth/account-request') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .expect(200); expect(response.body.requests).toBeDefined(); @@ -345,10 +565,11 @@ describe('Account Request - GET /auth/account-request', () => { }); it('should reject requests from a regular USER', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); + await request(app.getHttpServer()) .post('/user') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .send({ password: 'userpass', name: 'Regular User', @@ -356,10 +577,14 @@ describe('Account Request - GET /auth/account-request', () => { }) .expect(201); + const regularUserJwt = await getJwtForUser( + 'regularuser-getall@example.com', + 'userpass', + ); + return request(app.getHttpServer()) .get('/auth/account-request') - .set('X-User-Email', 'regularuser-getall@example.com') - .set('X-User-Password', 'userpass') + .set('Authorization', `Bearer ${regularUserJwt}`) .expect(401); }); }); @@ -377,11 +602,11 @@ describe('Account Request - DELETE /auth/account-request/:id', () => { .expect(201); const id = createResp.body.id; + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); const deleteResp = await request(app.getHttpServer()) .delete(`/auth/account-request/${id}`) - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .expect(200); expect(deleteResp.body.id).toBe(id); @@ -389,8 +614,7 @@ describe('Account Request - DELETE /auth/account-request/:id', () => { const allResp = await request(app.getHttpServer()) .get('/auth/account-request') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .expect(200); const ids = allResp.body.requests.map((r: any) => r.id); @@ -403,19 +627,19 @@ describe('Account Request - DELETE /auth/account-request/:id', () => { .expect(401); }); - it('should return 400 for non-numeric id', () => { + it('should return 400 for non-numeric id', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); return request(app.getHttpServer()) .delete('/auth/account-request/abc') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .expect(400); }); - it('should return error for non-existent id', () => { + it('should return error for non-existent id', async () => { + const adminJwt = await getJwtForUser(ADMIN_EMAIL, ADMIN_PASSWORD); return request(app.getHttpServer()) .delete('/auth/account-request/999999') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) + .set('Authorization', `Bearer ${adminJwt}`) .expect(404); }); }); diff --git a/packages/api-gateway/test/project.e2e-spec.ts b/packages/api-gateway/test/project.e2e-spec.ts index abafe218..06d968d5 100644 --- a/packages/api-gateway/test/project.e2e-spec.ts +++ b/packages/api-gateway/test/project.e2e-spec.ts @@ -480,60 +480,6 @@ describe('Project Deletion Routes', () => { }); }); }); - -describe('Project API Key Routes', () => { - it('Create an API key for a project with valid inputs', async () => { - const resp = await request(app.getHttpServer()) - .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) - .send({ - environment: 'prod', - project: { - name: 'test-seed-project', - }, - }) - .expect(201); - expect(resp.body['apiKey']).toBeDefined(); - }); - - it('Deletes an API key for a project with valid inputs', async () => { - const resp = await request(app.getHttpServer()) - .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', ADMIN_PASSWORD) - .send({ - environment: 'prod', - project: { - name: 'test-seed-project', - }, - }) - .expect(201); - - expect(resp.body['apiKey']).toBeDefined(); - - await request(app.getHttpServer()) - .delete('/auth/key') - .set('Authorization', `Bearer ${resp.body['apiKey']}`) - .send() - .expect(200); - }); - - it('Create an API Key for a project with invalid user/pass', async () => { - await request(app.getHttpServer()) - .post('/auth/key') - .set('X-User-Email', ADMIN_EMAIL) - .set('X-User-Password', 'not-a-pass') - .send({ - environment: 'prod', - project: { - name: 'test-seed-project', - }, - }) - .expect(401); - }); -}); - describe('Project Linking Middleware', () => { it('No authorization headers for /project/id/:id/user route ', async () => { const project = await request(app.getHttpServer()) diff --git a/packages/auth-service/src/modules/api_key/api_key.controller.ts b/packages/auth-service/src/modules/api_key/api_key.controller.ts index b7f02281..67095695 100644 --- a/packages/auth-service/src/modules/api_key/api_key.controller.ts +++ b/packages/auth-service/src/modules/api_key/api_key.controller.ts @@ -2,8 +2,9 @@ import { Controller, Inject } from '@nestjs/common'; import { ClientGrpc, RpcException } from '@nestjs/microservices'; import { ApiKeyProto, AuthCommonProto, UserProto } from 'juno-proto'; import { lastValueFrom } from 'rxjs'; -import { createHash, randomBytes } from 'crypto'; +import { randomBytes } from 'crypto'; import { status } from '@grpc/grpc-js'; +import { hashApiKey } from './api_key.utils'; @Controller('API_Key') @ApiKeyProto.ApiKeyServiceControllerMethods() @@ -17,12 +18,22 @@ export class ApiKeyController implements ApiKeyProto.ApiKeyServiceController { @Inject(UserProto.USER_AUTH_SERVICE_NAME) private userAuthClient: ClientGrpc, ) {} + + onModuleInit() { + this.apiKeyDbService = + this.apiKeyClient.getService( + ApiKeyProto.API_KEY_DB_SERVICE_NAME, + ); + this.userAuthService = + this.userAuthClient.getService( + UserProto.USER_AUTH_SERVICE_NAME, + ); + } + async validateApiKey( request: ApiKeyProto.ValidateApiKeyRequest, ): Promise { - const apiKeyHash = createHash('sha256') - .update(request.apiKey) - .digest('hex'); + const apiKeyHash = hashApiKey(request.apiKey); const apiKey = await lastValueFrom( this.apiKeyDbService.getApiKey({ hash: apiKeyHash, @@ -42,52 +53,78 @@ export class ApiKeyController implements ApiKeyProto.ApiKeyServiceController { }; } - onModuleInit() { - this.apiKeyDbService = - this.apiKeyClient.getService( - ApiKeyProto.API_KEY_DB_SERVICE_NAME, - ); - this.userAuthService = - this.userAuthClient.getService( - UserProto.USER_AUTH_SERVICE_NAME, - ); + async getApiKey( + request: ApiKeyProto.GetApiKeyRequest, + ): Promise { + const apiKey = await lastValueFrom(this.apiKeyDbService.getApiKey(request)); + return { key: apiKey }; } async issueApiKey( request: ApiKeyProto.IssueApiKeyRequest, ): Promise { const rawApiKey = randomBytes(32).toString('hex'); - const apiKeyHash = createHash('sha256').update(rawApiKey).digest('hex'); - const key = this.apiKeyDbService.createApiKey({ - apiKey: { - hash: apiKeyHash, - description: request.description, - scopes: [AuthCommonProto.ApiScope.FULL], - project: request.project, - environment: request.environment, - }, - }); - if (!key) { + const apiKeyHash = hashApiKey(rawApiKey); + + const info = await lastValueFrom( + this.apiKeyDbService.createApiKey({ + apiKey: { + hash: apiKeyHash, + description: request.description, + scopes: [AuthCommonProto.ApiScope.FULL], + project: request.project, + environment: request.environment, + createdAt: new Date().toISOString(), + }, + }), + ); + + if (!info) { throw new RpcException({ code: status.FAILED_PRECONDITION, message: 'Failed to create API Key', }); } + return { apiKey: rawApiKey, - info: await lastValueFrom(key), + info, }; } + + async getAllApiKeys( + request: ApiKeyProto.GetAllApiKeysRequest, + ): Promise { + const result = await lastValueFrom( + this.apiKeyDbService.getAllApiKeys({ + offset: request.offset ?? 0, + limit: request.limit ?? undefined, + projects: request.projects ?? [], + }), + ); + const keys = result.keys ?? []; + + return { + keys: keys.map((key) => ({ + ...key, + scopes: key.scopes ?? [], + })), + count: result.count, + }; + } + + async deleteApiKey( + request: ApiKeyProto.DeleteApiKeyRequest, + ): Promise { + await lastValueFrom(this.apiKeyDbService.deleteApiKey({ id: request.id })); + return { success: true }; + } + async revokeApiKey( request: ApiKeyProto.RevokeApiKeyRequest, ): Promise { - const hash = createHash('sha256').update(request.apiKey).digest('hex'); - const key = this.apiKeyDbService.deleteApiKey({ - hash, - }); - if (!key) { - return { success: false }; - } + const hash = hashApiKey(request.apiKey); + await lastValueFrom(this.apiKeyDbService.deleteApiKey({ hash })); return { success: true }; } } diff --git a/packages/auth-service/src/modules/api_key/api_key.utils.ts b/packages/auth-service/src/modules/api_key/api_key.utils.ts new file mode 100644 index 00000000..6fcfcdcf --- /dev/null +++ b/packages/auth-service/src/modules/api_key/api_key.utils.ts @@ -0,0 +1,5 @@ +import { createHash } from 'crypto'; + +export function hashApiKey(rawKey: string): string { + return createHash('sha256').update(rawKey).digest('hex'); +} diff --git a/packages/db-service/prisma/migrations/20260309131239_api_key_creation_timestamp/migration.sql b/packages/db-service/prisma/migrations/20260309131239_api_key_creation_timestamp/migration.sql new file mode 100644 index 00000000..08a51b26 --- /dev/null +++ b/packages/db-service/prisma/migrations/20260309131239_api_key_creation_timestamp/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[email]` on the table `NewAccountRequest` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE UNIQUE INDEX "NewAccountRequest_email_key" ON "NewAccountRequest"("email"); diff --git a/packages/db-service/prisma/schema.prisma b/packages/db-service/prisma/schema.prisma index da4db5cc..ada0428b 100644 --- a/packages/db-service/prisma/schema.prisma +++ b/packages/db-service/prisma/schema.prisma @@ -38,6 +38,7 @@ model ApiKey { projectId Int project Project @relation(fields: [projectId], references: [id]) environment String + createdAt DateTime @default(now()) } enum ApiScope { diff --git a/packages/db-service/src/modules/auth/auth.controller.ts b/packages/db-service/src/modules/auth/auth.controller.ts index b3d8b5ab..4c3fc324 100644 --- a/packages/db-service/src/modules/auth/auth.controller.ts +++ b/packages/db-service/src/modules/auth/auth.controller.ts @@ -2,9 +2,10 @@ import { Controller } from '@nestjs/common'; import { AuthService } from './auth.service'; import { ApiKeyProto, AuthCommonProto, UserProto } from 'juno-proto'; import { ApiKeyIdentifier } from 'juno-proto/dist/gen/identifiers'; -import { validateApiKeydentifier } from 'src/utility/validate'; +import { validateApiKeyIdentifier } from 'src/utility/validate'; import * as bcrypt from 'bcrypt'; import { mapPrismaRoleToRPC, mapRPCRoleToPrisma } from 'src/utility/convert'; +import { Prisma } from '@prisma/client'; @Controller() @ApiKeyProto.ApiKeyDbServiceControllerMethods() @@ -16,8 +17,35 @@ export class ApiKeyDbController { constructor(private readonly apiKeyService: AuthService) {} - getApiKey(request: ApiKeyIdentifier): Promise { - return this.apiKeyService.findApiKey(validateApiKeydentifier(request)); + async getApiKey(request: ApiKeyIdentifier): Promise { + const apiKey = await this.apiKeyService.findApiKey( + validateApiKeyIdentifier(request), + ); + return apiKey; + } + + async getAllApiKeys( + request: ApiKeyProto.GetAllApiKeysParams, + ): Promise { + const whereClause: Prisma.ApiKeyWhereInput | undefined = + request.projects && request.projects.length > 0 + ? { + OR: request.projects.map((proj) => + proj.id != null + ? { projectId: Number(proj.id) } + : { project: { name: proj.name } }, + ), + } + : undefined; + + const keys = await this.apiKeyService.apiKeys( + request.offset, + request.limit, + undefined, + whereClause, + ); + + return keys; } async createApiKey( @@ -43,7 +71,7 @@ export class ApiKeyDbController async deleteApiKey( request: ApiKeyIdentifier, ): Promise { - return this.apiKeyService.deleteApiKey(validateApiKeydentifier(request)); + return this.apiKeyService.deleteApiKey(validateApiKeyIdentifier(request)); } async createAccountRequest( diff --git a/packages/db-service/src/modules/auth/auth.service.ts b/packages/db-service/src/modules/auth/auth.service.ts index 07986441..6cc77c52 100644 --- a/packages/db-service/src/modules/auth/auth.service.ts +++ b/packages/db-service/src/modules/auth/auth.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { RpcException } from '@nestjs/microservices'; import { ApiKey, NewAccountRequest, Prisma } from '@prisma/client'; import { AuthCommonProto } from 'juno-proto'; +import { ApiKeyProto } from 'juno-proto'; import { PrismaService } from 'src/prisma.service'; @Injectable() @@ -15,15 +16,32 @@ export class AuthService { cursor?: Prisma.ApiKeyWhereUniqueInput, where?: Prisma.ApiKeyWhereInput, orderBy?: Prisma.ApiKeyOrderByWithRelationInput, - ): Promise { - const apiKeys = await this.prisma.apiKey.findMany({ - skip, - take, - cursor, - where, - orderBy, - }); - return apiKeys.map((key) => convertDbApiKeyToTs(key)); + ): Promise { + try { + const [apiKeys, count] = await this.prisma.$transaction([ + this.prisma.apiKey.findMany({ + skip, + take, + cursor, + where, + orderBy: orderBy ?? { id: 'asc' }, + include: { project: true }, + }), + this.prisma.apiKey.count({ where }), + ]); + + const apiKeyObjs = apiKeys.map((key) => convertDbApiKeyToTs(key)); + + return { + keys: apiKeyObjs, + count: count, + }; + } catch (e) { + throw new RpcException({ + code: status.INTERNAL, + message: `Failed to retrieve API keys: ${e.message}`, + }); + } } async apiKey( @@ -58,8 +76,9 @@ export class AuthService { try { let projectId: number | undefined = undefined; if (input.project.connect.id) { - if (Number.isInteger(input.project.connect.id)) { - projectId = Number(input.project.connect.id); + const numId = Number(input.project.connect.id); + if (Number.isInteger(numId)) { + projectId = numId; } } else if (input.project.connect.name) { const name = input.project.connect.name.toString(); @@ -109,6 +128,7 @@ export class AuthService { ): Promise { const key = await this.prisma.apiKey.delete({ where: lookup, + include: { project: true }, }); return convertDbApiKeyToTs(key); } @@ -138,7 +158,10 @@ export class AuthService { return this.prisma.newAccountRequest.delete({ where: { id } }); } } -const convertDbApiKeyToTs = (key: ApiKey): AuthCommonProto.ApiKey => { + +const convertDbApiKeyToTs = ( + key: ApiKey & { project?: { id: number; name: string } }, +): AuthCommonProto.ApiKey => { const mappedScopes = key.scopes.map((scope) => { switch (scope) { case 'FULL': @@ -156,8 +179,12 @@ const convertDbApiKeyToTs = (key: ApiKey): AuthCommonProto.ApiKey => { hash: key.hash, scopes: mappedScopes, description: key.description, - project: { id: key.projectId }, + project: key.project + ? { id: key.project.id, name: key.project.name } + : { id: key.projectId }, environment: key.environment, + createdAt: key.createdAt.toISOString(), }; + return apiKey; }; diff --git a/packages/db-service/src/utility/validate.ts b/packages/db-service/src/utility/validate.ts index fd31a555..4101bc22 100644 --- a/packages/db-service/src/utility/validate.ts +++ b/packages/db-service/src/utility/validate.ts @@ -87,7 +87,7 @@ export function validateEmailSenderIdentifier( }; } -export function validateApiKeydentifier( +export function validateApiKeyIdentifier( identifier: IdentifierProto.ApiKeyIdentifier, ): Prisma.ApiKeyWhereUniqueInput { if (identifier.id && identifier.hash) { diff --git a/packages/db-service/test/auth.e2e-spec.ts b/packages/db-service/test/auth.e2e-spec.ts index f7808621..cdad1c76 100644 --- a/packages/db-service/test/auth.e2e-spec.ts +++ b/packages/db-service/test/auth.e2e-spec.ts @@ -154,6 +154,166 @@ describe('DB Service API Key Tests', () => { }); }); +describe('DB Service getAllApiKeys filtering by projectId', () => { + let apiKeyClient: any; + let projectClient: any; + + beforeEach(() => { + const proto = ProtoLoader.loadSync([ + ApiKeyProtoFile, + ProjectProtoFile, + IdentifiersProtoFile, + ]) as any; + + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + apiKeyClient = new protoGRPC.juno.api_key.ApiKeyDbService( + process.env.DB_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + projectClient = new protoGRPC.juno.project.ProjectService( + process.env.DB_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + }); + + it('only returns API keys belonging to the requested project when filtering by ID', async () => { + // Create two projects + const projectA: any = await new Promise((resolve, reject) => { + projectClient.createProject( + { name: 'filter-project-a' }, + (err: any, resp: any) => { + if (err) reject(err); + else resolve(resp); + }, + ); + }); + + const projectB: any = await new Promise((resolve, reject) => { + projectClient.createProject( + { name: 'filter-project-b' }, + (err: any, resp: any) => { + if (err) reject(err); + else resolve(resp); + }, + ); + }); + + // Create API keys for project A + await new Promise((resolve, reject) => { + apiKeyClient.createApiKey( + { + apiKey: { + hash: 'projecta-key-1-hash', + description: 'project-a-key-1', + scopes: [0], + project: { name: 'filter-project-a' }, + environment: 'dev', + }, + }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + await new Promise((resolve, reject) => { + apiKeyClient.createApiKey( + { + apiKey: { + hash: 'projecta-key-2-hash', + description: 'project-a-key-2', + scopes: [0], + project: { name: 'filter-project-a' }, + environment: 'prod', + }, + }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + // Create an API key for project B + await new Promise((resolve, reject) => { + apiKeyClient.createApiKey( + { + apiKey: { + hash: 'projectb-key-1-hash', + description: 'project-b-key-1', + scopes: [0], + project: { name: 'filter-project-b' }, + environment: 'dev', + }, + }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + // Query getAllApiKeys for project A by ID + const resultA: any = await new Promise((resolve, reject) => { + apiKeyClient.getAllApiKeys( + { projects: [{ id: Number(projectA.id) }], offset: 0, limit: 100 }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + const keysA = resultA.keys || []; + expect(keysA.length).toBe(2); + for (const key of keysA) { + expect(Number(key.project.id)).toBe(Number(projectA.id)); + } + + // Query getAllApiKeys for project B by ID + const resultB: any = await new Promise((resolve, reject) => { + apiKeyClient.getAllApiKeys( + { projects: [{ id: Number(projectB.id) }], offset: 0, limit: 100 }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + const keysB = resultB.keys || []; + expect(keysB.length).toBe(1); + expect(Number(keysB[0].project.id)).toBe(Number(projectB.id)); + expect(keysB[0].description).toBe('project-b-key-1'); + }); + + it('returns an empty list when project has no API keys', async () => { + const project: any = await new Promise((resolve, reject) => { + projectClient.createProject( + { name: 'empty-project' }, + (err: any, resp: any) => { + if (err) reject(err); + else resolve(resp); + }, + ); + }); + + const result: any = await new Promise((resolve, reject) => { + apiKeyClient.getAllApiKeys( + { projects: [{ id: Number(project.id) }], offset: 0, limit: 100 }, + (err, res) => { + if (err) reject(err); + else resolve(res); + }, + ); + }); + + const keys = result.keys || []; + expect(keys.length).toBe(0); + }); +}); + describe('DB Service Account Request Tests', () => { let accountRequestClient: any; diff --git a/packages/proto/definitions/api_key.proto b/packages/proto/definitions/api_key.proto index a259d2be..90de3f95 100644 --- a/packages/proto/definitions/api_key.proto +++ b/packages/proto/definitions/api_key.proto @@ -7,18 +7,28 @@ import "auth_common.proto"; service ApiKeyService { rpc issueApiKey(IssueApiKeyRequest) returns (IssueApiKeyResponse); - rpc revokeApiKey(RevokeApiKeyRequest) returns (RevokeApiKeyResponse); - rpc validateApiKey(ValidateApiKeyRequest) returns (ValidateApiKeyResponse); + rpc getApiKey(GetApiKeyRequest) returns (GetApiKeyResponse); + rpc getAllApiKeys(GetAllApiKeysRequest) returns (GetAllApiKeysResponse); + rpc deleteApiKey(DeleteApiKeyRequest) returns (DeleteApiKeyResponse); } service ApiKeyDbService { rpc createApiKey(CreateApiKeyParams) returns (auth_common.ApiKey); rpc getApiKey(identifiers.ApiKeyIdentifier) returns (auth_common.ApiKey); + rpc getAllApiKeys(GetAllApiKeysParams) returns (GetAllApiKeysResult); rpc deleteApiKey(identifiers.ApiKeyIdentifier) returns (auth_common.ApiKey); } +message GetApiKeyRequest { + int32 id = 1; +} + +message GetApiKeyResponse { + auth_common.ApiKey key = 1; +} + message IssueApiKeyRequest { identifiers.ProjectIdentifier project = 1; string description = 2; @@ -31,6 +41,28 @@ message ValidateApiKeyResponse { auth_common.ApiKey key = 2; } +message GetAllApiKeysRequest { + repeated identifiers.ProjectIdentifier projects = 1; + optional int32 offset = 2; + optional int32 limit = 3; +} + +message GetAllApiKeysParams { + repeated identifiers.ProjectIdentifier projects = 1; + int32 offset = 2; + int32 limit = 3; +} + +message GetAllApiKeysResult { + repeated auth_common.ApiKey keys = 1; + int32 count = 2; +} + +message GetAllApiKeysResponse { + repeated auth_common.ApiKey keys = 1; + int32 count = 2; +} + message CreateApiKeyParams { ApiKeyNoId apiKey = 1; } message ApiKeyNoId { @@ -39,6 +71,7 @@ message ApiKeyNoId { repeated auth_common.ApiScope scopes = 3; identifiers.ProjectIdentifier project = 4; string environment = 5; + string created_at = 6; } message IssueApiKeyResponse { @@ -48,3 +81,6 @@ message IssueApiKeyResponse { message RevokeApiKeyRequest { string apiKey = 1; } message RevokeApiKeyResponse { bool success = 1; } + +message DeleteApiKeyRequest { int32 id = 1; } +message DeleteApiKeyResponse { bool success = 1; } diff --git a/packages/proto/definitions/auth_common.proto b/packages/proto/definitions/auth_common.proto index 3b3e8af3..3d41191c 100644 --- a/packages/proto/definitions/auth_common.proto +++ b/packages/proto/definitions/auth_common.proto @@ -15,4 +15,5 @@ message ApiKey { repeated ApiScope scopes = 4; identifiers.ProjectIdentifier project = 5; string environment = 6; + string created_at = 7; } diff --git a/packages/proto/src/gen/analytics.ts b/packages/proto/src/gen/analytics.ts index 56f22d9d..e36db2f3 100644 --- a/packages/proto/src/gen/analytics.ts +++ b/packages/proto/src/gen/analytics.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: analytics.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/analytics_config.ts b/packages/proto/src/gen/analytics_config.ts index 8749c236..168d3ff4 100644 --- a/packages/proto/src/gen/analytics_config.ts +++ b/packages/proto/src/gen/analytics_config.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: analytics_config.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/api_key.ts b/packages/proto/src/gen/api_key.ts index fb054601..e3d7e7a1 100644 --- a/packages/proto/src/gen/api_key.ts +++ b/packages/proto/src/gen/api_key.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: api_key.proto /* eslint-disable */ @@ -12,6 +12,14 @@ import { ApiKeyIdentifier, ProjectIdentifier } from './identifiers'; export const protobufPackage = 'juno.api_key'; +export interface GetApiKeyRequest { + id: number; +} + +export interface GetApiKeyResponse { + key: ApiKey | undefined; +} + export interface IssueApiKeyRequest { project: ProjectIdentifier | undefined; description: string; @@ -27,6 +35,28 @@ export interface ValidateApiKeyResponse { key: ApiKey | undefined; } +export interface GetAllApiKeysRequest { + projects: ProjectIdentifier[]; + offset?: number | undefined; + limit?: number | undefined; +} + +export interface GetAllApiKeysParams { + projects: ProjectIdentifier[]; + offset: number; + limit: number; +} + +export interface GetAllApiKeysResult { + keys: ApiKey[]; + count: number; +} + +export interface GetAllApiKeysResponse { + keys: ApiKey[]; + count: number; +} + export interface CreateApiKeyParams { apiKey: ApiKeyNoId | undefined; } @@ -37,6 +67,7 @@ export interface ApiKeyNoId { scopes: ApiScope[]; project: ProjectIdentifier | undefined; environment: string; + createdAt: string; } export interface IssueApiKeyResponse { @@ -52,6 +83,14 @@ export interface RevokeApiKeyResponse { success: boolean; } +export interface DeleteApiKeyRequest { + id: number; +} + +export interface DeleteApiKeyResponse { + success: boolean; +} + export const JUNO_API_KEY_PACKAGE_NAME = 'juno.api_key'; export interface ApiKeyServiceClient { @@ -62,6 +101,14 @@ export interface ApiKeyServiceClient { validateApiKey( request: ValidateApiKeyRequest, ): Observable; + + getApiKey(request: GetApiKeyRequest): Observable; + + getAllApiKeys( + request: GetAllApiKeysRequest, + ): Observable; + + deleteApiKey(request: DeleteApiKeyRequest): Observable; } export interface ApiKeyServiceController { @@ -85,6 +132,27 @@ export interface ApiKeyServiceController { | Promise | Observable | ValidateApiKeyResponse; + + getApiKey( + request: GetApiKeyRequest, + ): + | Promise + | Observable + | GetApiKeyResponse; + + getAllApiKeys( + request: GetAllApiKeysRequest, + ): + | Promise + | Observable + | GetAllApiKeysResponse; + + deleteApiKey( + request: DeleteApiKeyRequest, + ): + | Promise + | Observable + | DeleteApiKeyResponse; } export function ApiKeyServiceControllerMethods() { @@ -93,6 +161,9 @@ export function ApiKeyServiceControllerMethods() { 'issueApiKey', 'revokeApiKey', 'validateApiKey', + 'getApiKey', + 'getAllApiKeys', + 'deleteApiKey', ]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor( @@ -127,6 +198,8 @@ export interface ApiKeyDbServiceClient { getApiKey(request: ApiKeyIdentifier): Observable; + getAllApiKeys(request: GetAllApiKeysParams): Observable; + deleteApiKey(request: ApiKeyIdentifier): Observable; } @@ -139,6 +212,13 @@ export interface ApiKeyDbServiceController { request: ApiKeyIdentifier, ): Promise | Observable | ApiKey; + getAllApiKeys( + request: GetAllApiKeysParams, + ): + | Promise + | Observable + | GetAllApiKeysResult; + deleteApiKey( request: ApiKeyIdentifier, ): Promise | Observable | ApiKey; @@ -146,7 +226,12 @@ export interface ApiKeyDbServiceController { export function ApiKeyDbServiceControllerMethods() { return function (constructor: Function) { - const grpcMethods: string[] = ['createApiKey', 'getApiKey', 'deleteApiKey']; + const grpcMethods: string[] = [ + 'createApiKey', + 'getApiKey', + 'getAllApiKeys', + 'deleteApiKey', + ]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor( constructor.prototype, diff --git a/packages/proto/src/gen/auth_common.ts b/packages/proto/src/gen/auth_common.ts index 9814a8cf..412d9f81 100644 --- a/packages/proto/src/gen/auth_common.ts +++ b/packages/proto/src/gen/auth_common.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: auth_common.proto /* eslint-disable */ @@ -21,6 +21,7 @@ export interface ApiKey { scopes: ApiScope[]; project: ProjectIdentifier | undefined; environment: string; + createdAt: string; } export const JUNO_AUTH_COMMON_PACKAGE_NAME = 'juno.auth_common'; diff --git a/packages/proto/src/gen/common.ts b/packages/proto/src/gen/common.ts index d06e0cd2..d7b4aeff 100644 --- a/packages/proto/src/gen/common.ts +++ b/packages/proto/src/gen/common.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: common.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/email.ts b/packages/proto/src/gen/email.ts index 42a7ce08..f7347925 100644 --- a/packages/proto/src/gen/email.ts +++ b/packages/proto/src/gen/email.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: email.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file.ts b/packages/proto/src/gen/file.ts index b9c15314..a6469f52 100644 --- a/packages/proto/src/gen/file.ts +++ b/packages/proto/src/gen/file.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: file.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_bucket.ts b/packages/proto/src/gen/file_bucket.ts index 0cac4da5..5abcddb9 100644 --- a/packages/proto/src/gen/file_bucket.ts +++ b/packages/proto/src/gen/file_bucket.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: file_bucket.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_config.ts b/packages/proto/src/gen/file_config.ts index 124697f9..e277d32b 100644 --- a/packages/proto/src/gen/file_config.ts +++ b/packages/proto/src/gen/file_config.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: file_config.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_provider.ts b/packages/proto/src/gen/file_provider.ts index d8b86cde..a7811cad 100644 --- a/packages/proto/src/gen/file_provider.ts +++ b/packages/proto/src/gen/file_provider.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: file_provider.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/health.ts b/packages/proto/src/gen/health.ts index 25852e7c..b9272ebc 100644 --- a/packages/proto/src/gen/health.ts +++ b/packages/proto/src/gen/health.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: health.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/identifiers.ts b/packages/proto/src/gen/identifiers.ts index 9121d1f3..245265a0 100644 --- a/packages/proto/src/gen/identifiers.ts +++ b/packages/proto/src/gen/identifiers.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: identifiers.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/jwt.ts b/packages/proto/src/gen/jwt.ts index c694671f..24e19bc6 100644 --- a/packages/proto/src/gen/jwt.ts +++ b/packages/proto/src/gen/jwt.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: jwt.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/logging.ts b/packages/proto/src/gen/logging.ts index 862e970d..d659ae09 100644 --- a/packages/proto/src/gen/logging.ts +++ b/packages/proto/src/gen/logging.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: logging.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/project.ts b/packages/proto/src/gen/project.ts index 8a6c1ee4..fd79b4ed 100644 --- a/packages/proto/src/gen/project.ts +++ b/packages/proto/src/gen/project.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: project.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/reset_db.ts b/packages/proto/src/gen/reset_db.ts index c85aff85..0b7b2359 100644 --- a/packages/proto/src/gen/reset_db.ts +++ b/packages/proto/src/gen/reset_db.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: reset_db.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/user.ts b/packages/proto/src/gen/user.ts index a85d17a1..9838490a 100644 --- a/packages/proto/src/gen/user.ts +++ b/packages/proto/src/gen/user.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.3 +// protoc v5.29.3 // source: user.proto /* eslint-disable */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b252c89e..a53801c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7613,7 +7613,7 @@ packages: integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==, } engines: { node: '>=6.4.0 <13 || >=14' } - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@9.0.2: resolution: @@ -7707,7 +7707,7 @@ packages: integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==, } engines: { node: '>=10' } - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.10: resolution: diff --git a/scripts/doc-extract-macos.sh b/scripts/doc-extract-macos.sh new file mode 100755 index 00000000..5854f378 --- /dev/null +++ b/scripts/doc-extract-macos.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +# A utility script for autogenerating the OpenAPI SDK code. +# +# Swagger OpenAPI documentation has a super neat way of automatically generating SDK code from an API page. Unfortunately, this process has been fairly manual and tedious--this script automates the vast majority of it. +# +# Keep in mind that this script is ONLY for if you need to update the SDK without releasing Juno. Upon a new release, the Juno-SDK repository has a GitHub action for fetching the latest SDK code. This is more for the "oh no, I forgot I needed that feature in juno for this part of the sdk to work." +# +# Make sure you have Juno running before running this script, preferably with start:dev:up-all. +# + +# The api-gateway is exposed on port 8888 in docker-compose-dev.yml +# extract gateway information from running juno instance +gateway_container_id=$(docker ps -q --filter "ancestor=juno-api-gateway") +gateway_container_port=$(docker port $gateway_container_id) + +gateway_public_port=$(echo "$gateway_container_port" | grep -o "0.0.0.0:[0-9]*" | cut -d':' -f2) # MACOS version + +# grab doc yaml +curl "localhost:$gateway_public_port/docs-yaml" > docs-yaml + +# run gen client sdk script +pnpm gen-client-sdk + +echo "Successfully generated SDK code. You can find it under .openapi-generator." diff --git a/scripts/doc-extract.sh b/scripts/doc-extract-window.sh similarity index 86% rename from scripts/doc-extract.sh rename to scripts/doc-extract-window.sh index 8c49b79e..e5f07457 100755 --- a/scripts/doc-extract.sh +++ b/scripts/doc-extract-window.sh @@ -1,15 +1,16 @@ #!/bin/sh # A utility script for autogenerating the OpenAPI SDK code. -# +# # Swagger OpenAPI documentation has a super neat way of automatically generating SDK code from an API page. Unfortunately, this process has been fairly manual and tedious--this script automates the vast majority of it. # # Keep in mind that this script is ONLY for if you need to update the SDK without releasing Juno. Upon a new release, the Juno-SDK repository has a GitHub action for fetching the latest SDK code. This is more for the "oh no, I forgot I needed that feature in juno for this part of the sdk to work." # -# Make sure you have Juno running before running this script, preferably with start:dev:up-all. +# Make sure you have Juno running before running this script, preferably with start:dev:up-all. # -# extract gateway information from running juno instance +# The api-gateway is exposed on port 8888 in docker-compose-dev.yml +# Extract gateway information from running juno instance gateway_container_id=$(docker ps -q --filter "ancestor=juno-api-gateway") gateway_container_port=$(docker port $gateway_container_id) @@ -19,7 +20,7 @@ gateway_public_port=$(echo "$gateway_container_port" | grep -oP "0.0.0.0:\K\d+") # grab doc yaml curl "localhost:$gateway_public_port/docs-yaml" > docs-yaml -# run gen client sdk script +# run gen client sdk script pnpm gen-client-sdk echo "Successfully generated SDK code. You can find it under .openapi-generator."