Allow GitLab connections without hook permissions (#567)

* Allow GitLab connections without hook permissions

Warn instead of fail when connecting a GitLab project that Hookshot
cannot provision a webhook for.

* Mention manual "Secret token" for GitLab webhooks

* Refactor warning pane into a separate component

* Recolour warning pane for better contrast
This commit is contained in:
Andrew Ferrazzutti 2022-11-08 10:19:41 -05:00 committed by GitHub
parent 7fc0b5b8ed
commit db8221b60a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 91 additions and 36 deletions

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

@ -0,0 +1 @@
Allow adding connections to GitLab projects even when Hookshot doesn't have permissions to automatically provision a webhook for it. When that occurs, tell the user to ask a project admin to add the webhook.

View File

@ -14,8 +14,8 @@ To set up a connection to a GitLab project in a new room:
3. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). 3. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state).
4. Send the command `!hookshot gitlab project https://mydomain/my/project`. 4. Send the command `!hookshot gitlab project https://mydomain/my/project`.
5. If you have permission to bridge this repo, the bridge will respond with a confirmation message. (Users with `Developer` permissions or greater can bridge projects.) 5. If you have permission to bridge this repo, the bridge will respond with a confirmation message. (Users with `Developer` permissions or greater can bridge projects.)
6. If you have configured the bridge with a `publicUrl` inside `gitlab.webhook`, it will automatically provision the webhook for you. 6. If you have configured the bridge with a `publicUrl` inside `gitlab.webhook`, and you have `Maintainer` permissions or greater on the project, the bot will automatically provision the webhook for you.
7. Otherwise, you'll need to manually configure the webhook to point to your public address for the webhooks listener. 7. Otherwise, you'll need to manually configure the project with a webhook that points to your public address for the webhooks listener, sets the "Secret token" to the one you put in your Hookshot configuration (`gitlab.webhook.secret`), and enables all Triggers that need to be bridged (as Hookshot can only bridge events for enabled Triggers). This can be configured on the GitLab webpage for the project under Settings > Webhook Settings. If you do not have access to this page, you must ask someone who does (i.e. someone with at least `Maintainer` permissions on the project) to add the webhook for you.
## Configuration ## Configuration

View File

@ -68,14 +68,14 @@ export class ConnectionManager extends EventEmitter {
* @param data The data corresponding to the connection state. This will be validated. * @param data The data corresponding to the connection state. This will be validated.
* @returns The resulting connection. * @returns The resulting connection.
*/ */
public async provisionConnection(roomId: string, userId: string, type: string, data: Record<string, unknown>): Promise<IConnection> { public async provisionConnection(roomId: string, userId: string, type: string, data: Record<string, unknown>) {
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`); log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`);
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type)); const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type));
if (connectionType?.provisionConnection) { if (connectionType?.provisionConnection) {
if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) { if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) {
throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser); throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser);
} }
const { connection } = await connectionType.provisionConnection(roomId, userId, data, { const result = await connectionType.provisionConnection(roomId, userId, data, {
as: this.as, as: this.as,
config: this.config, config: this.config,
tokenStore: this.tokenStore, tokenStore: this.tokenStore,
@ -85,8 +85,8 @@ export class ConnectionManager extends EventEmitter {
github: this.github, github: this.github,
getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this), getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this),
}); });
this.push(connection); this.push(result.connection);
return connection; return result;
} }
throw new ApiError(`Connection type not known`); throw new ApiError(`Connection type not known`);
} }

View File

