mirror of
https://github.com/immich-app/immich.git
synced 2025-08-30 23:02:39 -04:00
feat: get metadata about the current api key (#21027)
This commit is contained in:
parent
a313e4338e
commit
e00556a34a
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@ -77,6 +77,7 @@ Class | Method | HTTP request | Description
|
||||
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
|
||||
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
|
||||
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys |
|
||||
*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me |
|
||||
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
|
||||
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
|
||||
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
|
||||
|
41
mobile/openapi/lib/api/api_keys_api.dart
generated
41
mobile/openapi/lib/api/api_keys_api.dart
generated
@ -213,6 +213,47 @@ class APIKeysApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response].
|
||||
Future<Response> getMyApiKeyWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/api-keys/me';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
Future<APIKeyResponseDto?> getMyApiKey() async {
|
||||
final response = await getMyApiKeyWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'APIKeyResponseDto',) as APIKeyResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint requires the `apiKey.update` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
@ -1488,6 +1488,38 @@
|
||||
"description": "This endpoint requires the `apiKey.create` permission."
|
||||
}
|
||||
},
|
||||
"/api-keys/me": {
|
||||
"get": {
|
||||
"operationId": "getMyApiKey",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIKeyResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"API Keys"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api-keys/{id}": {
|
||||
"delete": {
|
||||
"operationId": "deleteApiKey",
|
||||
|
@ -2027,6 +2027,14 @@ export function createApiKey({ apiKeyCreateDto }: {
|
||||
body: apiKeyCreateDto
|
||||
})));
|
||||
}
|
||||
export function getMyApiKey(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: ApiKeyResponseDto;
|
||||
}>("/api-keys/me", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `apiKey.delete` permission.
|
||||
*/
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { APIKeyController } from 'src/controllers/api-key.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(APIKeyController.name, () => {
|
||||
describe(ApiKeyController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(ApiKeyService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]);
|
||||
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
@ -33,6 +33,13 @@ describe(APIKeyController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/me`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
|
||||
|
@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('API Keys')
|
||||
@Controller('api-keys')
|
||||
export class APIKeyController {
|
||||
export class ApiKeyController {
|
||||
constructor(private service: ApiKeyService) {}
|
||||
|
||||
@Post()
|
||||
@ -24,6 +24,12 @@ export class APIKeyController {
|
||||
return this.service.getAll(auth);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Authenticated({ permission: false })
|
||||
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
|
||||
return this.service.getMine(auth);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.ApiKeyRead })
|
||||
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ActivityController } from 'src/controllers/activity.controller';
|
||||
import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { APIKeyController } from 'src/controllers/api-key.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
@ -34,7 +34,7 @@ import { UserController } from 'src/controllers/user.controller';
|
||||
import { ViewController } from 'src/controllers/view.controller';
|
||||
|
||||
export const controllers = [
|
||||
APIKeyController,
|
||||
ApiKeyController,
|
||||
ActivityController,
|
||||
AlbumController,
|
||||
AppController,
|
||||
|
@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js';
|
||||
|
||||
type AdminRoute = { admin?: true };
|
||||
type SharedLinkRoute = { sharedLink?: true };
|
||||
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
|
||||
type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
|
||||
|
||||
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
|
||||
const decorators: MethodDecorator[] = [
|
||||
@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat
|
||||
}
|
||||
|
||||
if (options?.permission) {
|
||||
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All));
|
||||
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
|
||||
}
|
||||
|
||||
if ((options as SharedLinkRoute)?.sharedLink) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
@ -134,6 +134,41 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMine', () => {
|
||||
it('should not work with a session token', async () => {
|
||||
const session = factory.session();
|
||||
const auth = factory.auth({ session });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.getMine(auth)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(mocks.apiKey.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const apiKey = factory.authApiKey();
|
||||
const auth = factory.auth({ apiKey });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.getMine(auth)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id);
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
await sut.getById(auth, apiKey.id);
|
||||
|
||||
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const auth = factory.auth();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ApiKey } from 'src/database';
|
||||
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@ -46,6 +46,19 @@ export class ApiKeyService extends BaseService {
|
||||
await this.apiKeyRepository.delete(auth.user.id, id);
|
||||
}
|
||||
|
||||
async getMine(auth: AuthDto): Promise<APIKeyResponseDto> {
|
||||
if (!auth.apiKey) {
|
||||
throw new ForbiddenException('Not authenticated with an API Key');
|
||||
}
|
||||
|
||||
const key = await this.apiKeyRepository.getById(auth.user.id, auth.apiKey.id);
|
||||
if (!key) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
}
|
||||
|
||||
return this.map(key);
|
||||
}
|
||||
|
||||
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
|
||||
const key = await this.apiKeyRepository.getById(auth.user.id, id);
|
||||
if (!key) {
|
||||
|
@ -518,6 +518,20 @@ describe(AuthService.name, () => {
|
||||
await expect(result).rejects.toThrow('Missing required permission: all');
|
||||
});
|
||||
|
||||
it('should not require any permission when metadata is set to `false`', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
|
||||
const result = sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: false },
|
||||
});
|
||||
await expect(result).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) });
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
||||
|
@ -48,7 +48,8 @@ export type ValidateRequest = {
|
||||
metadata: {
|
||||
sharedLinkRoute: boolean;
|
||||
adminRoute: boolean;
|
||||
permission?: Permission;
|
||||
/** `false` explicitly means no permission is required, which otherwise defaults to `all` */
|
||||
permission?: Permission | false;
|
||||
uri: string;
|
||||
};
|
||||
};
|
||||
@ -187,7 +188,11 @@ export class AuthService extends BaseService {
|
||||
throw new ForbiddenException('Forbidden');
|
||||
}
|
||||
|
||||
if (authDto.apiKey && !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })) {
|
||||
if (
|
||||
authDto.apiKey &&
|
||||
requestedPermission !== false &&
|
||||
!isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })
|
||||
) {
|
||||
throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user