separate role for user oauth2 clients
This commit is contained in:
parent
e884ce80fc
commit
7a06c882ac
|
@ -4,5 +4,5 @@ import { SetMetadata } from '@nestjs/common';
|
|||
* Restrict this route to only these privileges. AND logic!
|
||||
* @param privileges List of privileges for this route
|
||||
*/
|
||||
export const Privileges = (...privileges: string[]) =>
|
||||
export const Privileges = (...privileges: (string | string[])[]) =>
|
||||
SetMetadata('privileges', privileges);
|
||||
|
|
|
@ -9,7 +9,7 @@ export class PrivilegesGuard implements CanActivate {
|
|||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const privileges = this.reflector.get<string[]>(
|
||||
const privileges = this.reflector.get<(string | string[])[]>(
|
||||
'privileges',
|
||||
context.getHandler(),
|
||||
);
|
||||
|
@ -18,6 +18,20 @@ export class PrivilegesGuard implements CanActivate {
|
|||
}
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
const withOrLogic = privileges.some((entry) => Array.isArray(entry));
|
||||
if (withOrLogic) {
|
||||
return privileges.some((entry) => {
|
||||
if (Array.isArray(entry)) {
|
||||
return entry.every((item) =>
|
||||
user.privileges.find(({ name }) => name === item),
|
||||
);
|
||||
} else {
|
||||
return user.privileges.find(({ name }) => name === entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return privileges.every((item) =>
|
||||
user.privileges.find(({ name }) => name === item),
|
||||
);
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Client } from 'connect-redis';
|
||||
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity';
|
||||
import { User } from 'src/modules/objects/user/user.entity';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
|
||||
const UNPRIVILEGED_STRIP = ['openid', 'id_token', 'management', 'implicit'];
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private _form: FormUtilityService) {}
|
||||
|
@ -14,4 +18,26 @@ export class AdminService {
|
|||
: null,
|
||||
} as Partial<OAuth2Client>;
|
||||
}
|
||||
|
||||
public userHasPrivilege(user: User, privilege: string): boolean {
|
||||
return user.privileges.some(({ name }) => name === privilege);
|
||||
}
|
||||
|
||||
public userCanEditClient(user: User, client: Client): boolean {
|
||||
if (this.userHasPrivilege(user, 'admin:oauth2')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return client.owner?.id === user.id;
|
||||
}
|
||||
|
||||
public removeUnprivileged(input: string[]): string[] {
|
||||
return input.reduce((list, current) => {
|
||||
if (UNPRIVILEGED_STRIP.includes(current)) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return [...list, current];
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
|
@ -36,6 +37,7 @@ import { TokenService } from 'src/modules/utility/services/token.service';
|
|||
import { PageOptions } from 'src/types/pagination.interfaces';
|
||||
import { AdminService } from './admin.service';
|
||||
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
const RELATIONS = ['urls', 'picture', 'owner'];
|
||||
const SET_CLIENT_FIELDS = [
|
||||
|
@ -66,22 +68,47 @@ export class OAuth2AdminController {
|
|||
|
||||
@Get('scopes')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async oauth2Scopes() {
|
||||
return this._oaClient.availableScopes;
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async oauth2Scopes(@CurrentUser() user: User) {
|
||||
const reducedPermissions = !this._service.userHasPrivilege(
|
||||
user,
|
||||
'admin:oauth2',
|
||||
);
|
||||
|
||||
return reducedPermissions
|
||||
? this._service.removeUnprivileged(this._oaClient.availableScopes)
|
||||
: this._oaClient.availableScopes;
|
||||
}
|
||||
|
||||
@Get('grants')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async oauth2Grants() {
|
||||
return this._oaClient.availableGrantTypes;
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async oauth2Grants(@CurrentUser() user: User) {
|
||||
const reducedPermissions = !this._service.userHasPrivilege(
|
||||
user,
|
||||
'admin:oauth2',
|
||||
);
|
||||
|
||||
return reducedPermissions
|
||||
? this._service.removeUnprivileged(this._oaClient.availableGrantTypes)
|
||||
: this._oaClient.availableGrantTypes;
|
||||
}
|
||||
|
||||
@Get('clients')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async oauth2ClientList(@Query() options: { q?: string } & PageOptions) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async oauth2ClientList(
|
||||
@Query() options: { q?: string } & PageOptions,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
if (!this._service.userHasPrivilege(user, 'admin:oauth2')) {
|
||||
const unprivileged = await this._oaClient.getClientsByOwner(
|
||||
user,
|
||||
RELATIONS,
|
||||
);
|
||||
return unprivileged.map((item) => this._service.stripClientInfo(item));
|
||||
}
|
||||
|
||||
const search = options.q ? decodeURIComponent(options.q) : null;
|
||||
const resultCount = await this._oaClient.searchClientsCount(
|
||||
search,
|
||||
|
@ -108,29 +135,49 @@ export class OAuth2AdminController {
|
|||
|
||||
@Get('clients/:id')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async oauth2Client(@Param('id') id: string) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async oauth2Client(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to view this client',
|
||||
);
|
||||
}
|
||||
|
||||
return this._service.stripClientInfo(client);
|
||||
}
|
||||
|
||||
@Patch('clients/:id')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async updateOauth2Client(
|
||||
@Param('id') id: string,
|
||||
@Body() setter: Partial<OAuth2Client>,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS);
|
||||
|
||||
// Non-admin cannot change the activated or verified status
|
||||
if (!this._service.userHasPrivilege(user, 'admin:oauth2')) {
|
||||
delete allowedFieldsOnly.activated;
|
||||
delete allowedFieldsOnly.verified;
|
||||
}
|
||||
|
||||
if (!Object.keys(allowedFieldsOnly).length) {
|
||||
return this._service.stripClientInfo(client);
|
||||
}
|
||||
|
@ -143,13 +190,19 @@ export class OAuth2AdminController {
|
|||
|
||||
@Post('clients/:id/new-secret')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async createNewSecret(@Param('id') id: string) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async createNewSecret(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
client.client_secret = this._token.generateSecret();
|
||||
await this._oaClient.updateClient(client);
|
||||
|
||||
|
@ -162,13 +215,22 @@ export class OAuth2AdminController {
|
|||
|
||||
@Delete('clients/:id/authorizations')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async deleteClientAuthorizations(@Param('id') id: string) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async deleteClientAuthorizations(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
await this._oaClient.wipeClientAuthorizations(client);
|
||||
|
||||
return this._service.stripClientInfo(client);
|
||||
|
@ -176,21 +238,29 @@ export class OAuth2AdminController {
|
|||
|
||||
@Get('clients/:id/urls')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async oauth2ClientURLs(@Param('id') id: string) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async oauth2ClientURLs(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
return client.urls;
|
||||
}
|
||||
|
||||
@Delete('clients/:id/urls/:url')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async deleteOAuth2ClientURL(
|
||||
@Param('id') id: string,
|
||||
@Param('url') urlId: string,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||
const parsedURLId = parseInt(urlId, 10);
|
||||
|
@ -199,6 +269,12 @@ export class OAuth2AdminController {
|
|||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsedURLId) {
|
||||
throw new BadRequestException('Invalid URL ID');
|
||||
}
|
||||
|
@ -215,11 +291,12 @@ export class OAuth2AdminController {
|
|||
|
||||
@Put('clients/:id/urls/:url')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async updateOAuth2ClientURL(
|
||||
@Param('id') id: string,
|
||||
@Param('url') urlId: string,
|
||||
@Body() setter: { url: string; type: string },
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||
const plucked = this._form.pluckObject(setter, ['url', 'type']);
|
||||
|
@ -229,6 +306,12 @@ export class OAuth2AdminController {
|
|||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsedURLId) {
|
||||
throw new BadRequestException('Invalid URL ID');
|
||||
}
|
||||
|
@ -249,16 +332,23 @@ export class OAuth2AdminController {
|
|||
|
||||
@Post('clients/:id/urls')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async createOAuth2ClientURL(
|
||||
@Param('id') id: string,
|
||||
@Body() setter: { url: string; type: string },
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) {
|
||||
throw new NotFoundException('Missing or invalid fields');
|
||||
}
|
||||
|
@ -272,9 +362,10 @@ export class OAuth2AdminController {
|
|||
return url;
|
||||
}
|
||||
|
||||
@Throttle(3, 60)
|
||||
@Post('clients/:id/picture')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadClientPictureFile(
|
||||
@CurrentUser() user: User,
|
||||
|
@ -288,6 +379,12 @@ export class OAuth2AdminController {
|
|||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('Picture upload failed');
|
||||
}
|
||||
|
@ -315,13 +412,22 @@ export class OAuth2AdminController {
|
|||
|
||||
@Delete('clients/:id/picture')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
async deleteClientPictureFile(@Param('id') id: string) {
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async deleteClientPictureFile(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const client = await this._oaClient.getById(parseInt(id, 10), ['picture']);
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
if (!this._service.userCanEditClient(user, client)) {
|
||||
throw new UnauthorizedException(
|
||||
'You do not have permission to edit this client',
|
||||
);
|
||||
}
|
||||
|
||||
this._oaClient.deletePicture(client);
|
||||
client.picture = null;
|
||||
await this._oaClient.updateClient(client);
|
||||
|
@ -332,12 +438,16 @@ export class OAuth2AdminController {
|
|||
// New client
|
||||
@Post('/clients')
|
||||
@Scopes('management')
|
||||
@Privileges('admin', 'admin:oauth2')
|
||||
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
|
||||
async createNewClient(
|
||||
@Body() setter: Partial<OAuth2Client>,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS);
|
||||
const reducedPermissions = !this._service.userHasPrivilege(
|
||||
user,
|
||||
'admin:oauth2',
|
||||
);
|
||||
|
||||
if (!Object.keys(allowedFieldsOnly).length) {
|
||||
throw new BadRequestException('Required fields are missing');
|
||||
|
@ -349,20 +459,21 @@ export class OAuth2AdminController {
|
|||
|
||||
const splitGrants = allowedFieldsOnly.grants.split(' ');
|
||||
const splitScopes = allowedFieldsOnly.scope.split(' ');
|
||||
let availableGrantTypes = this._oaClient.availableGrantTypes;
|
||||
let availableScopes = this._oaClient.availableScopes;
|
||||
|
||||
if (
|
||||
!splitGrants.every((grant) =>
|
||||
this._oaClient.availableGrantTypes.includes(grant),
|
||||
)
|
||||
) {
|
||||
if (reducedPermissions) {
|
||||
availableGrantTypes =
|
||||
this._service.removeUnprivileged(availableGrantTypes);
|
||||
availableScopes = this._service.removeUnprivileged(availableScopes);
|
||||
allowedFieldsOnly.activated = true;
|
||||
}
|
||||
|
||||
if (!splitGrants.every((grant) => availableGrantTypes.includes(grant))) {
|
||||
throw new BadRequestException('Bad grant types');
|
||||
}
|
||||
|
||||
if (
|
||||
!splitScopes.every((scope) =>
|
||||
this._oaClient.availableScopes.includes(scope),
|
||||
)
|
||||
) {
|
||||
if (!splitScopes.every((scope) => availableScopes.includes(scope))) {
|
||||
throw new BadRequestException('Bad scopes');
|
||||
}
|
||||
|
||||
|
|
|
@ -159,6 +159,9 @@ export class OAuth2ClientService {
|
|||
{
|
||||
title: ILike(`%${search}%`),
|
||||
},
|
||||
{
|
||||
client_id: search,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
skip: offset,
|
||||
|
@ -177,12 +180,25 @@ export class OAuth2ClientService {
|
|||
{
|
||||
title: ILike(`%${search}%`),
|
||||
},
|
||||
{
|
||||
client_id: search,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
public async getClientsByOwner(
|
||||
owner: User,
|
||||
relations?: string[],
|
||||
): Promise<OAuth2Client[]> {
|
||||
return this.clientRepository.find({
|
||||
where: { owner: { id: owner.id } },
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
public async getClientURLs(
|
||||
id: string,
|
||||
type?: OAuth2ClientURLType,
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Request, Response } from 'express';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
|
||||
|
@ -83,6 +84,7 @@ export class SettingsController {
|
|||
res.redirect('/account/general');
|
||||
}
|
||||
|
||||
@Throttle(3, 60)
|
||||
@Post('avatar')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadAvatarFile(
|
||||
|
|
Loading…
Reference in New Issue