@ -10,7 +10,7 @@ import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes";
import { CommandConnection } from "./CommandConnection"; import { CommandConnection } from "./CommandConnection";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { ErrCode, ApiError, ValidatorApiError } from "../api" import { ErrCode, ApiError, ValidatorApiError } from "../api"
import { AccessLevel } from "../Gitlab/Types"; import { AccessLevel } from "../Gitlab/Types";
import Ajv, { JSONSchemaType } from "ajv"; import Ajv, { JSONSchemaType } from "ajv";
@ -217,7 +217,9 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
} }
// Try to setup a webhook // Try to setup a webhook
if (gitlabConfig.webhook.publicUrl) { // Requires at least a "Maintainer" role: https://docs.gitlab.com/ee/user/permissions.html
let warning: ConnectionWarning | undefined;
if (gitlabConfig.webhook.publicUrl && permissionLevel >= AccessLevel.Maintainer) {
const hooks = await client.projects.hooks.list(project.id); const hooks = await client.projects.hooks.list(project.id);
const hasHook = hooks.find(h => h.url === gitlabConfig.webhook.publicUrl); const hasHook = hooks.find(h => h.url === gitlabConfig.webhook.publicUrl);
if (!hasHook) { if (!hasHook) {
@ -235,11 +237,17 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
wiki_page_events: true, wiki_page_events: true,
}); });
} }
} else { } else if (!gitlabConfig.webhook.publicUrl) {
log.info(`Not creating webhook, webhookUrl is not defined in config`); log.info(`Not creating webhook, webhookUrl is not defined in config`);
} else {
warning = {
header: "Cannot create webhook",
message: "You have insufficient permissions on this project to provision a webhook for it. Ask a Maintainer or Owner of the project to add the webhook for you.",
};
log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`)
} }
await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData); await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
return {connection}; return {connection, warning};
} }
public static getProvisionerDetails(botUserId: string) { public static getProvisionerDetails(botUserId: string) {

View File

@ -1,6 +1,6 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { GetConnectionsResponseItem } from "../provisioning/api"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config"; import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
@ -80,7 +80,7 @@ export interface IConnection {
export interface ConnectionDeclaration<C extends IConnection = IConnection> { export interface ConnectionDeclaration<C extends IConnection = IConnection> {
EventTypes: string[]; EventTypes: string[];
ServiceCategory: string; ServiceCategory: string;
provisionConnection?: (roomId: string, userId: string, data: Record<string, unknown>, opts: ProvisionConnectionOpts) => Promise<{connection: C}>; provisionConnection?: (roomId: string, userId: string, data: Record<string, unknown>, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>;
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C> createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>
} }

View File

@ -108,9 +108,9 @@ export class SetupConnection extends CommandConnection {
if (!path) { if (!path) {
throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid."); throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid.");
} }
const {connection} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts); const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts);
this.pushConnections(connection); this.pushConnections(connection);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}`); await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : ""));
} }
private async checkJiraLogin(userId: string, urlStr: string) { private async checkJiraLogin(userId: string, urlStr: string) {

View File

@ -134,11 +134,14 @@ export class BridgeWidgetApi {
throw new ApiError("A JSON body must be provided", ErrCode.BadValue); throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
} }
this.connMan.validateCommandPrefix(req.params.roomId, req.body); this.connMan.validateCommandPrefix(req.params.roomId, req.body);
const connection = await this.connMan.provisionConnection(req.params.roomId as string, req.userId, req.params.type as string, req.body as Record<string, unknown>); const result = await this.connMan.provisionConnection(req.params.roomId, req.userId, req.params.type, req.body);
if (!connection.getProvisionerDetails) { if (!result.connection.getProvisionerDetails) {
throw new Error('Connection supported provisioning but not getProvisionerDetails'); throw new Error('Connection supported provisioning but not getProvisionerDetails');
} }
res.send(connection.getProvisionerDetails(true)); res.send({
...result.connection.getProvisionerDetails(true),
warning: result.warning,
});
} catch (ex) { } catch (ex) {
log.error(`Failed to create connection for ${req.params.roomId}`, ex); log.error(`Failed to create connection for ${req.params.roomId}`, ex);
throw ex; throw ex;

View File

@ -9,11 +9,17 @@ export interface GetConnectionTypeResponseItem {
botUserId: string; botUserId: string;
} }
export interface ConnectionWarning {
header: string,
message: string,
}
export interface GetConnectionsResponseItem<Config = object, Secrets = object> extends GetConnectionTypeResponseItem { export interface GetConnectionsResponseItem<Config = object, Secrets = object> extends GetConnectionTypeResponseItem {
id: string; id: string;
config: Config; config: Config;
secrets?: Secrets; secrets?: Secrets;
canEdit?: boolean; canEdit?: boolean;
warning?: ConnectionWarning;
} }
const log = new Logger("Provisioner.api"); const log = new Logger("Provisioner.api");

