chore(admin): remove user type toggles (#3161)

This commit is contained in:
Hampus
2026-10-03 14:22:13 +02:00
committed by GitHub
parent 81d69c41f5
commit 7fa00c0e89
90 changed files with 67 additions and 627 deletions
@@ -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 -2
View File
@@ -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',