mirror of
https://github.com/immich-app/immich.git
synced 2025-11-03 02:57:10 -05: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* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
|
||||||
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
|
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
|
||||||
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys |
|
*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} |
|
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
|
||||||
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
|
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
|
||||||
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
|
*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;
|
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.
|
/// This endpoint requires the `apiKey.update` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
|||||||
@ -1488,6 +1488,38 @@
|
|||||||
"description": "This endpoint requires the `apiKey.create` permission."
|
"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}": {
|
"/api-keys/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "deleteApiKey",
|
"operationId": "deleteApiKey",
|
||||||
|
|||||||
@ -2027,6 +2027,14 @@ export function createApiKey({ apiKeyCreateDto }: {
|
|||||||
body: 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.
|
* 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 { Permission } from 'src/enum';
|
||||||
import { ApiKeyService } from 'src/services/api-key.service';
|
import { ApiKeyService } from 'src/services/api-key.service';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
describe(APIKeyController.name, () => {
|
describe(ApiKeyController.name, () => {
|
||||||
let ctx: ControllerContext;
|
let ctx: ControllerContext;
|
||||||
const service = mockBaseService(ApiKeyService);
|
const service = mockBaseService(ApiKeyService);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]);
|
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
|
||||||
return () => ctx.close();
|
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', () => {
|
describe('GET /api-keys/:id', () => {
|
||||||
it('should be an authenticated route', async () => {
|
it('should be an authenticated route', async () => {
|
||||||
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
|
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
|
|||||||
|
|
||||||
@ApiTags('API Keys')
|
@ApiTags('API Keys')
|
||||||
@Controller('api-keys')
|
@Controller('api-keys')
|
||||||
export class APIKeyController {
|
export class ApiKeyController {
|
||||||
constructor(private service: ApiKeyService) {}
|
constructor(private service: ApiKeyService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -24,6 +24,12 @@ export class APIKeyController {
|
|||||||
return this.service.getAll(auth);
|
return this.service.getAll(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@Authenticated({ permission: false })
|
||||||
|
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
|
||||||
|
return this.service.getMine(auth);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Authenticated({ permission: Permission.ApiKeyRead })
|
@Authenticated({ permission: Permission.ApiKeyRead })
|
||||||
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
|
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ActivityController } from 'src/controllers/activity.controller';
|
import { ActivityController } from 'src/controllers/activity.controller';
|
||||||
import { AlbumController } from 'src/controllers/album.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 { AppController } from 'src/controllers/app.controller';
|
||||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||||
import { AssetController } from 'src/controllers/asset.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';
|
import { ViewController } from 'src/controllers/view.controller';
|
||||||
|
|
||||||
export const controllers = [
|
export const controllers = [
|
||||||
APIKeyController,
|
ApiKeyController,
|
||||||
ActivityController,
|
ActivityController,
|
||||||
AlbumController,
|
AlbumController,
|
||||||
AppController,
|
AppController,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js';
|
|||||||
|
|
||||||
type AdminRoute = { admin?: true };
|
type AdminRoute = { admin?: true };
|
||||||
type SharedLinkRoute = { sharedLink?: true };
|
type SharedLinkRoute = { sharedLink?: true };
|
||||||
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
|
type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
|
||||||
|
|
||||||
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
|
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
|
||||||
const decorators: MethodDecorator[] = [
|
const decorators: MethodDecorator[] = [
|
||||||
@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.permission) {
|
if (options?.permission) {
|
||||||
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All));
|
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((options as SharedLinkRoute)?.sharedLink) {
|
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 { Permission } from 'src/enum';
|
||||||
import { ApiKeyService } from 'src/services/api-key.service';
|
import { ApiKeyService } from 'src/services/api-key.service';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
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', () => {
|
describe('getById', () => {
|
||||||
it('should throw an error if the key is not found', async () => {
|
it('should throw an error if the key is not found', async () => {
|
||||||
const auth = factory.auth();
|
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 { ApiKey } from 'src/database';
|
||||||
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
|
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.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);
|
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> {
|
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
|
||||||
const key = await this.apiKeyRepository.getById(auth.user.id, id);
|
const key = await this.apiKeyRepository.getById(auth.user.id, id);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
@ -518,6 +518,20 @@ describe(AuthService.name, () => {
|
|||||||
await expect(result).rejects.toThrow('Missing required permission: all');
|
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 () => {
|
it('should return an auth dto', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = factory.authUser();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
||||||
|
|||||||
@ -48,7 +48,8 @@ export type ValidateRequest = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
sharedLinkRoute: boolean;
|
sharedLinkRoute: boolean;
|
||||||
adminRoute: boolean;
|
adminRoute: boolean;
|
||||||
permission?: Permission;
|
/** `false` explicitly means no permission is required, which otherwise defaults to `all` */
|
||||||
|
permission?: Permission | false;
|
||||||
uri: string;
|
uri: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -187,7 +188,11 @@ export class AuthService extends BaseService {
|
|||||||
throw new ForbiddenException('Forbidden');
|
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}`);
|
throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user