View File

@ -129,18 +129,21 @@ export class Provisioner {
return res.send(connection.getProvisionerDetails()); return res.send(connection.getProvisionerDetails());
} }
private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record<string, unknown>, {userId: string}>, res: Response, next: NextFunction) { private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record<string, unknown>, {userId: string}>, res: Response<GetConnectionsResponseItem>, next: NextFunction) {
// Need to figure out which connections are available // Need to figure out which connections are available
try { try {
if (!req.body || typeof req.body !== "object") { if (!req.body || typeof req.body !== "object") {
throw new ApiError("A JSON body must be provided.", ErrCode.BadValue); throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
} }
this.connMan.validateCommandPrefix(req.params.roomId, req.body); this.connMan.validateCommandPrefix(req.params.roomId, req.body);
const connection = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body); const result = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body);
if (!connection.getProvisionerDetails) { if (!result.connection.getProvisionerDetails) {
throw new Error('Connection supported provisioning but not getProvisionerDetails.'); throw new Error('Connection supported provisioning but not getProvisionerDetails');
} }
res.send(connection.getProvisionerDetails(true)); res.send({
...result.connection.getProvisionerDetails(true),
warning: result.warning,
});
} catch (ex) { } catch (ex) {
log.error(`Failed to create connection for ${req.params.roomId}`, ex); log.error(`Failed to create connection for ${req.params.roomId}`, ex);
return next(ex); return next(ex);

View File

@ -117,7 +117,7 @@ export class BridgeAPI {
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`); return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`);
} }
async createConnection(roomId: string, type: string, config: IConnectionState) { async createConnection(roomId: string, type: string, config: IConnectionState): Promise<GetConnectionsResponseItem> {
return this.request('POST', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(type)}`, config); return this.request('POST', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(type)}`, config);
} }

View File

