Refactor hookshot to include permission grant system (#655)

* Add support for checking connection grants

* Make this less noisy

* Remove import

* Trying to generic-ize the grant system

* Implement for Figma

* More grant reformatting

* Add tests

* changelog

* Fix todo

* Refactor grants

* Add logging to grant checkers

* Ensure we provide a sender
This commit is contained in:
Will Hunt 2023-03-17 16:38:39 +00:00 committed by GitHub
parent 46e1008942
commit 6233aa19c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 731 additions and 96 deletions

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

@ -0,0 +1 @@
Implement grant system to internally record all approved connections in hookshot.

View File

@ -1,6 +1,6 @@
import { AdminAccountData } from "./AdminRoomCommandHandler";
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, IAppserviceRegistration, Intent } from "matrix-bot-sdk";
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, Intent } from "matrix-bot-sdk";
import BotUsersManager from "./Managers/BotUsersManager";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
@ -494,7 +494,7 @@ export class Bridge {
this.tokenStore,
this.commentProcessor,
this.messageClient,
this.config.github,
this.config,
);
connManager.push(discussionConnection);
} catch (ex) {
@ -1294,7 +1294,7 @@ export class Bridge {
adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {
const [connection] = this.connectionManager?.getForGitHubProject(project.id) || [];
if (!connection) {
const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, adminRoom.userId);
const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, this.config, adminRoom.userId);
this.connectionManager?.push(connection);
} else {
await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
@ -1318,7 +1318,7 @@ export class Bridge {
this.tokenStore,
this.commentProcessor,
this.messageClient,
this.config.gitlab,
this.config,
);
this.connectionManager?.push(newConnection);
return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId);

View File

@ -515,15 +515,15 @@ export class BridgeConfig {
@hideKey()
private readonly bridgePermissions: BridgePermissions;
constructor(configData: BridgeConfigRoot, env: {[key: string]: string|undefined}) {
constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) {
this.bridge = configData.bridge;
assert.ok(this.bridge);
this.github = configData.github && new BridgeConfigGitHub(configData.github);
if (this.github?.auth && env["GITHUB_PRIVATE_KEY_FILE"]) {
this.github.auth.privateKeyFile = env["GITHUB_PRIVATE_KEY_FILE"];
if (this.github?.auth && env?.["GITHUB_PRIVATE_KEY_FILE"]) {
this.github.auth.privateKeyFile = env?.["GITHUB_PRIVATE_KEY_FILE"];
}
if (this.github?.oauth && env["GITHUB_OAUTH_REDIRECT_URI"]) {
this.github.oauth.redirect_uri = env["GITHUB_OAUTH_REDIRECT_URI"];
if (this.github?.oauth && env?.["GITHUB_OAUTH_REDIRECT_URI"]) {
this.github.oauth.redirect_uri = env?.["GITHUB_OAUTH_REDIRECT_URI"];
}
this.gitlab = configData.gitlab && new BridgeConfigGitLab(configData.gitlab);
this.figma = configData.figma;
@ -577,10 +577,10 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
}
// TODO: Formalize env support
if (env.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
this.queue.monolithic = false;
this.queue.host = env.CFG_QUEUE_HOST;
this.queue.port = env.CFG_QUEUE_POST ? parseInt(env.CFG_QUEUE_POST, 10) : undefined;
this.queue.host = env?.CFG_QUEUE_HOST;
this.queue.port = env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined;
}
this.goNebMigrator = configData.goNebMigrator;

View File

@ -1,4 +1,4 @@
import { BridgeConfig } from "./Config";
import { BridgeConfig, BridgeConfigRoot } from "./Config";
import YAML from "yaml";
import { getConfigKeyMetadata, keyIsHidden } from "./Decorators";
import { Node, YAMLSeq } from "yaml/types";
@ -8,7 +8,7 @@ import { DefaultDisallowedIpRanges } from "matrix-appservice-bridge";
const serverName = "example.com";
const hookshotWebhooksUrl = "https://example.com";
export const DefaultConfig = new BridgeConfig({
export const DefaultConfigRoot: BridgeConfigRoot = {
bridge: {
domain: serverName,
url: "http://localhost:8008",
@ -147,7 +147,9 @@ export const DefaultConfig = new BridgeConfig({
resources: ['widgets'],
}
]
}, {});
};
export const DefaultConfig = new BridgeConfig(DefaultConfigRoot);
function renderSection(doc: YAML.Document, obj: Record<string, unknown>, parentNode?: YAMLSeq) {
const entries = Object.entries(obj);

View File

@ -166,7 +166,14 @@ export class ConnectionManager extends EventEmitter {
}
}
public createConnectionForState(roomId: string, state: StateEvent<any>, rollbackBadState: boolean) {
/**
* This is called ONLY when we spot new state in a room and want to create a connection for it.
* @param roomId
* @param state
* @param rollbackBadState
* @returns
*/
public async createConnectionForState(roomId: string, state: StateEvent<any>, rollbackBadState: boolean) {
// Empty object == redacted
if (state.content.disabled === true || Object.keys(state.content).length === 0) {
log.debug(`${roomId} has disabled state for ${state.type}`);
@ -188,7 +195,7 @@ export class ConnectionManager extends EventEmitter {
return;
}
return connectionType.createConnectionForState(roomId, state, {
const connection = await connectionType.createConnectionForState(roomId, state, {
as: this.as,
intent: botUser.intent,
config: this.config,
@ -198,8 +205,19 @@ export class ConnectionManager extends EventEmitter {
storage: this.storage,
github: this.github,
});
// Finally, ensure the connection is allowed by us.
await connection.ensureGrant?.(state.sender);
return connection;
}
/**
* This is called when hookshot starts up, or a hookshot service bot has left
* and we need to recalculate the right bots for all the connections in a room.
* @param roomId
* @param rollbackBadState
* @returns
*/
public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) {
const botUser = this.botUsersManager.getBotUserInRoom(roomId);
if (!botUser) {

View File

@ -36,10 +36,10 @@ export abstract class CommandConnection<StateType extends IConnectionState = ICo
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
this.state = this.validateConnectionState(stateEv.content);
this.state = await this.validateConnectionState(stateEv.content);
}
protected abstract validateConnectionState(content: unknown): ValidatedStateType;
protected abstract validateConnectionState(content: unknown): Promise<ValidatedStateType>|ValidatedStateType;
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn) {
const commandResult = await handleCommand(

View File

@ -5,8 +5,9 @@ import { BaseConnection } from "./BaseConnection";
import { IConnection, IConnectionState } from ".";
import { Logger } from "matrix-appservice-bridge";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BridgeConfigFigma } from "../Config/Config";
import { BridgeConfig } from "../Config/Config";
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
const log = new Logger("FigmaFileConnection");
@ -47,7 +48,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
if (!config.figma) {
throw Error('Figma is not configured');
}
return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, intent, storage);
return new FigmaFileConnection(roomId, event.stateKey, event.content, config, as, intent, storage);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, intent, config, storage}: ProvisionConnectionOpts) {
@ -55,7 +56,8 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
throw Error('Figma is not configured');
}
const validState = this.validateState(data);
const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, intent, storage);
const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config, as, intent, storage);
await new GrantChecker(as.botIntent, "figma").grantConnection(roomId, { fileId: validState.fileId, instanceName: validState.instanceName || "none"});
await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState);
return {
connection,
@ -63,12 +65,13 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
}
private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}> = new ConfigGrantChecker("figma", this.as, this.config);
constructor(
roomId: string,
stateKey: string,
private state: FigmaFileConnectionState,
private readonly config: BridgeConfigFigma,
private readonly config: BridgeConfig,
private readonly as: Appservice,
private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider) {
@ -91,6 +94,14 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
return this.state.priority || super.priority;
}
public async ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}, sender);
}
public async onRemove() {
return this.grantChecker.ungrantConnection(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"});
}
public async handleNewComment(payload: FigmaPayload) {
// We need to check if the comment was actually new.
// There isn't a way to tell how the comment has changed, so for now check the timestamps
@ -102,8 +113,8 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
let intent;
if (this.config.overrideUserId) {
intent = this.as.getIntentForUserId(this.config.overrideUserId);
if (this.config.figma?.overrideUserId) {
intent = this.as.getIntentForUserId(this.config.figma.overrideUserId);
} else {
intent = this.intent;
}

View File

@ -12,7 +12,9 @@ import { DiscussionCommentCreatedEvent } from "@octokit/webhooks-types";
import { GithubGraphQLClient } from "../Github/GithubInstance";
import { Logger } from "matrix-appservice-bridge";
import { BaseConnection } from "./BaseConnection";
import { BridgeConfigGitHub } from "../Config/Config";
import { BridgeConfig, BridgeConfigGitHub } from "../Config/Config";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import QuickLRU from "@alloc/quick-lru";
export interface GitHubDiscussionConnectionState {
owner: string;
repo: string;
@ -48,21 +50,20 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
}
return new GitHubDiscussionConnection(
roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor,
messageClient, config.github,
messageClient, config,
);
}
readonly sentEvents = new Set<string>(); //TODO: Set some reasonable limits
public static async createDiscussionRoom(
as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient,
config: BridgeConfigGitHub,
config: BridgeConfig,
) {
const commentIntent = await getIntentForUser({
login: discussion.user.login,
avatarUrl: discussion.user.avatar_url,
}, as, config.userIdPrefix);
}, as, config.github?.userIdPrefix);
const state: GitHubDiscussionConnectionState = {
owner,
repo,
@ -94,9 +95,19 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
format: 'org.matrix.custom.html',
});
await intent.ensureJoined(roomId);
return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config);
}
private static grantKey(state: GitHubDiscussionConnectionState) {
return `${this.CanonicalEventType}/${state.owner}/${state.repo}`;
}
private readonly sentEvents = new QuickLRU<string, undefined>({ maxSize: 128 });
private readonly grantChecker: GrantChecker;
private readonly config: BridgeConfigGitHub;
constructor(
roomId: string,
private readonly as: Appservice,
@ -106,9 +117,14 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
private readonly tokenStore: UserTokenStore,
private readonly commentProcessor: CommentProcessor,
private readonly messageClient: MessageSenderClient,
private readonly config: BridgeConfigGitHub,
bridgeConfig: BridgeConfig,
) {
super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType);
if (!bridgeConfig.github) {
throw Error('Expected github to be enabled in config');
}
this.config = bridgeConfig.github;
this.grantChecker = new ConfigGrantChecker("github", this.as, bridgeConfig);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
@ -125,7 +141,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
const qlClient = new GithubGraphQLClient(octokit);
const commentId = await qlClient.addDiscussionComment(this.state.internalId, ev.content.body);
log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`);
this.sentEvents.add(commentId);
this.sentEvents.set(commentId, undefined);
return true;
}
@ -162,6 +178,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionConnection.grantKey(this.state));
// Do a sanity check that the event exists.
try {
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
@ -171,4 +188,8 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
public async ensureGrant(sender?: string) {
await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender);
}
}

View File

@ -6,6 +6,8 @@ import axios from "axios";
import { GitHubDiscussionConnection } from "./GithubDiscussion";
import { GithubInstance } from "../Github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
const log = new Logger("GitHubDiscussionSpace");
@ -35,12 +37,13 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.grantKey(event.content));
return new GitHubDiscussionSpace(
await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 2) {
log.error(`Invalid alias pattern '${result}'`);
throw Error("Could not find issue");
@ -141,10 +144,19 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
};
}
constructor(public readonly space: Space,
private static grantKey(state: GitHubDiscussionSpaceConnectionState) {
return `${this.CanonicalEventType}/${state.owner}/${state.repo}`;
}
private readonly grantChecker: GrantChecker;
constructor(as: Appservice,
config: BridgeConfig,
public readonly space: Space,
private state: GitHubDiscussionSpaceConnectionState,
stateKey: string) {
super(space.roomId, stateKey, GitHubDiscussionSpace.CanonicalEventType)
this.grantChecker = new ConfigGrantChecker("github", as, config);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
@ -167,9 +179,15 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
log.info(`Adding connection to ${this.toString()}`);
await this.space.addChildRoom(discussion.roomId);
}
public async ensureGrant(sender?: string) {
await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionSpace.grantKey(this.state), sender);
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionSpace.grantKey(this.state));
// Do a sanity check that the event exists.
try {

View File

@ -3,6 +3,8 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { ProjectsGetResponseData } from "../Github/Types";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
export interface GitHubProjectConnectionState {
// eslint-disable-next-line camelcase
@ -28,10 +30,14 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
if (!config.github) {
throw Error('GitHub is not configured');
}
return new GitHubProjectConnection(roomId, as, intent, event.content, event.stateKey);
return new GitHubProjectConnection(roomId, as, intent, config, event.content, event.stateKey);
}
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, inviteUser: string): Promise<GitHubProjectConnection> {
public static getGrantKey(projectId: number) {
return `${this.CanonicalEventType}/${projectId}`;
}
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise<GitHubProjectConnection> {
log.info(`Fetching ${project.name} ${project.id}`);
// URL hack so we don't need to fetch the repo itself.
@ -55,22 +61,31 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
},
],
});
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id));
return new GitHubProjectConnection(roomId, as, intent, state, project.url)
return new GitHubProjectConnection(roomId, as, intent, config, state, project.url)
}
get projectId() {
return this.state.project_id;
}
private readonly grantChecker: GrantChecker;
constructor(
public readonly roomId: string,
as: Appservice,
intent: Intent,
config: BridgeConfig,
private state: GitHubProjectConnectionState,
stateKey: string,
) {
super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType);
this.grantChecker = new ConfigGrantChecker("github", as, config);
}
public ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, GitHubProjectConnection.getGrantKey(this.state.project_id), sender);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {

View File

@ -28,6 +28,8 @@ import { PermissionCheckFn } from ".";
import { MinimalGitHubIssue, MinimalGitHubRepo } from "../libRs";
import Ajv, { JSONSchemaType } from "ajv";
import { HookFilter } from "../HookFilter";
import { GrantChecker } from "../grants/GrantCheck";
import { GitHubGrantChecker } from "../Github/GrantChecker";
const log = new Logger("GitHubRepoConnection");
const md = new markdown();
@ -359,11 +361,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
throw new ValidatorApiError(validator.errors);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
const validData = this.validateState(data);
static async assertUserHasAccessToRepo(userId: string, org: string, repo: string, github: GithubInstance, tokenStore: UserTokenStore) {
const octokit = await tokenStore.getOctokitForUser(userId);
if (!octokit) {
throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser);
@ -371,8 +369,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
const me = await octokit.users.getAuthenticated();
let permissionLevel;
try {
const repo = await octokit.repos.getCollaboratorPermissionLevel({owner: validData.org, repo: validData.repo, username: me.data.login });
permissionLevel = repo.data.permission;
const githubRepo = await octokit.repos.getCollaboratorPermissionLevel({owner: org, repo, username: me.data.login });
permissionLevel = githubRepo.data.permission;
} catch (ex) {
throw new ApiError("Could not determine if the user has access to this repository, does the repository exist?", ErrCode.ForbiddenUser);
}
@ -380,6 +378,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (permissionLevel !== "admin" && permissionLevel !== "write") {
throw new ApiError("You must at least have write permissions to bridge this repository", ErrCode.ForbiddenUser);
}
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
const validData = this.validateState(data);
await this.assertUserHasAccessToRepo(userId, validData.org, validData.repo, github, tokenStore);
const appOctokit = await github.getSafeOctokitForRepo(validData.org, validData.repo);
if (!appOctokit) {
throw new ApiError(
@ -393,6 +399,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
);
}
const stateEventKey = `${validData.org}/${validData.repo}`;
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(validData.org, validData.repo));
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
return {
stateEventContent: validData,
@ -413,7 +420,10 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubRepoConnection(roomId, as, intent, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github);
const connectionState = this.validateState(state.content, true);
return new GitHubRepoConnection(roomId, as, intent, connectionState, tokenStore, state.stateKey, github, config.github);
}
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
@ -501,6 +511,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public debounceOnIssueLabeled = new Map<number, {labels: Set<string>, timeout: NodeJS.Timeout}>();
private readonly grantChecker = new GitHubGrantChecker(this.as, this.githubInstance, this.tokenStore);
constructor(
roomId: string,
private readonly as: Appservice,
@ -557,8 +569,15 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
return this.state.priority || super.priority;
}
protected validateConnectionState(content: unknown) {
return GitHubRepoConnection.validateState(content);
public async ensureGrant(sender?: string, state = this.state) {
await this.grantChecker.assertConnectionGranted(this.roomId, state, sender);
}
protected async validateConnectionState(content: unknown) {
const state = GitHubRepoConnection.validateState(content);
// Validate the permissions of this state
await this.ensureGrant(undefined, state);
return state;
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
@ -1373,6 +1392,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, { org: this.org, repo: this.repo });
// Do a sanity check that the event exists.
try {
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
@ -1395,6 +1415,10 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
return true;
}
public static getGrantKey(org: string, repo: string) {
return `${this.CanonicalEventType}/${org}/${repo}`;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -5,6 +5,8 @@ import axios from "axios";
import { GitHubDiscussionSpace } from ".";
import { GithubInstance } from "../Github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
const log = new Logger("GitHubOwnerSpace");
@ -28,13 +30,17 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
static readonly QueryRoomRegex = /#github_(.+):.*/;
static readonly ServiceCategory = "github";
private static grantKey(state: GitHubUserSpaceConnectionState) {
return `${this.CanonicalEventType}/${state.username}`;
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, intent}: InstantiateConnectionOpts) {
github, config, intent, as}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubUserSpace(
await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
@ -137,12 +143,21 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
};
}
constructor(public readonly space: Space,
private readonly grantChecker: GrantChecker;
constructor(as: Appservice,
config: BridgeConfig,
public readonly space: Space,
private state: GitHubUserSpaceConnectionState,
stateKey: string) {
super(space.roomId, stateKey, GitHubUserSpace.CanonicalEventType);
this.grantChecker = new ConfigGrantChecker("github", as, config);
}
public ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, GitHubUserSpace.grantKey(this.state), sender);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubUserSpace.EventTypes.includes(eventType) && this.stateKey === stateKey;
}

View File

@ -5,11 +5,12 @@ import { UserTokenStore } from "../UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { BridgeConfig, BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { GetIssueResponse } from "../Gitlab/Types";
import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes";
import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
export interface GitLabIssueConnectionState {
instance: string;
@ -70,7 +71,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
commentProcessor,
messageClient,
instance,
config.gitlab,
config,
);
}
@ -84,7 +85,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,
messageSender: MessageSenderClient,
config: BridgeConfigGitLab,
config: BridgeConfig,
) {
const state: GitLabIssueConnectionState = {
projects,
@ -109,6 +110,11 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
},
],
});
await new GrantChecker(as.botIntent, "gitlab").grantConnection(roomId, {
instance: state.instance,
project: state.projects[0].toString(),
issue: state.iid.toString(),
});
return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config);
}
@ -121,6 +127,9 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
return this.instance.url;
}
private readonly grantChecker: GrantChecker<{instance: string, project: string, issue: string}>;
private readonly config: BridgeConfigGitLab;
constructor(
roomId: string,
private readonly as: Appservice,
@ -131,9 +140,22 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
private commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,
private instance: GitLabInstance,
private config: BridgeConfigGitLab,
config: BridgeConfig,
) {
super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType);
this.grantChecker = new ConfigGrantChecker("gitlab", as, config);
if (!config.gitlab) {
throw Error('No gitlab config!');
}
this.config = config.gitlab;
}
public ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, {
instance: this.state.instance,
project: this.state.projects[0],
issue: this.state.iid.toString(),
}, sender);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {

View File

@ -20,6 +20,7 @@ import { HookFilter } from "../HookFilter";
import { GitLabClient } from "../Gitlab/Client";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import axios from "axios";
import { GitLabGrantChecker } from "../Gitlab/GrantChecker";
export interface GitLabRepoConnectionState extends IConnectionState {
instance: string;
@ -202,7 +203,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
throw new ValidatorApiError(validator.errors);
}
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {intent, tokenStore, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -211,18 +212,16 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, event.stateKey, intent, state, tokenStore, instance);
return new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance);
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
const gitlabConfig = config.gitlab;
const validData = this.validateState(data);
const instance = gitlabConfig.instances[validData.instance];
public static async assertUserHasAccessToProject(
instanceName: string, path: string, requester: string,
tokenStore: UserTokenStore, config: BridgeConfigGitLab
) {
const instance = config.instances[instanceName];
if (!instance) {
throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`);
throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`);
}
const client = await tokenStore.getGitLabForUser(requester, instance.url);
if (!client) {
@ -230,7 +229,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
let permissionLevel;
try {
permissionLevel = await client.projects.getMyAccessLevel(validData.path);
permissionLevel = await client.projects.getMyAccessLevel(path);
} catch (ex) {
throw new ApiError("Could not determine if the user has access to this project, does the project exist?", ErrCode.ForbiddenUser);
}
@ -238,11 +237,28 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (permissionLevel < AccessLevel.Developer) {
throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser);
}
return permissionLevel;
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { as, config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
const validData = this.validateState(data);
const gitlabConfig = config.gitlab;
const instance = gitlabConfig.instances[validData.instance];
if (!instance) {
throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`);
}
const permissionLevel = await this.assertUserHasAccessToProject(validData.instance, validData.path, requester, tokenStore, gitlabConfig);
const client = await tokenStore.getGitLabForUser(requester, instance.url);
if (!client) {
throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser);
}
const project = await client.projects.get(validData.path);
const stateEventKey = `${validData.instance}/${validData.path}`;
const connection = new GitLabRepoConnection(roomId, stateEventKey, intent, validData, tokenStore, instance);
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path);
@ -282,6 +298,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
};
log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`)
}
await new GitLabGrantChecker(as, gitlabConfig, tokenStore).grantConnection(roomId, { instance: validData.instance, path: validData.path })
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
return {connection, warning};
}
@ -377,9 +394,13 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
private readonly hookFilter: HookFilter<AllowedEventsNames>;
private readonly grantChecker;
constructor(
roomId: string,
stateKey: string,
as: Appservice,
config: BridgeConfigGitLab,
private readonly intent: Intent,
state: ConnectionStateValidated,
private readonly tokenStore: UserTokenStore,
@ -397,6 +418,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
"!gl",
"gitlab",
)
this.grantChecker = new GitLabGrantChecker(as, config, tokenStore);
if (!state.path || !state.instance) {
throw Error('Invalid state, missing `path` or `instance`');
}
@ -431,6 +453,11 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
const validatedState = GitLabRepoConnection.validateState(stateEv.content);
await this.grantChecker.assertConnectionGranted(this.roomId, {
instance: validatedState.instance,
path: validatedState.path,
} , stateEv.sender);
await super.onStateUpdate(stateEv);
this.hookFilter.enabledHooks = this.state.enableHooks;
}
@ -856,6 +883,7 @@ ${data.description}`;
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, { instance: this.state.instance, path: this.path });
// Do a sanity check that the event exists.
try {
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);

