merge: always validate role policies (!1176)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1176 Approved-by: Hazelnoot <acomputerdog@gmail.com> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
cfbffefb7f
4 changed files with 209 additions and 19 deletions
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import Ajv from 'ajv';
|
||||||
import type {
|
import type {
|
||||||
MiMeta,
|
MiMeta,
|
||||||
MiRole,
|
MiRole,
|
||||||
|
|
@ -35,8 +36,10 @@ import {
|
||||||
type ManagedMemorySingleCache,
|
type ManagedMemorySingleCache,
|
||||||
type ManagedMemoryKVCache,
|
type ManagedMemoryKVCache,
|
||||||
} from '@/global/CacheManagementService.js';
|
} from '@/global/CacheManagementService.js';
|
||||||
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { getCallerId } from '@/misc/attach-caller-id.js';
|
import { getCallerId } from '@/misc/attach-caller-id.js';
|
||||||
|
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { JSONSchemaType, ValidateFunction } from 'ajv';
|
||||||
|
|
||||||
export type RolePolicies = {
|
export type RolePolicies = {
|
||||||
gtlAvailable: boolean;
|
gtlAvailable: boolean;
|
||||||
|
|
@ -122,6 +125,86 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
|
|
||||||
// TODO cache sync fixes (and maybe events too?)
|
// TODO cache sync fixes (and maybe events too?)
|
||||||
|
|
||||||
|
const DefaultPoliciesSchema: JSONSchemaType<RolePolicies> = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [],
|
||||||
|
properties: {
|
||||||
|
gtlAvailable: { type: 'boolean' },
|
||||||
|
ltlAvailable: { type: 'boolean' },
|
||||||
|
btlAvailable: { type: 'boolean' },
|
||||||
|
canPublicNote: { type: 'boolean' },
|
||||||
|
scheduleNoteMax: { type: 'integer', minimum: 0 },
|
||||||
|
mentionLimit: { type: 'integer', minimum: 0 },
|
||||||
|
canInvite: { type: 'boolean' },
|
||||||
|
inviteLimit: { type: 'integer', minimum: 0 },
|
||||||
|
inviteLimitCycle: { type: 'integer', minimum: 0 },
|
||||||
|
inviteExpirationTime: { type: 'integer', minimum: 0 },
|
||||||
|
canManageCustomEmojis: { type: 'boolean' },
|
||||||
|
canManageAvatarDecorations: { type: 'boolean' },
|
||||||
|
canSearchNotes: { type: 'boolean' },
|
||||||
|
canUseTranslator: { type: 'boolean' },
|
||||||
|
canHideAds: { type: 'boolean' },
|
||||||
|
|
||||||
|
// these can be less than 1 MB
|
||||||
|
// (test/unit/server/api/drive/files/create.ts depends on this)
|
||||||
|
driveCapacityMb: { type: 'number', minimum: 0 },
|
||||||
|
maxFileSizeMb: { type: 'number', minimum: 0 },
|
||||||
|
|
||||||
|
alwaysMarkNsfw: { type: 'boolean' },
|
||||||
|
canUpdateBioMedia: { type: 'boolean' },
|
||||||
|
pinLimit: { type: 'integer', minimum: 0 },
|
||||||
|
antennaLimit: { type: 'integer', minimum: 0 },
|
||||||
|
wordMuteLimit: { type: 'integer', minimum: 0 },
|
||||||
|
webhookLimit: { type: 'integer', minimum: 0 },
|
||||||
|
clipLimit: { type: 'integer', minimum: 0 },
|
||||||
|
noteEachClipsLimit: { type: 'integer', minimum: 0 },
|
||||||
|
userListLimit: { type: 'integer', minimum: 0 },
|
||||||
|
userEachUserListsLimit: { type: 'integer', minimum: 0 },
|
||||||
|
rateLimitFactor: { type: 'number', minimum: 0.01 },
|
||||||
|
canImportNotes: { type: 'boolean' },
|
||||||
|
avatarDecorationLimit: { type: 'integer', minimum: 0 },
|
||||||
|
canImportAntennas: { type: 'boolean' },
|
||||||
|
canImportBlocking: { type: 'boolean' },
|
||||||
|
canImportFollowing: { type: 'boolean' },
|
||||||
|
canImportMuting: { type: 'boolean' },
|
||||||
|
canImportUserLists: { type: 'boolean' },
|
||||||
|
chatAvailability: { type: 'string', enum: ['available', 'readonly', 'unavailable'] },
|
||||||
|
canTrend: { type: 'boolean' },
|
||||||
|
canViewFederation: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoleSchema: JSONSchemaType<MiRole['policies']> = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [],
|
||||||
|
properties: Object.fromEntries(
|
||||||
|
Object.entries(DefaultPoliciesSchema.properties!).map(
|
||||||
|
(
|
||||||
|
// I picked `canTrend` here, but any policy name is fine, the
|
||||||
|
// type of their bit of the schema is all the same
|
||||||
|
[policy, value]: [string, JSONSchemaType<RolePolicies>['properties']['canTrend']]
|
||||||
|
) => [
|
||||||
|
policy,
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
// we can't require `value` because the MiRole says `value:
|
||||||
|
// any` which includes undefined, so technically `value` is
|
||||||
|
// not really required
|
||||||
|
required: ['priority', 'useDefault'],
|
||||||
|
properties: {
|
||||||
|
priority: { type: 'integer', minimum: 0, maximum: 2 },
|
||||||
|
useDefault: { type: 'boolean' },
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
private readonly rolesCache: ManagedMemorySingleCache<MiRole[]>;
|
private readonly rolesCache: ManagedMemorySingleCache<MiRole[]>;
|
||||||
|
|
@ -129,6 +212,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
|
||||||
private cacheService: CacheService;
|
private cacheService: CacheService;
|
||||||
private notificationService: NotificationService;
|
private notificationService: NotificationService;
|
||||||
|
private defaultPoliciesValidator: ValidateFunction<RolePolicies>;
|
||||||
|
private roleValidator: ValidateFunction<MiRole['policies']>;
|
||||||
|
|
||||||
public static AlreadyAssignedError = class extends Error {};
|
public static AlreadyAssignedError = class extends Error {};
|
||||||
public static NotAssignedError = class extends Error {};
|
public static NotAssignedError = class extends Error {};
|
||||||
|
|
@ -167,6 +252,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
// TODO additional cache for final calculation?
|
// TODO additional cache for final calculation?
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onMessage);
|
this.redisForSub.on('message', this.onMessage);
|
||||||
|
|
||||||
|
// this is copied from server/api/endpoint-base.ts
|
||||||
|
const ajv = new Ajv.default({
|
||||||
|
useDefaults: true,
|
||||||
|
allErrors: true,
|
||||||
|
});
|
||||||
|
this.defaultPoliciesValidator = ajv.compile(DefaultPoliciesSchema);
|
||||||
|
this.roleValidator = ajv.compile(RoleSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -756,6 +849,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> {
|
public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> {
|
||||||
|
this.assertValidRole(values);
|
||||||
|
|
||||||
const date = this.timeService.date;
|
const date = this.timeService.date;
|
||||||
const created = await this.rolesRepository.insertOne({
|
const created = await this.rolesRepository.insertOne({
|
||||||
id: this.idService.gen(date.getTime()),
|
id: this.idService.gen(date.getTime()),
|
||||||
|
|
@ -792,6 +887,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(role: MiRole, params: Partial<MiRole>, moderator?: MiUser): Promise<void> {
|
public async update(role: MiRole, params: Partial<MiRole>, moderator?: MiUser): Promise<void> {
|
||||||
|
this.assertValidRole(params);
|
||||||
|
|
||||||
const date = this.timeService.date;
|
const date = this.timeService.date;
|
||||||
await this.rolesRepository.update(role.id, {
|
await this.rolesRepository.update(role.id, {
|
||||||
updatedAt: date,
|
updatedAt: date,
|
||||||
|
|
@ -843,4 +940,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
public onApplicationShutdown(signal?: string | undefined): void {
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
this.dispose();
|
this.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public assertValidRole(role: Partial<MiRole>): void {
|
||||||
|
if (!role.policies) return;
|
||||||
|
|
||||||
|
if (this.roleValidator(role.policies)) return;
|
||||||
|
|
||||||
|
throw new IdentifiableError(
|
||||||
|
'39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
|
||||||
|
'invalid policy values',
|
||||||
|
false,
|
||||||
|
this.roleValidator.errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public assertValidDefaultPolicies(policies: object): void {
|
||||||
|
if (this.defaultPoliciesValidator(policies)) return;
|
||||||
|
|
||||||
|
throw new IdentifiableError(
|
||||||
|
'39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
|
||||||
|
'invalid policy values',
|
||||||
|
false,
|
||||||
|
this.defaultPoliciesValidator.errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['admin', 'role'],
|
tags: ['admin', 'role'],
|
||||||
|
|
@ -20,6 +22,14 @@ export const meta = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
ref: 'Role',
|
ref: 'Role',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
badValues: {
|
||||||
|
message: 'Invalid policy values',
|
||||||
|
code: 'BAD_POLICY_VALUES',
|
||||||
|
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
|
|
@ -69,7 +79,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const created = await this.roleService.create(ps, me);
|
const created = await this.roleService.create(ps, me);
|
||||||
|
|
||||||
return await this.roleEntityService.pack(created, me);
|
try {
|
||||||
|
return await this.roleEntityService.pack(created, me);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof IdentifiableError) {
|
||||||
|
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
|
||||||
|
throw new ApiError(
|
||||||
|
meta.errors.badValues,
|
||||||
|
e.cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['admin', 'role'],
|
tags: ['admin', 'role'],
|
||||||
|
|
@ -15,6 +18,14 @@ export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireAdmin: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:roles',
|
kind: 'write:admin:roles',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
badValues: {
|
||||||
|
message: 'Invalid policy values',
|
||||||
|
code: 'BAD_POLICY_VALUES',
|
||||||
|
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
|
|
@ -35,8 +46,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private moderationLogService: ModerationLogService,
|
private moderationLogService: ModerationLogService,
|
||||||
|
private roleService: RoleService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
try {
|
||||||
|
this.roleService.assertValidDefaultPolicies(ps.policies);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof IdentifiableError) {
|
||||||
|
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
|
||||||
|
throw new ApiError(
|
||||||
|
meta.errors.badValues,
|
||||||
|
e.cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
const before = await this.metaService.fetch(true);
|
const before = await this.metaService.fetch(true);
|
||||||
|
|
||||||
await this.metaService.update({
|
await this.metaService.update({
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { RolesRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['admin', 'role'],
|
tags: ['admin', 'role'],
|
||||||
|
|
@ -23,6 +24,12 @@ export const meta = {
|
||||||
code: 'NO_SUCH_ROLE',
|
code: 'NO_SUCH_ROLE',
|
||||||
id: 'cd23ef55-09ad-428a-ac61-95a45e124b32',
|
id: 'cd23ef55-09ad-428a-ac61-95a45e124b32',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
badValues: {
|
||||||
|
message: 'Invalid policy values',
|
||||||
|
code: 'BAD_POLICY_VALUES',
|
||||||
|
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -67,23 +74,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new ApiError(meta.errors.noSuchRole);
|
throw new ApiError(meta.errors.noSuchRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.roleService.update(role, {
|
try {
|
||||||
name: ps.name,
|
await this.roleService.update(role, {
|
||||||
description: ps.description,
|
name: ps.name,
|
||||||
color: ps.color,
|
description: ps.description,
|
||||||
iconUrl: ps.iconUrl,
|
color: ps.color,
|
||||||
target: ps.target,
|
iconUrl: ps.iconUrl,
|
||||||
condFormula: ps.condFormula,
|
target: ps.target,
|
||||||
isPublic: ps.isPublic,
|
condFormula: ps.condFormula,
|
||||||
isModerator: ps.isModerator,
|
isPublic: ps.isPublic,
|
||||||
isAdministrator: ps.isAdministrator,
|
isModerator: ps.isModerator,
|
||||||
isExplorable: ps.isExplorable,
|
isAdministrator: ps.isAdministrator,
|
||||||
asBadge: ps.asBadge,
|
isExplorable: ps.isExplorable,
|
||||||
preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount,
|
asBadge: ps.asBadge,
|
||||||
canEditMembersByModerator: ps.canEditMembersByModerator,
|
preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount,
|
||||||
displayOrder: ps.displayOrder,
|
canEditMembersByModerator: ps.canEditMembersByModerator,
|
||||||
policies: ps.policies,
|
displayOrder: ps.displayOrder,
|
||||||
}, me);
|
policies: ps.policies,
|
||||||
|
}, me);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof IdentifiableError) {
|
||||||
|
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
|
||||||
|
throw new ApiError(
|
||||||
|
meta.errors.badValues,
|
||||||
|
e.cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue