diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 62cf04e00e..76f4e0dbdd 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -342,11 +342,11 @@ export class ChatService { } @bindThis - public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) { - if (await this.isRoomMember(room, meId)) { + public async hasPermissionToViewRoomTimeline(me: MiUser, room: MiChatRoom) { + if (await this.isRoomMember(room, me.id)) { return true; } else { - const iAmModerator = await this.roleService.isModerator({ id: meId }); + const iAmModerator = await this.roleService.isModerator(me); if (iAmModerator) { return true; } @@ -563,12 +563,12 @@ export class ChatService { } @bindThis - public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) { - if (room.ownerId === meId) { + public async hasPermissionToDeleteRoom(me: MiUser, room: MiChatRoom) { + if (room.ownerId === me.id) { return true; } - const iAmModerator = await this.roleService.isModerator({ id: meId }); + const iAmModerator = await this.roleService.isModerator(me); if (iAmModerator) { return true; } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 6429e304e5..d16dbb57b0 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -31,6 +31,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; +import { getCallerId } from '@/misc/attach-caller-id.js'; export type RolePolicies = { gtlAvailable: boolean; @@ -414,7 +415,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats)); - return [...assignedRoles, ...matchedCondRoles]; + + let allRoles = [...assignedRoles, ...matchedCondRoles]; + + // Check for dropped token permissions + const rank = user ? getCallerId(user)?.accessToken?.rank : null; + if (rank != null) { + // Copy roles, since they come from a cache + allRoles = allRoles.map(role => ({ + ...role, + isModerator: role.isModerator && (rank === 'admin' || rank === 'mod'), + isAdministrator: role.isAdministrator && rank === 'admin', + })); + } + + return allRoles; } /** @@ -514,12 +529,22 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async isModerator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; + + // Check for dropped token permissions + const callerId = getCallerId(user); + if (callerId?.accessToken?.rank != null && callerId.accessToken.rank !== 'admin' && callerId.accessToken.rank !== 'mod') return false; + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); } @bindThis public async isAdministrator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; + + // Check for dropped token permissions + const callerId = getCallerId(user); + if (callerId?.accessToken?.rank != null && callerId.accessToken.rank !== 'admin') return false; + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 2aff910ea0..f5ce8b22a2 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -481,8 +481,8 @@ export class UserEntityService implements OnModuleInit { const isDetailed = opts.schema !== 'UserLite'; const meId = me ? me.id : null; const isMe = meId === user.id; - const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false); - const iAmAdmin = opts.iAmAdmin ?? (me ? await this.roleService.isAdministrator(user) : false); + const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me) : false); + const iAmAdmin = opts.iAmAdmin ?? (me ? await this.roleService.isAdministrator(me) : false); const profile = isDetailed ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) diff --git a/packages/backend/src/misc/attach-caller-id.ts b/packages/backend/src/misc/attach-caller-id.ts new file mode 100644 index 0000000000..d5179a37ea --- /dev/null +++ b/packages/backend/src/misc/attach-caller-id.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { MiAccessToken } from '@/models/AccessToken.js'; + +const callerIdSymbol = Symbol('callerId'); + +/** + * Client metadata associated with an object (typically an instance of MiUser). + */ +export interface CallerId { + /** + * Client's access token, or null if no token was used. + */ + accessToken?: MiAccessToken | null; +} + +interface ObjectWithCallerId { + [callerIdSymbol]?: CallerId; +} + +/** + * Attaches client metadata to an object. + * Calling this repeatedly will overwrite the previous value. + * Pass undefined to erase the attached data. + * @param target Object to attach to (typically an instance of MiUser). + * @param callerId Data to attach. + */ +export function attachCallerId(target: object, callerId: CallerId | undefined): void { + (target as ObjectWithCallerId)[callerIdSymbol] = callerId; +} + +/** + * Fetches client metadata from an object. + * Returns undefined if no metadata is attached. + * @param target Object to fetch from. + */ +export function getCallerId(target: object): CallerId | undefined { + return (target as ObjectWithCallerId)[callerIdSymbol]; +} diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 397626c49d..3681602e1f 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -13,6 +13,7 @@ import type { MiApp } from '@/models/App.js'; import { CacheService } from '@/core/CacheService.js'; import { isNativeUserToken } from '@/misc/token.js'; import { bindThis } from '@/decorators.js'; +import { attachCallerId } from '@/misc/attach-caller-id.js'; export class AuthenticationError extends Error { constructor(message: string) { @@ -62,6 +63,9 @@ export class AuthenticateService implements OnApplicationShutdown { }, { token: token, // miauth }], + relations: { + user: true, + }, }); if (accessToken == null) { @@ -72,10 +76,11 @@ export class AuthenticateService implements OnApplicationShutdown { lastUsedAt: new Date(), }); - const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, - () => this.usersRepository.findOneBy({ - id: accessToken.userId, - }) as Promise); + // Loaded by relation above + const user = accessToken.user as MiLocalUser; + + // Attach token to user - this will be read by RoleService to drop admin/moderator permissions. + attachCallerId(user, { accessToken }); if (accessToken.appId) { const app = await this.appCache.fetch(accessToken.appId,