View File

@ -25,6 +25,19 @@ export interface IConnection {
priority: number;
/**
* Ensures that the current state loaded into the connection has been granted by
* the remote service. I.e. If the room is bridged into a GitHub repository,
* check that the *sender* has permission to bridge it.
*
* If a grant cannot be found, it may be determined by doing an API lookup against
* the remote service.
*
* @param sender The matrix ID of the sender of the event.
* @throws If the grant cannot be found, and cannot be detetermined, this will throw.
*/
ensureGrant?: (sender?: string) => void;
/**
* The unique connection ID. This is a opaque hash of the roomId, connection type and state key.
*/

View File

@ -16,6 +16,8 @@ import JiraApi from "jira-client";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigJira } from "../Config/Config";
import { HookshotJiraApi } from "../Jira/Client";
import { GrantChecker } from "../grants/GrantCheck";
import { JiraGrantChecker } from "../Jira/GrantChecker";
type JiraAllowedEventsNames =
"issue_created" |
@ -94,8 +96,6 @@ const md = new markdownit();
*/
@Connection
export class JiraProjectConnection extends CommandConnection<JiraProjectConnectionState> implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
@ -107,35 +107,41 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
static async assertUserHasAccessToProject(tokenStore: UserTokenStore, userId: string, urlStr: string) {
const url = new URL(urlStr);
const jiraClient = await tokenStore.getJiraForUser(userId, url.toString());
if (!jiraClient) {
throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser);
}
const jiraResourceClient = await jiraClient.getClientForUrl(url);
if (!jiraResourceClient) {
throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser);
}
const projectKey = JiraProjectConnection.getProjectKeyForUrl(url);
if (!projectKey) {
throw new ApiError("URL did not contain a valid project key", ErrCode.BadValue);
}
try {
// Need to check that the user can access this.
const project = await jiraResourceClient.getProject(projectKey);
return project;
} catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
}
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, intent, tokenStore, config}: ProvisionConnectionOpts) {
if (!config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
}
const validData = validateJiraConnectionState(data);
log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`);
const jiraClient = await tokenStore.getJiraForUser(userId, validData.url);
if (!jiraClient) {
throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser);
}
const jiraResourceClient = await jiraClient.getClientForUrl(new URL(validData.url));
if (!jiraResourceClient) {
throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser);
}
const project = await this.assertUserHasAccessToProject(tokenStore, userId, validData.url);
const connection = new JiraProjectConnection(roomId, as, intent, validData, validData.url, tokenStore);
log.debug(`projectKey for ${validData.url} is ${connection.projectKey}`);
if (!connection.projectKey) {
throw Error('Expected projectKey to be defined');
}
try {
// Need to check that the user can access this.
const project = await jiraResourceClient.getProject(connection.projectKey);
// Fetch the project's id now, to support events that identify projects by id instead of url
if (connection.state.id !== undefined && connection.state.id !== project.id) {
log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`);
connection.state.id = project.id;
}
} catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
// Fetch the project's id now, to support events that identify projects by id instead of url
if (connection.state.id !== undefined && connection.state.id !== project.id) {
log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`);
connection.state.id = project.id;
}
await intent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
log.info(`Created connection via provisionConnection ${connection.toString()}`);
@ -200,13 +206,15 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
*/
private projectUrl?: URL;
private readonly grantChecker: GrantChecker<{url: string}>;
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
state: JiraProjectConnectionState,
stateKey: string,
private readonly tokenStore: UserTokenStore,
private readonly tokenStore: UserTokenStore
) {
super(
roomId,
@ -227,6 +235,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
} else {
throw Error('State is missing both id and url, cannot create connection');
}
this.grantChecker = new JiraGrantChecker(as, tokenStore);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
@ -237,6 +246,12 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
return validateJiraConnectionState(content);
}
public ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, {
url: this.state.url,
}, sender);
}
public async onJiraIssueCreated(data: JiraIssueEvent) {
// NOTE This is the only event type that shouldn't be skipped if the state object is missing,
// for backwards compatibility with issue creation having been the only supported Jira event type,
@ -499,6 +514,9 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, {
url: this.state.url,
});
// Do a sanity check that the event exists.
try {
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);

View File

@ -0,0 +1,39 @@
import { Appservice } from "matrix-bot-sdk";
import { GitHubRepoConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
import { GithubInstance } from "./GithubInstance";
import { Logger } from 'matrix-appservice-bridge';
const log = new Logger('GitHubGrantChecker');
interface GitHubGrantConnectionId {
org: string;
repo: string;
}
export class GitHubGrantChecker extends GrantChecker<GitHubGrantConnectionId> {
constructor(private readonly as: Appservice, private readonly github: GithubInstance, private readonly tokenStore: UserTokenStore) {
super(as.botIntent, "github")
}
protected async checkFallback(roomId: string, connectionId: GitHubGrantConnectionId, sender?: string) {
if (!sender) {
log.debug(`Tried to check fallback for ${roomId} with a missing sender`);
// Cannot validate without a sender.
return false;
}
if (this.as.isNamespacedUser(sender)) {
// Bridge is always valid.
return true;
}
try {
await GitHubRepoConnection.assertUserHasAccessToRepo(sender, connectionId.org, connectionId.repo, this.github, this.tokenStore);
return true;
} catch (ex) {
log.info(`Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, ex);
return false;
}
}
}

View File

@ -0,0 +1,40 @@
import { Logger } from "matrix-appservice-bridge";
import { Appservice } from "matrix-bot-sdk";
import { BridgeConfigGitLab } from "../Config/Config";
import { GitLabRepoConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
const log = new Logger('GitLabGrantChecker');
interface GitLabGrantConnectionId{
instance: string;
path: string;
}
export class GitLabGrantChecker extends GrantChecker<GitLabGrantConnectionId> {
constructor(private readonly as: Appservice, private readonly config: BridgeConfigGitLab, private readonly tokenStore: UserTokenStore) {
super(as.botIntent, "gitlab")
}
protected async checkFallback(roomId: string, connectionId: GitLabGrantConnectionId, sender?: string) {
if (!sender) {
log.debug(`Tried to check fallback for ${roomId} with a missing sender`);
// Cannot validate without a sender.
return false;
}
if (this.as.isNamespacedUser(sender)) {
// Bridge is always valid.
return true;
}
try {
await GitLabRepoConnection.assertUserHasAccessToProject(connectionId.instance, connectionId.path, sender, this.tokenStore, this.config);
return true;
} catch (ex) {
log.info(`${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, ex);
return false;
}
}
}

View File

@ -35,9 +35,9 @@ export async function ensureUserIsInRoom(targetIntent: Intent, botClient: Matrix
}
}
export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix: string) {
export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix?: string) {
const domain = as.botUserId.split(":")[1];
const intent = as.getIntentForUserId(`@${prefix}${user.login}:${domain}`);
const intent = as.getIntentForUserId(`@${prefix ?? ''}${user.login}:${domain}`);
const displayName = `${user.login}`;
// Verify up-to-date profile
let profile;

33
src/Jira/GrantChecker.ts Normal file
View File

@ -0,0 +1,33 @@
import { Appservice } from "matrix-bot-sdk";
import { JiraProjectConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
import { UserTokenStore } from "../UserTokenStore";
interface JiraGrantConnectionId{
url: string;
}
export class JiraGrantChecker extends GrantChecker<JiraGrantConnectionId> {
constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) {
super(as.botIntent, "jira")
}
protected async checkFallback(roomId: string, connectionId: JiraGrantConnectionId, sender?: string) {
if (!sender) {
// Cannot validate without a sender.
return false;
}
if (this.as.isNamespacedUser(sender)) {
// Bridge is always valid.
return true;
}
try {
await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url);
return true;
} catch (ex) {
return false;
}
}
}

View File

@ -119,7 +119,7 @@ export default class BotUsersManager {
* @param roomId
*/
onRoomJoin(botUser: BotUser, roomId: string): void {
log.info(`Bot user ${botUser.userId} joined room ${roomId}`);
log.debug(`Bot user ${botUser.userId} joined room ${roomId}`);
const botUsers = this._botsInRooms.get(roomId) ?? new Set<BotUser>();
botUsers.add(botUser);
this._botsInRooms.set(roomId, botUsers);

114
src/grants/GrantCheck.ts Normal file
View File

@ -0,0 +1,114 @@
import { Logger } from "matrix-appservice-bridge";
import { Appservice, Intent, MatrixError } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { FormatUtil } from "../FormatUtil";
const GRANT_ACCOUNT_DATA_KEY = "uk.half-shot.matrix-hookshot.grant";
interface GrantContent {
granted: boolean;
}
const log = new Logger("GrantChecker");
export class GrantRejectedError extends Error {
constructor(public readonly roomId: string, public readonly connectionId: string) {
super(`No grant exists for ${roomId}/${connectionId}. Rejecting`);
}
}
type ConnectionId = string|object;
export class GrantChecker<cId extends ConnectionId = ConnectionId> {
private static stringifyConnectionId<cId = ConnectionId>(connId: cId) {
if (typeof connId === "string") {
return FormatUtil.hashId(connId.toString());
}
return FormatUtil.hashId(Object.entries(connId as Record<string, unknown>).map((data) => `${data[0]}:${data[1]}`).join(''));
}
constructor(private readonly intent: Intent, protected readonly grantType: string) { }
/**
* If the connection hasn't been previously granted, we can use this function to check
* their permissions in the moment.
*
* By default, this always returns false.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected checkFallback(_roomId: string, _connectionId: cId, _sender?: string): Promise<boolean>|boolean {
return false;
}
private getKey(connectionIdStr: string): string {
return `${GRANT_ACCOUNT_DATA_KEY}/${this.grantType}/${connectionIdStr}`.toLowerCase();
}
public async assertConnectionGranted(roomId: string, connectionId: cId, sender?: string) {
const connId = GrantChecker.stringifyConnectionId(connectionId);
try {
const content = await this.intent.underlyingClient.getRoomAccountData<GrantContent>(this.getKey(connId), roomId);
if (!content.granted) {
// Previously granted but now stale.
throw new GrantRejectedError(roomId, connId);
}
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
if (!await this.checkFallback?.(roomId, connectionId, sender)) {
throw new GrantRejectedError(roomId, connId);
} else {
log.info(`Grant fallback succeeded for ${roomId}/${connectionId}`);
await this.grantConnection(roomId, connectionId);
}
} else {
log.warn(`Failed to check grant in ${roomId}/${connectionId}`, ex);
throw new GrantRejectedError(roomId, connId);
}
}
}
public async grantConnection(roomId: string, connectionId: cId) {
const cidStr = GrantChecker.stringifyConnectionId(connectionId);
log.info(`Granting ${roomId}/${cidStr}`);
await this.intent.underlyingClient.setRoomAccountData(
this.getKey(cidStr),
roomId,
{ granted: true } as GrantContent
);
}
public async ungrantConnection(roomId: string, connectionId: cId) {
const cidStr = GrantChecker.stringifyConnectionId(connectionId);
log.info(`Ungranting ${roomId}/${cidStr}`);
await this.intent.underlyingClient.setRoomAccountData(
this.getKey(cidStr),
roomId,
{ granted: false } as GrantContent
);
}
}
/**
* Check the grant of a given connection, falling back to checking the permissions of the user
* across the bridge.
*/
export class ConfigGrantChecker<cId extends ConnectionId = ConnectionId> extends GrantChecker<cId> {
static ConfigMinAccessLevel = BridgePermissionLevel.admin;
constructor(grantType: string, private readonly as: Appservice, private readonly config: BridgeConfig) {
super(as.botIntent, grantType)
}
protected checkFallback(_roomId: string, _connectionId: cId, sender?: string) {
if (!sender) {
// Cannot validate without a sender.
return false;
}
if (this.as.isNamespacedUser(sender)) {
// Bridge is always valid.
return true;
}
return this.config.checkPermission(sender, this.grantType, ConfigGrantChecker.ConfigMinAccessLevel);
}
}

View File

@ -4,6 +4,7 @@ import { AppserviceMock } from "../utils/AppserviceMock";
import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections";
import { expect } from "chai";
import { BridgeConfigGitLab } from "../../src/Config/Config";
const ROOM_ID = "!foo:bar";
@ -42,6 +43,8 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
const connection = new GitLabRepoConnection(
ROOM_ID,
"state_key",
as,
{} as BridgeConfigGitLab,
intent,
GitLabRepoConnection.validateState({
instance: "bar",

View File

@ -0,0 +1,177 @@
import { expect } from "chai";
import { BridgeConfig } from "../../src/Config/Config";
import { DefaultConfigRoot } from "../../src/Config/Defaults";
import { FormatUtil } from "../../src/FormatUtil";
import { ConfigGrantChecker, GrantChecker, GrantRejectedError } from '../../src/grants/GrantCheck';
import { AppserviceMock } from "../utils/AppserviceMock";
import { IntentMock } from "../utils/IntentMock";
const ROOM_ID = '!a-room:bar';
const CONNECTION_ID = '!a-room:bar';
const ALWAYS_GRANT_USER = '@grant_me:bar';
const GRANT_SERVICE_USER = '@grant_service_user:bar';
const GRANT_SERVCE_LOW_PERMS = '@grant_service_user_without_perms:bar';
const GRANT_WRONG_SERVCE_USER = '@grant_wrong_service_user:bar';
const ALICE_USERID = '@alice:bar';
const GRANT_SERVICE = 'example-grant';
async function doesAssert(checker: GrantChecker<string>, roomId: string, connectionId: string, sender?: string) {
try {
await checker.assertConnectionGranted(roomId, connectionId, sender);
throw Error(`Expected ${roomId}/${connectionId} to have thrown an error`)
} catch (ex) {
expect(ex).instanceOf(GrantRejectedError, 'Error thrown, but was not a grant rejected error');
expect(ex.roomId).to.equal(roomId, "Grant rejected, but roomId didn't match");
// connectionIds are always hashed
expect(ex.connectionId).to.equal(FormatUtil.hashId(connectionId), "Grant rejected, but connectionId didn't match");
return true;
}
}
class TestGrantChecker extends GrantChecker {
protected checkFallback(roomId: string, connectionId: string | object, sender?: string | undefined) {
return sender === ALWAYS_GRANT_USER;
}
}
describe("GrantChecker", () => {
describe('base grant system', () => {
let check: GrantChecker<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let intent: any;
beforeEach(() => {
intent = IntentMock.create('@foo:bar');
check = new TestGrantChecker(intent, GRANT_SERVICE);
});
it('will grant a connection', async () => {
await check.grantConnection(ROOM_ID, CONNECTION_ID);
// And then to check that the grant has now been allowed.
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID);
});
it('will assert on a missing grant', async () => {
await doesAssert(
check,
ROOM_ID,
CONNECTION_ID
);
});
it('will allow a missing grant if sender matches', async () => {
// Use the special user to grant the connection
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER);
// And then to check that the grant has now been allowed.
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID);
});
it('will not conflict with another connection id', async () => {
await check.grantConnection(ROOM_ID, CONNECTION_ID);
await doesAssert(
check,
ROOM_ID,
CONNECTION_ID + "2",
);
});
it('will not conflict with another room', async () => {
await check.grantConnection(ROOM_ID, CONNECTION_ID);
await doesAssert(
check,
ROOM_ID + "2",
CONNECTION_ID
);
});
it('will not conflict with another grant service', async () => {
const anotherchecker = new TestGrantChecker(intent, GRANT_SERVICE + "-two");
await check.grantConnection(ROOM_ID, CONNECTION_ID);
await doesAssert(
anotherchecker,
ROOM_ID,
CONNECTION_ID
);
});
});
describe('config fallback', () => {
let check: GrantChecker<string>;
let as: AppserviceMock;
beforeEach(() => {
const mockAs = AppserviceMock.create();
as = mockAs;
const config = new BridgeConfig(
{
...DefaultConfigRoot,
permissions: [{
actor: ALWAYS_GRANT_USER,
services: [{
service: '*',
level: "admin",
}],
},
{
actor: GRANT_SERVICE_USER,
services: [{
service: GRANT_SERVICE,
level: "admin",
}]
},
{
actor: GRANT_SERVCE_LOW_PERMS,
services: [{
service: GRANT_SERVICE,
level: 'notifications',
}]
},
{
actor: GRANT_WRONG_SERVCE_USER,
services: [{
service: 'another-service',
level: "admin",
}]
}],
}
);
check = new ConfigGrantChecker(GRANT_SERVICE, mockAs, config);
});
it('will deny a missing grant if the sender is not provided', async () => {
await doesAssert(
check,
ROOM_ID,
CONNECTION_ID
);
});
it('will deny a missing grant if the sender is not in the appservice whitelist', async () => {
await doesAssert(
check,
ROOM_ID,
CONNECTION_ID,
ALICE_USERID,
);
});
it('will grant if the user is part of the appservice', async () => {
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, as.namespace + "bot");
});
it('will grant if the user has access to all services', async () => {
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER);
});
it('will grant if the user has access to this service', async () => {
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, GRANT_SERVICE_USER);
});
it('will not grant if the user has low access to this service', async () => {
await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_SERVCE_LOW_PERMS);
});
it('will not grant if the user has access to a different service', async () => {
await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_WRONG_SERVCE_USER);
});
});
});

View File

@ -4,6 +4,8 @@ export class AppserviceMock {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public readonly intentMap = new Map<string, any>();
public readonly botIntent = IntentMock.create(`@bot:example.com`);
public namespace = "@hookshot_";
static create(){
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new this() as any;
@ -26,4 +28,8 @@ export class AppserviceMock {
this.intentMap.set(userId, intent);
return intent;
}
public isNamespacedUser(userId: string) {
return userId.startsWith(this.namespace);
}
}

View File

@ -1,5 +1,6 @@
import { expect } from "chai";
import { MatrixError } from "matrix-bot-sdk";
export class MatrixClientMock {
static create(){
@ -9,6 +10,7 @@ export class MatrixClientMock {
// map room Id → user Ids
private joinedMembers: Map<string, string[]> = new Map();
public readonly roomAccountData: Map<string, string> = new Map();
async setDisplayName() {
return;
@ -28,6 +30,21 @@ export class MatrixClientMock {
roomMembers.push(userId);
this.joinedMembers.set(roomId, roomMembers);
}
async getRoomAccountData(key: string, roomId: string): Promise<string> {
const data = this.roomAccountData.get(roomId+key);
if (data) {
return data;
}
throw new MatrixError({
errcode: 'M_NOT_FOUND',
error: 'Test error: No account data',
}, 404);
}
async setRoomAccountData(key: string, roomId: string, value: string): Promise<void> {
this.roomAccountData.set(roomId+key, value);
}
}
export class IntentMock {