diff --git a/.changeset/icy-doodles-create.md b/.changeset/icy-doodles-create.md new file mode 100644 index 00000000..38f27b20 --- /dev/null +++ b/.changeset/icy-doodles-create.md @@ -0,0 +1,5 @@ +--- +"draupnir": patch +--- + +Add version command for appservice mode. diff --git a/.changeset/orange-pots-win.md b/.changeset/orange-pots-win.md new file mode 100644 index 00000000..47a01cb6 --- /dev/null +++ b/.changeset/orange-pots-win.md @@ -0,0 +1,6 @@ +--- +"draupnir": major +--- + +Require Self Service Provisioning to be Manually Enabled in config. Breaks +existing workflows. diff --git a/.changeset/sunny-cooks-jump.md b/.changeset/sunny-cooks-jump.md new file mode 100644 index 00000000..33a21eb4 --- /dev/null +++ b/.changeset/sunny-cooks-jump.md @@ -0,0 +1,5 @@ +--- +"draupnir": minor +--- + +Add appservice provision limits and add appservice force provision command. diff --git a/apps/draupnir/src/appservice/AppService.ts b/apps/draupnir/src/appservice/AppService.ts index b6c51b4e..c955e1f6 100644 --- a/apps/draupnir/src/appservice/AppService.ts +++ b/apps/draupnir/src/appservice/AppService.ts @@ -1,5 +1,6 @@ // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 // @@ -185,6 +186,7 @@ export class MjolnirAppService { const serverName = config.homeserver.domain; const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager( serverName, + config.maxDraupnirsPerUser ?? 1, dataStore, bridge, accessControl, @@ -263,60 +265,101 @@ export class MjolnirAppService { log.info( `${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a draupnir` ); - // Join the room and try to send the welcome flow + const client = this.bridge.getBot().getClient(); try { - await this.bridge.getBot().getClient().joinRoom(mxEvent.room_id); - await this.bridge - .getBot() - .getClient() - .sendText( + // Join the room so we can notify the requester and then reject the invite. + try { + await client.joinRoom(mxEvent.room_id); + } catch (e: unknown) { + log.error( + `Failed to join the room by ${mxEvent.sender} to process provisioning invite`, + e + ); + return; + } + + if (!this.config.allowSelfServiceProvisioning) { + await client + .sendText( + mxEvent.room_id, + "Self-service provisioning is disabled. Please ask an admin to provision a Draupnir for you." + ) + .catch((e: unknown) => { + log.error( + `Failed to notify ${mxEvent.sender} that self-service provisioning is disabled`, + e + ); + }); + return; + } + + try { + await client.sendText( mxEvent.room_id, "Your Draupnir is currently being provisioned. Please wait while we set up the rooms." ); - } catch (e: unknown) { - log.error( - `Failed to join the room by ${mxEvent.sender} to display the welcome flow`, - e - ); - } - try { - const result = await this.draupnirManager.provisionNewDraupnir( - mxEvent.sender as StringUserID - ); - if (isError(result)) { + } catch (e: unknown) { + log.error( + `Failed to send provisioning welcome flow to ${mxEvent.sender}; aborting provisioning`, + e + ); + // We don´t want to continue with provisioning because we don´t have a working communications channel with the user. + return; + } + // Ideallly we need to rework provisionNewDraupnir because its current state does not catch all expected errors. + let provisioningFailed = false; + try { + const provisionResult = await this.draupnirManager.provisionNewDraupnir( + mxEvent.sender as StringUserID + ); + if (isError(provisionResult)) { + log.error( + `Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`, + provisionResult.error + ); + provisioningFailed = true; + } + } catch (e: unknown) { log.error( `Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`, - result.error + e + ); + provisioningFailed = true; + } + + if (provisioningFailed) { + try { + await client.sendText( + mxEvent.room_id, + "Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected." + ); + } catch (e: unknown) { + log.error( + `Failed to send provisioning failure flow to ${mxEvent.sender}`, + e + ); + } + return; + } + // Send a notice that the invite must be accepted. + try { + await client.sendText( + mxEvent.room_id, + "Please accept the invitations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future." + ); + } catch (e: unknown) { + log.error( + `Failed to send provisioning success flow to ${mxEvent.sender}`, + e ); } - // Send a notice that the invite must be accepted - await this.bridge - .getBot() - .getClient() - .sendText( - mxEvent.room_id, - "Please accept the inviations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future." - ); - } catch (e: unknown) { - log.error( - `Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, - e - ); - // continue, we still want to reject this invitation. - // Send a notice that the provisioning failed - await this.bridge - .getBot() - .getClient() - .sendText( - mxEvent.room_id, - "Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected." - ); - } - try { - // reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their draupnir. - await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id); - } catch (e: unknown) { - log.warn("Unable to reject an invite to a room", e); + } finally { + try { + // Reject the invite to keep the room clean and make sure the invitee doesn't get confused and think this is their draupnir. + await client.leaveRoom(mxEvent.room_id); + } catch (e: unknown) { + log.warn("Unable to reject an invite to a room", e); + } } } diff --git a/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts b/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts index 9883a966..dbacff52 100644 --- a/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts +++ b/apps/draupnir/src/appservice/AppServiceDraupnirManager.ts @@ -1,5 +1,6 @@ // Copyright 2022 - 2024 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 // @@ -66,6 +67,7 @@ export class AppServiceDraupnirManager { private constructor( private readonly serverName: string, + private readonly maxDraupnirsPerUser: number, private readonly dataStore: DataStore, private readonly bridge: Bridge, private readonly accessControl: AccessControl, @@ -103,6 +105,7 @@ export class AppServiceDraupnirManager { */ public static async makeDraupnirManager( serverName: string, + maxDraupnirsPerUser: number, dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, @@ -115,6 +118,7 @@ export class AppServiceDraupnirManager { ): Promise { const draupnirManager = new AppServiceDraupnirManager( serverName, + maxDraupnirsPerUser, dataStore, bridge, accessControl, @@ -199,6 +203,19 @@ export class AppServiceDraupnirManager { */ public async provisionNewDraupnir( requestingUserID: StringUserID + ): Promise> { + return await this.provisionNewDraupnirInternal(requestingUserID, false); + } + + public async provisionNewDraupnirBypassingUserLimit( + requestingUserID: StringUserID + ): Promise> { + return await this.provisionNewDraupnirInternal(requestingUserID, true); + } + + private async provisionNewDraupnirInternal( + requestingUserID: StringUserID, + bypassUserLimit: boolean ): Promise> { const access = this.accessControl.getUserAccess(requestingUserID); if (access.outcome !== Access.Allowed) { @@ -208,7 +225,10 @@ export class AppServiceDraupnirManager { } const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID); - if (provisionedMjolnirs.length === 0) { + if ( + bypassUserLimit || + provisionedMjolnirs.length < this.maxDraupnirsPerUser + ) { const mjolnirLocalPart = `draupnir_${randomUUID()}`; const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); const draupnirUserID = StringUserID(mjIntent.userId); @@ -263,7 +283,7 @@ export class AppServiceDraupnirManager { return Ok(record); } else { return ActionError.Result( - `User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.` + `User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs, which meets the configured limit of ${this.maxDraupnirsPerUser}.` ); } } diff --git a/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts b/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts index cf000120..263a7b4a 100644 --- a/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts +++ b/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2024 Gnuxie +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: Apache-2.0 // @@ -17,10 +18,14 @@ import { AppserviceListUnstartedCommand, AppserviceRestartDraupnirCommand, } from "./ListCommand"; +import { AppserviceProvisionForUserCommand } from "./ProvisionCommand"; +import { AppserviceVersionCommand } from "./VersionCommand"; AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"]) .internCommand(AppserviceAllowCommand, ["admin", "allow"]) .internCommand(AppserviceRemoveCommand, ["admin", "remove"]) + .internCommand(AppserviceProvisionForUserCommand, ["admin", "provision"]) + .internCommand(AppserviceVersionCommand, ["admin", "version"]) .internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"]) .internCommand(AppserviceListUnstartedCommand, [ "admin", diff --git a/apps/draupnir/src/appservice/bot/ProvisionCommand.tsx b/apps/draupnir/src/appservice/bot/ProvisionCommand.tsx new file mode 100644 index 00000000..54976601 --- /dev/null +++ b/apps/draupnir/src/appservice/bot/ProvisionCommand.tsx @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; +import { ActionResult, isError, Ok } from "matrix-protection-suite"; +import { + MatrixUserIDPresentationType, + describeCommand, + tuple, +} from "@the-draupnir-project/interface-manager"; +import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; + +export const AppserviceProvisionForUserCommand = describeCommand({ + parameters: tuple({ + name: "user", + acceptor: MatrixUserIDPresentationType, + description: + "The user to provision a bot for, bypassing user allocation limit", + }), + summary: + "Provision a new Draupnir for a user while bypassing the per-user allocation limit.", + async executor( + context: AppserviceAdaptorContext, + _info, + _keywords, + _rest, + user + ): Promise> { + const result = + await context.appservice.draupnirManager.provisionNewDraupnirBypassingUserLimit( + user.toString() + ); + if (isError(result)) { + return result; + } + return Ok(undefined); + }, +}); + +AppserviceBotInterfaceAdaptor.describeRenderer( + AppserviceProvisionForUserCommand, + { + isAlwaysSupposedToUseDefaultRenderer: true, + } +); diff --git a/apps/draupnir/src/appservice/bot/VersionCommand.tsx b/apps/draupnir/src/appservice/bot/VersionCommand.tsx new file mode 100644 index 00000000..5fde85b3 --- /dev/null +++ b/apps/draupnir/src/appservice/bot/VersionCommand.tsx @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; +import { ActionResult, Ok, isError } from "matrix-protection-suite"; +import { + DeadDocumentJSX, + describeCommand, +} from "@the-draupnir-project/interface-manager"; +import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; +import { CURRENT_BRANCH, SOFTWARE_VERSION } from "../../config"; + +type AppserviceVersionInfo = { + version: string; + branch: string; +}; + +export const AppserviceVersionCommand = describeCommand({ + summary: + "Show Draupnir version and branch information for this appservice deployment.", + parameters: [], + async executor( + _context: AppserviceAdaptorContext + ): Promise> { + return Ok({ + version: SOFTWARE_VERSION, + branch: CURRENT_BRANCH, + }); + }, +}); + +AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceVersionCommand, { + JSXRenderer(result) { + if (isError(result)) { + return Ok(undefined); + } + return Ok( + + Version: + {result.ok.version} +
+ Branch: + {result.ok.branch} +
+ ); + }, +}); diff --git a/apps/draupnir/src/appservice/config/config.example.yaml b/apps/draupnir/src/appservice/config/config.example.yaml index 196b454f..45850c9d 100644 --- a/apps/draupnir/src/appservice/config/config.example.yaml +++ b/apps/draupnir/src/appservice/config/config.example.yaml @@ -16,5 +16,14 @@ adminRoom: "#draupnir-admin:localhost:9999" # The directory the bot should store various bits of information in dataPath: "/data/storage" +# Maximum number of bots each user can provision. +# Defaults to 1 when omitted. +maxDraupnirsPerUser: 1 + +# Allow users to self-provision by inviting the appservice bot. +# When false, provisioning must be done through admin commands. +# Defaults to false when omitted. +allowSelfServiceProvisioning: false + roomStateBackingStore: enabled: false diff --git a/apps/draupnir/src/appservice/config/config.ts b/apps/draupnir/src/appservice/config/config.ts index c5707ed2..66cd7e1f 100644 --- a/apps/draupnir/src/appservice/config/config.ts +++ b/apps/draupnir/src/appservice/config/config.ts @@ -1,5 +1,6 @@ // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 // @@ -100,5 +101,20 @@ export const AppserviceConfig = Type.Object({ "A directory where the appservice can storestore persistent data.", default: "/data/storage", }), + maxDraupnirsPerUser: Type.Optional( + Type.Integer({ + description: + "Maximum number of Draupnir instances a single user may provision.", + minimum: 1, + default: 1, + }) + ), + allowSelfServiceProvisioning: Type.Optional( + Type.Boolean({ + description: + "Allow users to self-provision by inviting the appservice bot. When false, provisioning must be done through admin commands.", + default: false, + }) + ), logging: Type.Optional(LoggingOptsSchema), }); diff --git a/apps/draupnir/test/appservice/integration/provisionTest.ts b/apps/draupnir/test/appservice/integration/provisionTest.ts index 00f4c30c..bb29cde5 100644 --- a/apps/draupnir/test/appservice/integration/provisionTest.ts +++ b/apps/draupnir/test/appservice/integration/provisionTest.ts @@ -1,5 +1,6 @@ // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 // @@ -8,20 +9,26 @@ // https://github.com/matrix-org/mjolnir // -import { readTestConfig, setupHarness } from "../utils/harness"; +import { readTestConfig, setupHarnessWithConfig } from "../utils/harness"; import { newTestUser } from "../../integration/clientHelper"; import { getFirstReply } from "../../integration/commands/commandUtils"; import { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { MjolnirAppService } from "../../../src/appservice/AppService"; +import { isOk } from "matrix-protection-suite"; +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; interface Context extends Mocha.Context { + // FIXME: Ideally the matrix client interface would implement [Symbol.dispose] + // to call stop and then we could use await using in the tests. moderator?: MatrixClient; + allowedUser?: MatrixClient; appservice?: MjolnirAppService | undefined; } describe("Test that the app service can provision a draupnir on invite of the appservice bot", function () { afterEach(function (this: Context) { this.moderator?.stop(); + this.allowedUser?.stop(); if (this.appservice) { return this.appservice.close(); } else { @@ -31,7 +38,8 @@ describe("Test that the app service can provision a draupnir on invite of the ap }); it("A moderator that requests a draupnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { const config = readTestConfig(); - this.appservice = await setupHarness(); + config.allowSelfServiceProvisioning = true; + this.appservice = await setupHarnessWithConfig(config); const appservice = this.appservice; // create a user to act as the moderator const moderator = await newTestUser(config.homeserver.url, { @@ -76,4 +84,107 @@ describe("Test that the app service can provision a draupnir on invite of the ap }); }); }); + + it("Users cannot self-service provision when self service provisioning is disabled", async function (this: Context) { + const config = readTestConfig(); + config.allowSelfServiceProvisioning = false; + this.appservice = await setupHarnessWithConfig(config); + const appservice = this.appservice; + const moderator = await newTestUser(config.homeserver.url, { + name: { contains: "self-service-disabled" }, + }); + this.moderator = moderator; + const roomWeWantProtecting = await moderator.createRoom(); + const roomsInvitedTo: string[] = []; + + moderator.on("room.invite", (roomId: string) => { + roomsInvitedTo.push(roomId); + }); + + await moderator.start(); + await moderator.inviteUser( + appservice.bridge.getBot().getUserId(), + roomWeWantProtecting + ); + + // Give the appservice time to process the invite and ensure no management/policy rooms were created. + await new Promise((resolve) => setTimeout(resolve, 3000)); + + if (roomsInvitedTo.length !== 0) { + throw new TypeError( + `Expected no self-service provisioning invites when disabled, got ${roomsInvitedTo.length}` + ); + } + }); + + it("Allows provisioning multiple bots per user up to maxDraupnirsPerUser", async function (this: Context) { + const config = readTestConfig(); + config.maxDraupnirsPerUser = 2; + this.appservice = await setupHarnessWithConfig(config); + const appservice = this.appservice; + const allowedUser = await newTestUser(config.homeserver.url, { + name: { contains: "multi-allowed" }, + }); + this.allowedUser = allowedUser; + const allowedUserID = (await allowedUser.getUserId()) as StringUserID; + + (await appservice.accessControl.allow(allowedUserID)).expect( + "Failed to allow user to provision bot" + ); + + ( + await appservice.draupnirManager.provisionNewDraupnir(allowedUserID) + ).expect("Failed to provision first bot for user"); + + ( + await appservice.draupnirManager.provisionNewDraupnir(allowedUserID) + ).expect("Failed to provision second bot for user"); + + const thirdProvisionResult = + await appservice.draupnirManager.provisionNewDraupnir(allowedUserID); + if (isOk(thirdProvisionResult)) { + throw new TypeError( + `Expected provisioning to fail after reaching limit for user ${allowedUserID}` + ); + } + + allowedUser.stop(); + }); + + it("Admin provisioning path can bypass per-user allocation limit", async function (this: Context) { + const config = readTestConfig(); + config.maxDraupnirsPerUser = 1; + this.appservice = await setupHarnessWithConfig(config); + const appservice = this.appservice; + const allowedUser = await newTestUser(config.homeserver.url, { + name: { contains: "admin-bypass-allowed" }, + }); + this.allowedUser = allowedUser; + const allowedUserID = (await allowedUser.getUserId()) as StringUserID; + + (await appservice.accessControl.allow(allowedUserID)).expect( + "Failed to allow admin bypass user" + ); + + ( + await appservice.draupnirManager.provisionNewDraupnir(allowedUserID) + ).expect("Failed to provision initial bot for admin bypass user"); + + ( + await appservice.commands.sendTextCommand( + "@test-admin:localhost:9999" as StringUserID, + `!admin provision ${allowedUserID}` + ) + ).expect("Failed to provision bot while bypassing user limit"); + + const ownedDraupnirs = + await appservice.draupnirManager.getOwnedDraupnir(allowedUserID); + if (ownedDraupnirs.length !== 2) { + throw new TypeError( + `Expected 2 draupnirs after admin bypass, got ${ownedDraupnirs.length}` + ); + } + + allowedUser.stop(); + }); }); diff --git a/apps/draupnir/test/appservice/integration/versionAndBranchDraupnirTest.ts b/apps/draupnir/test/appservice/integration/versionAndBranchDraupnirTest.ts new file mode 100644 index 00000000..90ad06ec --- /dev/null +++ b/apps/draupnir/test/appservice/integration/versionAndBranchDraupnirTest.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: AFL-3.0 + +import expect from "expect"; +import { MjolnirAppService } from "../../../src/appservice/AppService"; +import { setupHarness } from "../utils/harness"; +import { isError } from "matrix-protection-suite"; +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; + +interface Context extends Mocha.Context { + appservice?: MjolnirAppService; +} + +describe("Test appservice version command", function () { + beforeEach(async function (this: Context) { + this.appservice = await setupHarness(); + }); + afterEach(function (this: Context) { + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + + it("Can return version and branch from the appservice admin room", async function (this: Context) { + const appservice = this.appservice; + if (appservice === undefined) { + throw new TypeError(`Test setup failed`); + } + const result = await appservice.commands.sendTextCommand( + "@test:localhost:9999" as StringUserID, + "!admin version" + ); + if (isError(result)) { + throw new TypeError(`Command should have succeeded`); + } + expect(result.ok).toHaveProperty("version"); + expect(result.ok).toHaveProperty("branch"); + }); +}); diff --git a/apps/draupnir/test/appservice/utils/harness.ts b/apps/draupnir/test/appservice/utils/harness.ts index 4e432b9b..80392257 100644 --- a/apps/draupnir/test/appservice/utils/harness.ts +++ b/apps/draupnir/test/appservice/utils/harness.ts @@ -1,5 +1,6 @@ // Copyright 2022 Gnuxie // Copyright 2022 The Matrix.org Foundation C.I.C. +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 // @@ -28,8 +29,15 @@ export function readTestConfig(): AppserviceConfig { export async function setupHarness(): Promise { const config = readTestConfig(); + return await setupHarnessWithConfig(config); +} + +export async function setupHarnessWithConfig( + config: AppserviceConfig +): Promise { const utilityUser = await newTestUser(config.homeserver.url, { name: { contains: "utility" }, + isAdmin: true, }); if ( typeof config.adminRoom !== "string" || @@ -39,7 +47,19 @@ export async function setupHarness(): Promise { "This test expects the harness config to have a room alias." ); } - await ensureAliasedRoomExists(utilityUser, config.adminRoom); + await utilityUser + .deleteRoomAlias(config.adminRoom) + // Ignore if alias doesn't exist yet. + .catch((_: unknown) => undefined); + const adminRoomID = await ensureAliasedRoomExists( + utilityUser, + config.adminRoom + ); + await utilityUser.setUserPowerLevel( + "@draupnir-moderation:localhost:9999", + adminRoomID, + 100 + ); return await MjolnirAppService.run( 9000, config,