mirror of
https://github.com/immich-app/immich.git
synced 2025-12-30 09:15:28 +03:00
feat(server): granular permissions for api keys (#11824)
feat(server): api auth permissions
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -22,10 +23,11 @@ describe(APIKeyService.name, () => {
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
await sut.create(authStub.admin, { name: 'Test Key' });
|
||||
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] });
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'Test Key',
|
||||
permissions: [Permission.ALL],
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
@@ -35,11 +37,12 @@ describe(APIKeyService.name, () => {
|
||||
it('should not require a name', async () => {
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.create(authStub.admin, {});
|
||||
await sut.create(authStub.admin, { permissions: [Permission.ALL] });
|
||||
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'API Key',
|
||||
permissions: [Permission.ALL],
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto } 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 { APIKeyEntity } from 'src/entities/api-key.entity';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyService {
|
||||
@@ -14,16 +15,22 @@ export class APIKeyService {
|
||||
|
||||
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const secret = this.crypto.newPassword(32);
|
||||
|
||||
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
|
||||
throw new BadRequestException('Cannot grant permissions you do not have');
|
||||
}
|
||||
|
||||
const entity = await this.repository.create({
|
||||
key: this.crypto.hashSha256(secret),
|
||||
name: dto.name || 'API Key',
|
||||
userId: auth.user.id,
|
||||
permissions: dto.permissions,
|
||||
});
|
||||
|
||||
return { secret, apiKey: this.map(entity) };
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
|
||||
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
|
||||
const exists = await this.repository.getById(auth.user.id, id);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
@@ -62,6 +69,7 @@ export class APIKeyService {
|
||||
name: entity.name,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
permissions: entity.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
@@ -38,6 +39,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
|
||||
export interface LoginDetails {
|
||||
@@ -61,6 +63,7 @@ export type ValidateRequest = {
|
||||
metadata: {
|
||||
sharedLinkRoute: boolean;
|
||||
adminRoute: boolean;
|
||||
permission?: Permission;
|
||||
uri: string;
|
||||
};
|
||||
};
|
||||
@@ -157,7 +160,7 @@ export class AuthService {
|
||||
|
||||
async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise<AuthDto> {
|
||||
const authDto = await this.validate({ headers, queryParams });
|
||||
const { adminRoute, sharedLinkRoute, uri } = metadata;
|
||||
const { adminRoute, sharedLinkRoute, permission, uri } = metadata;
|
||||
|
||||
if (!authDto.user.isAdmin && adminRoute) {
|
||||
this.logger.warn(`Denied access to admin only route: ${uri}`);
|
||||
@@ -169,6 +172,10 @@ export class AuthService {
|
||||
throw new ForbiddenException('Forbidden');
|
||||
}
|
||||
|
||||
if (authDto.apiKey && permission && !isGranted({ requested: [permission], current: authDto.apiKey.permissions })) {
|
||||
throw new ForbiddenException(`Missing required permission: ${permission}`);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MemoryService {
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
|
||||
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
|
||||
|
||||
const memory = await this.repository.update({
|
||||
id,
|
||||
@@ -82,7 +82,7 @@ export class MemoryService {
|
||||
}
|
||||
|
||||
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
|
||||
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
|
||||
|
||||
const repos = { accessRepository: this.accessRepository, repository: this.repository };
|
||||
const results = await removeAssets(auth, repos, {
|
||||
|
||||
@@ -113,7 +113,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
||||
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
|
||||
const person = await this.findOrFail(personId);
|
||||
const result: PersonResponseDto[] = [];
|
||||
const changeFeaturePhoto: string[] = [];
|
||||
@@ -142,7 +142,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
||||
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
|
||||
|
||||
await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id);
|
||||
const face = await this.repository.getFaceById(dto.id);
|
||||
@@ -226,7 +226,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
|
||||
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||
// TODO: set by faceId directly
|
||||
@@ -581,7 +581,7 @@ export class PersonService {
|
||||
throw new BadRequestException('Cannot merge a person into themselves');
|
||||
}
|
||||
|
||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
|
||||
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
|
||||
let primaryPerson = await this.findOrFail(id);
|
||||
const primaryName = primaryPerson.name || primaryPerson.id;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user