Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: api key repository #15491

Merged
merged 1 commit into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions server/src/interfaces/api-key.interface.ts

This file was deleted.

40 changes: 13 additions & 27 deletions server/src/repositories/api-key.repository.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ApiKeys, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { AuthApiKey } from 'src/types';
import { asUuid } from 'src/utils/database';
import { Repository } from 'typeorm';

const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const;

@Injectable()
export class ApiKeyRepository implements IKeyRepository {
constructor(
@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>,
@InjectKysely() private db: Kysely<DB>,
) {}
export class ApiKeyRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}

async create(dto: Insertable<ApiKeys>): Promise<APIKeyEntity> {
const { id, name, createdAt, updatedAt, permissions } = await this.db
.insertInto('api_keys')
.values(dto)
.returningAll()
.executeTakeFirstOrThrow();

return { id, name, createdAt, updatedAt, permissions } as APIKeyEntity;
create(dto: Insertable<ApiKeys>) {
return this.db.insertInto('api_keys').values(dto).returningAll().executeTakeFirstOrThrow();
}

async update(userId: string, id: string, dto: Updateable<ApiKeys>): Promise<APIKeyEntity> {
async update(userId: string, id: string, dto: Updateable<ApiKeys>) {
return this.db
.updateTable('api_keys')
.set(dto)
.where('api_keys.userId', '=', userId)
.where('id', '=', asUuid(id))
.returningAll()
.executeTakeFirstOrThrow() as unknown as Promise<APIKeyEntity>;
.executeTakeFirstOrThrow();
}

async delete(userId: string, id: string): Promise<void> {
async delete(userId: string, id: string) {
await this.db.deleteFrom('api_keys').where('userId', '=', userId).where('id', '=', asUuid(id)).execute();
}

@GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string): Promise<AuthApiKey | undefined> {
getKey(hashedToken: string) {
return this.db
.selectFrom('api_keys')
.innerJoinLateral(
Expand Down Expand Up @@ -72,26 +58,26 @@ export class ApiKeyRepository implements IKeyRepository {
eb.fn.toJson('user').as('user'),
])
.where('api_keys.key', '=', hashedToken)
.executeTakeFirst() as Promise<AuthApiKey | undefined>;
.executeTakeFirst();
}

@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getById(userId: string, id: string): Promise<APIKeyEntity | null> {
getById(userId: string, id: string) {
return this.db
.selectFrom('api_keys')
.select(columns)
.where('id', '=', asUuid(id))
.where('userId', '=', userId)
.executeTakeFirst() as unknown as Promise<APIKeyEntity | null>;
.executeTakeFirst();
}

@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string): Promise<APIKeyEntity[]> {
getByUserId(userId: string) {
return this.db
.selectFrom('api_keys')
.select(columns)
.where('userId', '=', userId)
.orderBy('createdAt', 'desc')
.execute() as unknown as Promise<APIKeyEntity[]>;
.execute();
}
}
3 changes: 1 addition & 2 deletions server/src/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
Expand Down Expand Up @@ -79,6 +78,7 @@ export const repositories = [
//
AccessRepository,
ActivityRepository,
ApiKeyRepository,
];

export const providers = [
Expand All @@ -92,7 +92,6 @@ export const providers = [
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: IKeyRepository, useClass: ApiKeyRepository },
{ provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: ILoggerRepository, useClass: LoggerRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
Expand Down
10 changes: 2 additions & 8 deletions server/src/services/api-key.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils';
Expand All @@ -12,7 +12,7 @@ describe(APIKeyService.name, () => {
let sut: APIKeyService;

let cryptoMock: Mocked<ICryptoRepository>;
let keyMock: Mocked<IKeyRepository>;
let keyMock: Mocked<IApiKeyRepository>;

beforeEach(() => {
({ sut, cryptoMock, keyMock } = newTestService(APIKeyService));
Expand Down Expand Up @@ -56,8 +56,6 @@ describe(APIKeyService.name, () => {

describe('update', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);

await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf(
BadRequestException,
);
Expand All @@ -77,8 +75,6 @@ describe(APIKeyService.name, () => {

describe('delete', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);

await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);

expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid');
Expand All @@ -95,8 +91,6 @@ describe(APIKeyService.name, () => {

describe('getById', () => {
it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);

await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);

expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
Expand Down
7 changes: 4 additions & 3 deletions server/src/services/api-key.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ApiKeyItem } from 'src/types';
import { isGranted } from 'src/utils/access';

@Injectable()
Expand Down Expand Up @@ -57,13 +58,13 @@ export class APIKeyService extends BaseService {
return keys.map((key) => this.map(key));
}

private map(entity: APIKeyEntity): APIKeyResponseDto {
private map(entity: ApiKeyItem): APIKeyResponseDto {
return {
id: entity.id,
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
permissions: entity.permissions,
permissions: entity.permissions as Permission[],
};
}
}
4 changes: 2 additions & 2 deletions server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
Expand All @@ -12,6 +11,7 @@ import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
Expand Down Expand Up @@ -62,7 +62,7 @@ describe('AuthService', () => {

let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let keyMock: Mocked<IKeyRepository>;
let keyMock: Mocked<IApiKeyRepository>;
let oauthMock: Mocked<IOAuthRepository>;
let sessionMock: Mocked<ISessionRepository>;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
Expand Down
6 changes: 5 additions & 1 deletion server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { UserEntity } from 'src/entities/user.entity';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/interfaces/oauth.interface';
import { BaseService } from 'src/services/base.service';
import { AuthApiKey } from 'src/types';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';

Expand Down Expand Up @@ -309,7 +310,10 @@ export class AuthService extends BaseService {
const hashedKey = this.cryptoRepository.hashSha256(key);
const apiKey = await this.keyRepository.getKey(hashedKey);
if (apiKey) {
return { user: apiKey.user, apiKey };
return {
user: apiKey.user as unknown as UserEntity,
apiKey: apiKey as unknown as AuthApiKey,
};
}

throw new UnauthorizedException('Invalid API key');
Expand Down
4 changes: 2 additions & 2 deletions server/src/services/base.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
Expand Down Expand Up @@ -45,6 +44,7 @@ import { IVersionHistoryRepository } from 'src/interfaces/version-history.interf
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';

Expand All @@ -65,7 +65,7 @@ export class BaseService {
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository,
@Inject(IJobRepository) protected jobRepository: IJobRepository,
@Inject(IKeyRepository) protected keyRepository: IKeyRepository,
protected keyRepository: ApiKeyRepository,
@Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository,
@Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository,
@Inject(IMapRepository) protected mapRepository: IMapRepository,
Expand Down
7 changes: 7 additions & 0 deletions server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';

export type AuthApiKey = {
id: string;
Expand All @@ -14,7 +15,13 @@ export type RepositoryInterface<T extends object> = Pick<T, keyof T>;

export type IActivityRepository = RepositoryInterface<ActivityRepository>;
export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface<AccessRepository[K]> };
export type IApiKeyRepository = RepositoryInterface<ApiKeyRepository>;

export type ActivityItem =
| Awaited<ReturnType<IActivityRepository['create']>>
| Awaited<ReturnType<IActivityRepository['search']>>[0];

export type ApiKeyItem =
| Awaited<ReturnType<IApiKeyRepository['create']>>
| NonNullable<Awaited<ReturnType<IApiKeyRepository['getById']>>>
| Awaited<ReturnType<IApiKeyRepository['getByUserId']>>[0];
7 changes: 3 additions & 4 deletions server/test/fixtures/api-key.stub.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { AuthApiKey } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';

Expand All @@ -9,13 +7,14 @@ export const keyStub = {
key: 'my-api-key (hashed)',
user: userStub.admin,
permissions: [],
} as AuthApiKey),
} as any),

admin: Object.freeze({
id: 'my-random-guid',
name: 'My Key',
key: 'my-api-key (hashed)',
userId: authStub.admin.user.id,
user: userStub.admin,
} as APIKeyEntity),
permissions: [],
} as any),
};
4 changes: 2 additions & 2 deletions server/test/repositories/api-key.repository.mock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IApiKeyRepository } from 'src/types';
import { Mocked, vitest } from 'vitest';

export const newKeyRepositoryMock = (): Mocked<IKeyRepository> => {
export const newKeyRepositoryMock = (): Mocked<IApiKeyRepository> => {
return {
create: vitest.fn(),
update: vitest.fn(),
Expand Down
5 changes: 3 additions & 2 deletions server/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { ImmichWorker } from 'src/enum';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository, IActivityRepository } from 'src/types';
import { IAccessRepository, IActivityRepository, IApiKeyRepository } from 'src/types';
import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock';
Expand Down Expand Up @@ -118,7 +119,7 @@ export const newTestService = <T extends BaseService>(
databaseMock,
eventMock,
jobMock,
keyMock,
keyMock as IApiKeyRepository as ApiKeyRepository,
libraryMock,
machineLearningMock,
mapMock,
Expand Down
Loading