feat(admin,api): restrict community creation on self-hosted (#3055)

This commit is contained in:
omster
2026-09-30 16:32:45 +02:00
committed by GitHub
parent ab4069ed0e
commit ca719e7b5e
95 changed files with 508 additions and 13 deletions
@@ -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,
};
}
+11 -2
View File
@@ -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"
},