mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin,api): restrict community creation on self-hosted (#3055)
This commit is contained in:
@@ -124,6 +124,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
single_community_guild_id: policy.single_community_guild_id,
|
||||
direct_messages_disabled: policy.direct_messages_disabled,
|
||||
direct_messages_locked: policy.direct_messages_locked,
|
||||
guild_create_access: policy.guild_create_access,
|
||||
premium_mode: policy.premium_mode,
|
||||
services: {
|
||||
gif_enabled: policy.gif_enabled,
|
||||
@@ -831,6 +832,9 @@ function planInstancePolicyPatch(
|
||||
patch.direct_messages_locked = true;
|
||||
}
|
||||
}
|
||||
if (policy.guild_create_access !== undefined && policy.guild_create_access !== current.guild_create_access) {
|
||||
patch.guild_create_access = policy.guild_create_access;
|
||||
}
|
||||
if (policy.services) {
|
||||
if (policy.services.gif_enabled !== undefined) {
|
||||
patch.gif_enabled = policy.services.gif_enabled ?? null;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const COMMUNITY_CREATOR_TRAIT = 'community_creator';
|
||||
|
||||
interface LimitConfigReadResponse {
|
||||
limit_config: LimitConfigSnapshot;
|
||||
}
|
||||
|
||||
describe('guild creation access on a self-hosted instance', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
const asSelfHosted = async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
};
|
||||
|
||||
const createAdmin = async (): Promise<TestAccount> =>
|
||||
await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
AdminACLs.USER_UPDATE_TRAITS,
|
||||
]);
|
||||
|
||||
const createMember = async (): Promise<TestAccount> =>
|
||||
await setUserACLs(harness, await createTestAccount(harness), []);
|
||||
|
||||
const setGuildCreateAccess = async (admin: TestAccount, enabled: boolean): Promise<void> => {
|
||||
const updated = await createBuilder<InstanceConfigResponse>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({policy: {guild_create_access: enabled}})
|
||||
.execute();
|
||||
expect(updated.policy.guild_create_access).toBe(enabled);
|
||||
};
|
||||
|
||||
const readInstanceConfig = async (admin: TestAccount): Promise<InstanceConfigResponse> =>
|
||||
await createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
|
||||
|
||||
const createGuild = (account: TestAccount, name: string) =>
|
||||
createBuilder<GuildResponse>(harness, account.token).post('/guilds').body({name});
|
||||
|
||||
const grantGuildCreateToTrait = async (admin: TestAccount, trait: string): Promise<void> => {
|
||||
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({
|
||||
limit_config: {
|
||||
traitDefinitions: [...current.limit_config.traitDefinitions, trait],
|
||||
rules: [
|
||||
...current.limit_config.rules,
|
||||
{id: `grant_${trait}`, filters: {traits: [trait]}, limits: {feature_guild_create: 1}},
|
||||
],
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const grantTrait = async (admin: TestAccount, account: TestAccount, trait: string): Promise<void> => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${account.userId}/traits`)
|
||||
.body({traits: [trait]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
};
|
||||
|
||||
it('allows guild creation while the community creation policy is at its default', async () => {
|
||||
const admin = await createAdmin();
|
||||
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(true);
|
||||
|
||||
const member = await createMember();
|
||||
await asSelfHosted(async () => {
|
||||
const guild = await createGuild(member, 'Default policy community').execute();
|
||||
expect(guild.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a disabled policy and rejects guild creation for a member without a grant', async () => {
|
||||
const admin = await createAdmin();
|
||||
await setGuildCreateAccess(admin, false);
|
||||
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(false);
|
||||
|
||||
const member = await createMember();
|
||||
await asSelfHosted(async () => {
|
||||
await createGuild(member, 'Denied community')
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows guild creation only once a member holds the trait the grant rule targets', async () => {
|
||||
const admin = await createAdmin();
|
||||
await setGuildCreateAccess(admin, false);
|
||||
await grantGuildCreateToTrait(admin, COMMUNITY_CREATOR_TRAIT);
|
||||
|
||||
const member = await createMember();
|
||||
await asSelfHosted(async () => {
|
||||
await createGuild(member, 'Ungranted community')
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
await grantTrait(admin, member, COMMUNITY_CREATOR_TRAIT);
|
||||
await asSelfHosted(async () => {
|
||||
const guild = await createGuild(member, 'Granted community').execute();
|
||||
expect(guild.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows guild creation for a member holding a wildcard ACL while the policy is disabled', async () => {
|
||||
const admin = await createAdmin();
|
||||
await setGuildCreateAccess(admin, false);
|
||||
|
||||
const member = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
|
||||
await asSelfHosted(async () => {
|
||||
const guild = await createGuild(member, 'Wildcard community').execute();
|
||||
expect(guild.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -11,6 +14,8 @@ import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
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 {GuildCreationPermissionRequiredError} from '@fluxer/errors/src/domains/guild/GuildCreationPermissionRequiredError';
|
||||
import {SingleCommunityCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotCreateGuildsError';
|
||||
import {SingleCommunityCannotDeleteError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotDeleteError';
|
||||
import {SingleCommunityCannotLeaveError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotLeaveError';
|
||||
@@ -40,7 +45,8 @@ export function GuildBaseController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'create_guild',
|
||||
summary: 'Create guild',
|
||||
description: 'Only claimed, email-verified non-bot users can create guilds.',
|
||||
description:
|
||||
'Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
@@ -56,6 +62,19 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (!user.isUnclaimedAccount()) {
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
}
|
||||
if (Config.instance.selfHosted && !policy.guild_create_access) {
|
||||
const granted =
|
||||
user.acls.has(AdminACLs.WILDCARD) ||
|
||||
resolveLimitSafe(
|
||||
ctx.get('limitConfigService').getConfigSnapshot(),
|
||||
createLimitMatchContext({user}),
|
||||
'feature_guild_create',
|
||||
0,
|
||||
) > 0;
|
||||
if (!granted) {
|
||||
throw new GuildCreationPermissionRequiredError();
|
||||
}
|
||||
}
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface InstancePolicyConfig {
|
||||
direct_messages_disabled: boolean;
|
||||
direct_messages_locked: boolean;
|
||||
premium_mode: InstancePremiumMode;
|
||||
guild_create_access: boolean;
|
||||
gif_enabled: boolean | null;
|
||||
youtube_enabled: boolean | null;
|
||||
bluesky_enabled: boolean | null;
|
||||
@@ -647,6 +648,7 @@ const StoredInstancePolicySchema = z.object({
|
||||
direct_messages_disabled: InstancePolicyUpdateSchema.shape.direct_messages_disabled.default(false),
|
||||
direct_messages_locked: z.boolean().default(false),
|
||||
premium_mode: InstancePolicyUpdateSchema.shape.premium_mode.default('everyone'),
|
||||
guild_create_access: InstancePolicyUpdateSchema.shape.guild_create_access.default(true),
|
||||
gif_enabled: InstancePolicyServiceUpdateSchema.shape.gif_enabled.default(null),
|
||||
youtube_enabled: InstancePolicyServiceUpdateSchema.shape.youtube_enabled.default(null),
|
||||
bluesky_enabled: InstancePolicyServiceUpdateSchema.shape.bluesky_enabled.default(null),
|
||||
@@ -1773,6 +1775,7 @@ export class InstanceConfigRepository {
|
||||
single_community: policy.single_community_enabled,
|
||||
single_community_guild_id: policy.single_community_enabled ? policy.single_community_guild_id : null,
|
||||
direct_messages_disabled: policy.direct_messages_disabled,
|
||||
guild_create_access: policy.guild_create_access,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6236,7 +6236,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Only claimed, email-verified non-bot users can create guilds.",
|
||||
"description": "Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -30259,9 +30259,18 @@
|
||||
"direct_messages_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether direct messages and friend requests are disabled instance-wide"
|
||||
},
|
||||
"guild_create_access": {
|
||||
"type": "boolean",
|
||||
"description": "Whether every account can create communities. When false, only admins and accounts granted the feature_guild_create limit can"
|
||||
}
|
||||
},
|
||||
"required": ["single_community", "single_community_guild_id", "direct_messages_disabled"],
|
||||
"required": [
|
||||
"single_community",
|
||||
"single_community_guild_id",
|
||||
"direct_messages_disabled",
|
||||
"guild_create_access"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Community topology and direct-message policy for this instance"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user