Add Admin command from listing and disconnecting connections (#367)

* Add Admin command from listing and disconnecting connections

(Only lists Github for now).

* Require connectionManager permissions to manipulate connections

Gives connection management its own config section and switches
AdminRoom categories to be enums.

* Fail more descriptively if connectionManager is not up in time for adminRoom

* Fix Github API URLs (#366)

* Fix Github API URLs

* Add changelog entry

* Fixes

* Tidyup

Co-authored-by: Will Hunt <will@half-shot.uk>
Co-authored-by: Tadeusz Sośnierz <tadeusz@sosnierz.com>

* Fix URLs *again*

* Block private repos from being publically bridged

* Ensure check looks at service type

* Finish up deleting connections impl

Co-authored-by: Tadeusz Sośnierz <tadeusz@sosnierz.com>
Co-authored-by: Will Hunt <will@half-shot.uk>
This commit is contained in:
Tadeusz Sośnierz 2022-06-08 17:50:09 +02:00 committed by GitHub
parent ee2fd5bbcc
commit 86b9a83b97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 143 additions and 19 deletions

1
changelog.d/367.feature Normal file
View File

@ -0,0 +1 @@
Add ability for bridge admins to remove GitHub connections using the admin room.

View File

@ -113,7 +113,7 @@ The `level` can be:
- `login` All the above, and can also log in to the bridge.
- `notifications` All the above, and can also bridge their notifications.
- `manageConnections` All the above, and can create and delete connections (either via the provisioner, setup commands, or state events).
- `admin` All permissions. Currently, there are no admin features so this exists as a placeholder.
- `admin` All permissions. This allows you to perform administrative tasks like deleting connections from all rooms.
When permissions are checked, if a user matches any of the permission set and one
of those grants the right level for a service, they are allowed access. If none of the

View File

@ -5,6 +5,8 @@ import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunctio
import { BridgeConfig, BridgePermissionLevel } from "./Config/Config";
import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface";
import { Endpoints } from "@octokit/types";
import { GitHubDiscussionSpace, GitHubIssueConnection, GitHubRepoConnection } from "./Connections";
import { ConnectionManager } from "./ConnectionManager";
import { FormatUtil } from "./FormatUtil";
import { GetUserResponse } from "./Gitlab/Types";
import { GitHubBotCommands } from "./Github/AdminCommands";
@ -20,6 +22,12 @@ import markdown from "markdown-it";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"];
enum Category {
ConnectionManagement = "Connection Management",
Github = "Github",
Gitlab = "Gitlab",
Jira = "Jira",
}
const md = new markdown();
const log = new LogWrapper('AdminRoom');
@ -44,7 +52,9 @@ export class AdminRoom extends AdminRoomCommandHandler {
notifContent: NotificationFilterStateContent,
botIntent: Intent,
tokenStore: UserTokenStore,
config: BridgeConfig) {
config: BridgeConfig,
private connectionManager: ConnectionManager,
) {
super(botIntent, roomId, tokenStore, config, data);
this.notifFilter = new NotifFilter(notifContent);
}
@ -126,17 +136,40 @@ export class AdminRoom extends AdminRoomCommandHandler {
});
}
@botCommand("help", "This help text")
@botCommand("help", { help: "This help text" })
public async helpCommand() {
const enabledCategories = [
this.config.github ? "github" : "",
this.config.gitlab ? "gitlab" : "",
this.config.jira ? "jira" : "",
this.config.github ? Category.Github : "",
this.config.gitlab ? Category.Gitlab : "",
this.config.jira ? Category.Jira : "",
this.canAdminConnections('github') ? Category.ConnectionManagement : '',
];
return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage(undefined, enabledCategories));
}
@botCommand("github notifications toggle", { help: "Toggle enabling/disabling GitHub notifications in this room", category: "github"})
@botCommand("disconnect", { help: "Remove a connection", requiredArgs: ['roomId', 'id'], category: Category.ConnectionManagement })
public async disconnect(roomId: string, id: string) {
if (!this.canAdminConnections('github')) {
await this.sendNotice("Insufficient permissions.");
return;
}
// it's stupid that we need roomId -- shouldn't `id` identify the connection?
const conn = this.connectionManager.getConnectionById(roomId, id);
if (!conn) {
await this.sendNotice("Connection not found");
return;
}
try {
await this.connectionManager.purgeConnection(conn.roomId, conn.connectionId);
await this.sendNotice('Connection removed successfully');
} catch (err: unknown) {
log.debug(`Failed to purge connection: ${err}`);
await this.sendNotice('Connection could not be removed: see debug logs for details');
}
}
@botCommand("github notifications toggle", { help: "Toggle enabling/disabling GitHub notifications in this room", category: Category.Github})
public async setGitHubNotificationsStateToggle() {
const newData = await this.saveAccountData((data) => {
return {
@ -152,7 +185,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
await this.sendNotice(`${newData.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`);
}
@botCommand("github notifications filter participating", {help: "Toggle enabling/disabling GitHub notifications in this room", category: "github"})
@botCommand("github notifications filter participating", {help: "Toggle enabling/disabling GitHub notifications in this room", category: Category.Github})
private async setGitHubNotificationsStateParticipating() {
const newData = await this.saveAccountData((data) => {
if (!data.github?.notifications?.enabled) {
@ -175,7 +208,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.sendNotice(`Showing all events.`);
}
@botCommand("github notifications", {help: "Show the current notification settings", category: "github"})
@botCommand("github notifications", {help: "Show the current notification settings", category: Category.Github})
public async getGitHubNotificationsState() {
if (!this.notificationsEnabled("github")) {
return this.sendNotice(`Notifications are disabled.`);
@ -183,8 +216,42 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.sendNotice(`Notifications are enabled, ${this.notificationsParticipating("github") ? "Showing only events you are particiapting in." : "Showing all events."}`);
}
@botCommand("github list-connections", {help: "List currently bridged Github rooms", category: Category.ConnectionManagement})
public async listGithubConnections() {
if (!this.config.github) {
return this.sendNotice("The bridge is not configured with GitHub support.");
}
@botCommand("github project list-for-user", {help: "List GitHub projects for a user", optionalArgs:['user', 'repo'], category: "github"})
if (!this.canAdminConnections('github')) {
await this.sendNotice("Insufficient permissions.");
return;
}
const connections = {
repos: this.connectionManager.getAllConnectionsOfType(GitHubRepoConnection),
issues: this.connectionManager.getAllConnectionsOfType(GitHubIssueConnection),
discussions: this.connectionManager.getAllConnectionsOfType(GitHubDiscussionSpace),
};
const reposFormatted = connections.repos.map(c => ` - ${c.org}/${c.repo} (ID: \`${c.connectionId}\`, Room: \`${c.roomId}\`)`).join('\n');
const issuesFormatted = connections.issues.map(c => ` - ${c.org}/${c.repo}/${c.issueNumber} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`).join('\n');
const discussionsFormatted = connections.discussions.map(c => ` - ${c.owner}/${c.repo} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`).join('\n');
const content = [
connections.repos.length > 0 ? `Repositories:\n${reposFormatted}` : '',
connections.issues.length > 0 ? `Issues:\n${issuesFormatted}` : '',
connections.discussions.length > 0 ? `Discussions:\n${discussionsFormatted}` : '',
].join('\n\n') || 'No Github bridges';
return this.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html"
});
}
@botCommand("github project list-for-user", {help: "List GitHub projects for a user", optionalArgs:['user', 'repo'], category: Category.Github})
private async listGitHubProjectsForUser(username?: string, repo?: string) {
if (!this.config.github) {
return this.sendNotice("The bridge is not configured with GitHub support.");
@ -225,7 +292,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
});
}
@botCommand("github project list-for-org", {help: "List GitHub projects for an org", requiredArgs: ['org'], optionalArgs: ['repo'], category: "github"})
@botCommand("github project list-for-org", {help: "List GitHub projects for an org", requiredArgs: ['org'], optionalArgs: ['repo'], category: Category.Github})
private async listGitHubProjectsForOrg(org: string, repo?: string) {
if (!this.config.github) {
return this.sendNotice("The bridge is not configured with GitHub support.");
@ -263,7 +330,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
});
}
@botCommand("github project open", {help: "Open a GitHub project as a room", requiredArgs: ['projectId'], category: "github"})
@botCommand("github project open", {help: "Open a GitHub project as a room", requiredArgs: ['projectId'], category: Category.Github})
private async openProject(projectId: string) {
if (!this.config.github) {
return this.sendNotice("The bridge is not configured with GitHub support.");
@ -287,7 +354,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
}
}
@botCommand("github discussion open", {help: "Open a discussion room", requiredArgs: ['owner', 'repo', 'number'], category: "github"})
@botCommand("github discussion open", {help: "Open a discussion room", requiredArgs: ['owner', 'repo', 'number'], category: Category.Github})
private async listDiscussions(owner: string, repo: string, numberStr: string) {
const number = parseInt(numberStr);
if (!this.config.github) {
@ -313,7 +380,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
/* GitLab commands */
@botCommand("gitlab open issue", {help: "Open or join a issue room for GitLab", requiredArgs: ['url'], category: "gitlab"})
@botCommand("gitlab open issue", {help: "Open or join a issue room for GitLab", requiredArgs: ['url'], category: Category.Gitlab})
private async gitLabOpenIssue(url: string) {
if (!this.config.gitlab) {
return this.sendNotice("The bridge is not configured with GitLab support.");
@ -338,7 +405,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.emit('open.gitlab-issue', getIssueOpts, issue, instanceName, instance);
}
@botCommand("gitlab personaltoken", {help: "Set your personal access token for GitLab", requiredArgs: ['instanceName', 'accessToken'], category: "gitlab"})
@botCommand("gitlab personaltoken", {help: "Set your personal access token for GitLab", requiredArgs: ['instanceName', 'accessToken'], category: Category.Gitlab})
public async setGitLabPersonalAccessToken(instanceName: string, accessToken: string) {
let me: GetUserResponse;
if (!this.config.gitlab) {
@ -359,7 +426,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.tokenStore.storeUserToken("gitlab", this.userId, accessToken, instance.url);
}
@botCommand("gitlab hastoken", {help: "Check if you have a token stored for GitLab", requiredArgs: ["instanceName"], category: "gitlab"})
@botCommand("gitlab hastoken", {help: "Check if you have a token stored for GitLab", requiredArgs: ["instanceName"], category: Category.Gitlab})
public async gitlabHasPersonalToken(instanceName: string) {
if (!this.config.gitlab) {
return this.sendNotice("The bridge is not configured with GitLab support.");
@ -423,6 +490,10 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent());
}
private canAdminConnections(service: string): boolean {
return this.config.checkPermission(this.userId, service, BridgePermissionLevel.admin);
}
private async saveAccountData(updateFn: (record: AdminAccountData) => AdminAccountData) {
let oldData: AdminAccountData|null = await this.botIntent.underlyingClient.getSafeRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, null);
if (!oldData) {

View File

@ -1141,9 +1141,14 @@ export class Bridge {
}
private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) {
if (!this.connectionManager) {
throw Error('setUpAdminRoom() called before connectionManager was ready');
}
const adminRoom = new AdminRoom(
roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config,
roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config, this.connectionManager,
);
adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this));
adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {
const [connection] = this.connectionManager?.getForGitHubProject(project.id) || [];

View File

@ -155,4 +155,16 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
'uk.half-shot.matrix-hookshot.github.discussion.comment_id': data.comment.id,
}, 'm.room.message', intent.userId);
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
}

View File

@ -60,6 +60,9 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
if (!repoRes.owner) {
throw Error('Repo has no owner!');
}
if (repoRes.private) {
throw Error('Refusing to bridge private repo');
}
} catch (ex) {
log.error("Failed to get repo:", ex);
throw Error("Could not find repo");
@ -164,4 +167,17 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
log.info(`Adding connection to ${this.toString()}`);
await this.space.addChildRoom(discussion.roomId);
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey);
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey);
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
}

View File

@ -93,6 +93,9 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
owner,
repo: repoName,
})).data;
if (repo.private) {
throw Error('Refusing to bridge private repo');
}
} catch (ex) {
log.error("Failed to get issue:", ex);
throw Error("Could not find issue");
@ -342,6 +345,18 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
public onIssueStateChange() {
return this.syncIssueState();
}

View File

@ -302,6 +302,9 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
owner,
repo,
})).data;
if (repoRes.private) {
throw Error('Refusing to bridge private repo');
}
} catch (ex) {
log.error("Failed to get repo:", ex);
throw Error("Could not find repo");

View File

@ -2,6 +2,7 @@
import { expect } from "chai";
import { AdminRoom } from "../src/AdminRoom";
import { DefaultConfig } from "../src/Config/Defaults";
import { ConnectionManager } from "../src/ConnectionManager";
import { NotifFilter } from "../src/NotificationFilters";
import { UserTokenStore } from "../src/UserTokenStore";
import { IntentMock } from "./utils/IntentMock";
@ -14,7 +15,7 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In
data.admin_user = "@admin:bar";
}
const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig);
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, DefaultConfig), intent];
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, DefaultConfig, {} as ConnectionManager), intent];
}
describe("AdminRoom", () => {
@ -24,7 +25,7 @@ describe("AdminRoom", () => {
expect(intent.sentEvents).to.have.lengthOf(1);
expect(intent.sentEvents[0]).to.deep.equal({
roomId: ROOM_ID,
content: AdminRoom.helpMessage(undefined, ["github", "gitlab", "jira"]),
content: AdminRoom.helpMessage(undefined, ["Github", "Gitlab", "Jira"]),
});
});
})