@ -1,5 +1,5 @@
import { h, FunctionComponent } from "preact"; import { h, FunctionComponent } from "preact";
import ErrorBadge from "../../icons/warning-badge.svg"; import ErrorBadge from "../../icons/error-badge.svg";
import style from "./ErrorPane.module.scss"; import style from "./ErrorPane.module.scss";
export const ErrorPane: FunctionComponent<{header?: string}> = ({ children, header }) => { export const ErrorPane: FunctionComponent<{header?: string}> = ({ children, header }) => {

View File

@ -0,0 +1,4 @@
.warningPane {
max-width: 480px;
color: #FF812D;
}

View File

@ -0,0 +1,9 @@
import { h, FunctionComponent } from "preact";
import WarningBadge from "../../icons/warning-badge.svg";
import style from "./WarningPane.module.scss";
export const WarningPane: FunctionComponent<{header?: string}> = ({ children, header }) => {
return <div class={`card error ${style.warningPane}`}>
<p><strong><img src={WarningBadge} /> { header || "Problem occured during widget load" }</strong>: {children}</p>
</div>;
};

View File

@ -2,4 +2,5 @@ export * from "./Button";
export * from "./ButtonSet"; export * from "./ButtonSet";
export * from "./ErrorPane"; export * from "./ErrorPane";
export * from "./InputField"; export * from "./InputField";
export * from "./ListItem"; export * from "./ListItem";
export * from "./WarningPane";

View File

@ -1,7 +1,7 @@
import { h, FunctionComponent } from "preact"; import { h, FunctionComponent } from "preact";
import { useCallback, useEffect, useReducer, useState } from "preact/hooks" import { useCallback, useEffect, useReducer, useState } from "preact/hooks"
import { BridgeAPI, BridgeAPIError } from "../../BridgeAPI"; import { BridgeAPI, BridgeAPIError } from "../../BridgeAPI";
import { ErrorPane, ListItem } from "../elements"; import { ErrorPane, ListItem, WarningPane } from "../elements";
import style from "./RoomConfig.module.scss"; import style from "./RoomConfig.module.scss";
import { GetConnectionsResponseItem } from "../../../src/provisioning/api"; import { GetConnectionsResponseItem } from "../../../src/provisioning/api";
import { IConnectionState } from "../../../src/Connections"; import { IConnectionState } from "../../../src/Connections";
@ -34,18 +34,22 @@ interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsRespons
export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) { export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) {
const { api, roomId, type, headerImg, text, listItemName, connectionEventType } = props; const { api, roomId, type, headerImg, text, listItemName, connectionEventType } = props;
const ConnectionConfigComponent = props.connectionConfigComponent; const ConnectionConfigComponent = props.connectionConfigComponent;
const [ error, setError ] = useState<null|{header?: string, message: string}>(null); const [ error, setError ] = useState<null|{header?: string, message: string, isWarning?: boolean, forPrevious?: boolean}>(null);
const [ connections, setConnections ] = useState<ConnectionType[]|null>(null); const [ connections, setConnections ] = useState<ConnectionType[]|null>(null);
const [ serviceConfig, setServiceConfig ] = useState<SConfig|null>(null); const [ serviceConfig, setServiceConfig ] = useState<SConfig|null>(null);
const [ canEditRoom, setCanEditRoom ] = useState<boolean>(false); const [ canEditRoom, setCanEditRoom ] = useState<boolean>(false);
// We need to increment this every time we create a connection in order to properly reset the state. // We need to increment this every time we create a connection in order to properly reset the state.
const [ newConnectionKey, incrementConnectionKey ] = useReducer<number, undefined>(n => n+1, 0); const [ newConnectionKey, incrementConnectionKey ] = useReducer<number, undefined>(n => n+1, 0);
const clearCurrentError = () => {
setError(error => error?.forPrevious ? error : null);
}
useEffect(() => { useEffect(() => {
api.getConnectionsForService<ConnectionType>(roomId, type).then(res => { api.getConnectionsForService<ConnectionType>(roomId, type).then(res => {
setCanEditRoom(res.canEdit); setCanEditRoom(res.canEdit);
setConnections(res.connections); setConnections(res.connections);
setError(null); clearCurrentError();
}).catch(ex => { }).catch(ex => {
console.warn("Failed to fetch existing connections", ex); console.warn("Failed to fetch existing connections", ex);
setError({ setError({
@ -58,9 +62,7 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
useEffect(() => { useEffect(() => {
api.getServiceConfig<SConfig>(type) api.getServiceConfig<SConfig>(type)
.then(setServiceConfig) .then(setServiceConfig)
.then(() => { .then(clearCurrentError)
setError(null);
})
.catch(ex => { .catch(ex => {
console.warn("Failed to fetch service config", ex); console.warn("Failed to fetch service config", ex);
setError({ setError({
@ -71,10 +73,15 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
}, [api, type]); }, [api, type]);
const handleSaveOnCreation = useCallback((config: ConnectionState) => { const handleSaveOnCreation = useCallback((config: ConnectionState) => {
api.createConnection(roomId, connectionEventType, config).then(() => { api.createConnection(roomId, connectionEventType, config).then(result => {
// Force reload // Force reload
incrementConnectionKey(undefined); incrementConnectionKey(undefined);
setError(null); setError(!result.warning ? null : {
header: result.warning.header,
message: result.warning.message,
isWarning: true,
forPrevious: true,
});
}).catch(ex => { }).catch(ex => {
console.warn("Failed to create connection", ex); console.warn("Failed to create connection", ex);
setError({ setError({
@ -86,7 +93,11 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
return <main> return <main>
{ {
error && <ErrorPane header={error.header || "Error"}>{error.message}</ErrorPane> error &&
(!error.isWarning
? <ErrorPane header={error.header || "Error"}>{error.message}</ErrorPane>
: <WarningPane header={error.header || "Warning"}>{error.message}</WarningPane>
)
} }
<header className={style.header}> <header className={style.header}>
<img src={headerImg} /> <img src={headerImg} />

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#FF4B55"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#FF4B55"/> <circle cx="8" cy="8" r="8" fill="#FF812D"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/> <rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/> <rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 271 B

After

Width:  |  Height:  |  Size: 271 B

View File

@ -1,4 +1,8 @@
declare module "*.png" { declare module "*.png" {
const content: string const content: string
export = content export = content
}
declare module "*.svg" {
const content: string
export = content
} }