mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
chore(admin): remove user type toggles (#3161)
This commit is contained in:
@@ -9,7 +9,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
AdminApiKeyListResponse,
|
||||
CreateAdminApiKeyRequest,
|
||||
@@ -30,7 +30,7 @@ function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
acls: filterKnownAdminACLs(key.acls),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
name: result.apiKey.name,
|
||||
created_at: result.apiKey.createdAt.toISOString(),
|
||||
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
acls: Array.from(result.apiKey.acls),
|
||||
acls: filterKnownAdminACLs(result.apiKey.acls),
|
||||
};
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
AdminUserAclsRequest,
|
||||
AdminUserBanNoteRequest,
|
||||
AdminUserBanRequest,
|
||||
AdminUserBotStatusRequest,
|
||||
AdminUserChangeLogQuery,
|
||||
AdminUserClearFieldsRequest,
|
||||
AdminUserDeletionCancelRequest,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
AdminUserPremiumFlagsUpdateRequest,
|
||||
AdminUserRelationshipCategoryQuery,
|
||||
AdminUserRelationshipParam,
|
||||
AdminUserSystemStatusRequest,
|
||||
AdminUsersMeResponse,
|
||||
AdminUserTraitsRequest,
|
||||
AdminUserUnbanRequest,
|
||||
@@ -598,70 +596,6 @@ export function UserAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserBotStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_bot_status',
|
||||
summary: 'Set user bot status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserBotStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserSystemStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_system_status',
|
||||
summary: 'Set user system status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserSystemStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/users/:user_id/username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getIpAddressReverse, lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -65,7 +65,7 @@ export async function mapUserToAdminResponse(
|
||||
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
|
||||
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
|
||||
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
|
||||
acls: user.acls ? Array.from(user.acls) : [],
|
||||
acls: user.acls ? filterKnownAdminACLs(user.acls) : [],
|
||||
traits: Array.from(user.traits).sort(),
|
||||
has_totp: user.totpSecret !== null,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
|
||||
@@ -11,9 +11,6 @@ import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorS
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -21,8 +18,6 @@ import type {
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {types} from 'cassandra-driver';
|
||||
@@ -105,75 +100,6 @@ export class AdminUserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async setUserBotStatus(
|
||||
data: SetUserBotStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.bot && user.acls.size > 0) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates['system'] = false;
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserSystemStatus(
|
||||
data: SetUserSystemStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system',
|
||||
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
|
||||
);
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserEmail(
|
||||
data: VerifyUserEmailRequest,
|
||||
adminUserId: UserID,
|
||||
|
||||
@@ -78,7 +78,6 @@ const adminEndpoints: Array<AdminEndpointCase> = [
|
||||
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
|
||||
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
|
||||
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
|
||||
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
|
||||
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
|
||||
|
||||
@@ -21,6 +21,8 @@ interface AdminUserLookupResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
const RETIRED_ACL = 'retired:acl';
|
||||
|
||||
describe('Admin set user ACLs validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -69,4 +71,43 @@ describe('Admin set user ACLs validation', () => {
|
||||
.execute();
|
||||
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
|
||||
});
|
||||
test('lists only registry values when a retired ACL is stored', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
]);
|
||||
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
|
||||
const lookup = await createBuilder<AdminUserLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
|
||||
});
|
||||
test('saves ACLs for an account that holds a retired ACL', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.ACL_SET_USER,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
AdminACLs.USER_VIEW_EMAIL,
|
||||
]);
|
||||
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
|
||||
const result = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${target.userId}/acls`)
|
||||
.body({acls: [AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.user.acls.sort()).toEqual([AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL].sort());
|
||||
});
|
||||
test('returns the current admin when every registry ACL and a retired ACL are stored', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
...Object.values(AdminACLs),
|
||||
RETIRED_ACL,
|
||||
]);
|
||||
const me = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
|
||||
.get('/admin/users/@me')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(me.user.acls).toHaveLength(Object.values(AdminACLs).length);
|
||||
expect(me.user.acls).not.toContain(RETIRED_ACL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,6 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
|
||||
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
|
||||
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
|
||||
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
|
||||
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
|
||||
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
|
||||
];
|
||||
|
||||
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
|
||||
|
||||
@@ -185,39 +185,6 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/bot-status',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
|
||||
expected: {
|
||||
action: 'set_bot_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {bot: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/system-status',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
|
||||
expected: {
|
||||
action: 'set_system_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {system: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/username',
|
||||
|
||||
Reference in New Issue
Block a user