Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add configuration to limit teams and rooms counts #397

Merged
merged 19 commits into from
May 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/397.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ability to limit the number of teams and rooms via the config
6 changes: 6 additions & 0 deletions config/config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,9 @@ team_sync:

provisioning:
enabled: true
# Should the bridge deny users bridging channels to private rooms.
require_public_room: true
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved
limits:
room_count: 20
team_count: 1

17 changes: 16 additions & 1 deletion config/slack-config-schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,19 @@ properties:
type: "object"
properties:
enabled:
type: boolean
type: boolean
provisioning:
type: object
required: ["enabled"]
properties:
enabled:
type: boolean
require_public_room:
type: boolean
limits:
type: object
properties:
room_count:
type: number
team_count:
type: number
6 changes: 5 additions & 1 deletion src/IConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export interface IConfig {

provisioning?: {
enable: boolean;
auth_callbck: string;
require_public_room?: boolean;
limits?: {
team_count?: number;
room_count?: number;
}
};
}
28 changes: 25 additions & 3 deletions src/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const log = Logging.get("Main");

const RECENT_EVENTID_SIZE = 20;
const STARTUP_TEAM_INIT_CONCURRENCY = 10;
const STARTUP_RETRY_TIME_MS = 5000;
export const METRIC_ACTIVE_USERS = "active_users";
export const METRIC_ACTIVE_ROOMS = "active_rooms";
export const METRIC_PUPPETS = "remote_puppets";
Expand Down Expand Up @@ -810,7 +811,20 @@ export class Main {
}
const port = this.config.homeserver.appservice_port || cliPort;
this.bridge.run(port, this.config, this.appservice);
const roomListPromise = this.bridge.getBot().getJoinedRooms() as Promise<string[]>;
let joinedRooms: string[]|null = null;
while(joinedRooms === null) {
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved
try {
joinedRooms = await this.bridge.getBot().getJoinedRooms() as string[];
} catch (ex) {
if (ex.errcode === 'M_UNKNOWN_TOKEN') {
log.error("The homeserver doesn't recognise this bridge, have you configured the homeserver with the appservice registration file?");
} else {
log.error("Failed to fetch room list:", ex);
}
log.error(`Waiting ${STARTUP_RETRY_TIME_MS}ms before retrying`);
await new Promise(((resolve) => setTimeout(resolve, STARTUP_RETRY_TIME_MS)));
}
}

this.bridge.addAppServicePath({
handler: this.onHealth.bind(this.bridge),
Expand Down Expand Up @@ -868,13 +882,12 @@ export class Main {

const entries = await this.datastore.getAllRooms();
log.info(`Found ${entries.length} room entries in store`);
const joinedRooms = await roomListPromise;
i = 0;
await Promise.all(entries.map(async (entry) => {
i++;
log.info(`[${i}/${entries.length}] Loading room entry ${entry.matrix_id}`);
try {
await this.startupLoadRoomEntry(entry, joinedRooms, teamClients);
await this.startupLoadRoomEntry(entry, joinedRooms as string[], teamClients);
} catch (ex) {
log.error(`Failed to load entry ${entry.matrix_id}, exception thrown`, ex);
}
Expand Down Expand Up @@ -1121,6 +1134,15 @@ export class Main {
return accounts.find((acct) => acct.team_id === teamId);
}

public async willExceedTeamLimit(teamId: string) {
// First, check if we are limited
if (!this.config.provisioning?.limits?.team_count) {
return false;
}
const idSet = new Set((await this.datastore.getAllTeams()).map((t) => t.id));
return idSet.add(teamId).size > this.config.provisioning?.limits?.team_count;
}

public async killBridge() {
log.info("Killing bridge");
if (this.metricsCollectorInterval) {
Expand Down
57 changes: 36 additions & 21 deletions src/Provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { Logging, Bridge, MatrixUser } from "matrix-appservice-bridge";
import * as rp from "request-promise-native";
import { Logging, MatrixUser } from "matrix-appservice-bridge";
import { Request, Response} from "express";
import { Main } from "./Main";
import { HTTP_CODES } from "./BaseSlackHandler";
Expand Down Expand Up @@ -79,14 +78,37 @@ export class Provisioner {
}
}

private async userIsAllowedAction(actionVerb: "link"|"unlink"|"puppet") {
// Hit an endpoint, and wait for a response.
private async reachedRoomLimit() {
if (!this.main.config.provisioning?.limits?.room_count) {
// No limit applied
return false;
}
const currentCount = await this.main.datastore.getRoomCount();
return (currentCount >= this.main.config.provisioning?.limits?.room_count);
}

@command()
private async getconfig(_, res) {
const hasRoomLimit = this.main.config.provisioning?.limits?.room_count;
const hasTeamLimit = this.main.config.provisioning?.limits?.team_count;
res.json({
bot_user_id: this.main.botUserId,
require_public_room: this.main.config.provisioning?.require_public_room || false,
instance_name: this.main.config.homeserver.server_name,
room_limit: hasRoomLimit ? {
quota: this.main.config.provisioning?.limits?.room_count,
current: await this.main.datastore.getRoomCount(),
} : null,
team_limit: hasTeamLimit ? {
quota: this.main.config.provisioning?.limits?.team_count,
current: this.main.clientFactory.teamClientCount,
} : null,
});
}

@command()
private getbotid(_, res) {
res.json({bot_user_id: this.main.botUserId});
private async getbotid(_, res) {
return this.getconfig(_, res);
}

@command("user_id", { param: "puppeting", required: false})
Expand Down Expand Up @@ -215,21 +237,6 @@ export class Provisioner {
res.json({ accounts });
}

@command("user_id", "team_id")
private async removeaccount(_, res, userId, teamId) {
log.debug(`${userId} is removing their account on ${teamId}`);
const isLast = (await this.main.datastore.getPuppetedUsers()).filter((t) => t.teamId).length < 2;
if (isLast) {
log.warn("This is the last user on the workspace which means we will lose access to the team token!");
}
const client = await this.main.clientFactory.getClientForUser(teamId, userId);
if (client) {
await client.auth.revoke();
}
await this.main.datastore.removePuppetTokenByMatrixId(teamId, userId);
res.json({ });
}

@command("matrix_room_id", "user_id")
private async getlink(req, res, matrixRoomId, userId) {
const room = this.main.rooms.getByMatrixRoomId(matrixRoomId);
Expand Down Expand Up @@ -317,6 +324,14 @@ export class Provisioner {
text: `${userId} is not allowed to provision links in ${matrixRoomId}`,
});
}

if (await this.reachedRoomLimit()) {
throw {
code: HTTP_CODES.FORBIDDEN,
text: `You have reached the maximum number of bridged rooms.`,
};
}

const room = await this.main.actionLink(opts);
// Convert the room 'status' into a integration manager 'status'
let status = room.getStatus();
Expand Down
6 changes: 5 additions & 1 deletion src/SlackClientFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export class SlackClientFactory {
}
}

public get teamClientCount() {
return this.teamClients.size;
}

/**
* Gets a WebClient for a given teamId. If one has already been
* created, the cached client is returned.
Expand Down Expand Up @@ -196,7 +200,7 @@ export class SlackClientFactory {
return res !== null ? res.client : null;
}

private async createTeamClient(token: string) {
public async createTeamClient(token: string) {
const opts = this.config.slack_client_opts ? this.config.slack_client_opts : undefined;
const slackClient = new WebClient(token, {
logger: {
Expand Down
21 changes: 19 additions & 2 deletions src/SlackHookHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,23 @@ export class SlackHookHandler extends BaseSlackHandler {
if (room) { // Legacy webhook
// XXX: We no longer support setting tokens for webhooks
} else if (user) { // New event api
// Ensure that we can support another team.
if (await this.main.willExceedTeamLimit(response.team_id)) {
log.warn(`User ${response.user_id} tried to add a new team ${response.team_id} but the team limit was reached`);
try {
const tempClient = await this.main.clientFactory.createTeamClient(response.access_token);
await tempClient.slackClient.auth.revoke();
} catch (ex) {
log.warn(`Additionally failed to revoke the token:`, ex);
}
return {
code: 403,
// Not using templates to avoid newline awfulness.
// tslint:disable-next-line: prefer-template
html: "<h2>Integration Failed</h2>\n" +
`<p>You have reached the limit of Slack teams that can be bridged to Matrix. Please contact your admin.</p>`,
};
}
// We always get a user access token, but if we set certain
// fancy scopes we might not get a bot one.
await this.main.setUserAccessToken(
Expand All @@ -362,10 +379,10 @@ export class SlackHookHandler extends BaseSlackHandler {
log.error("Error during handling of an oauth token:", err);
return {
code: 403,
// Not using templaes to avoid newline awfulness.
// Not using templates to avoid newline awfulness.
// tslint:disable-next-line: prefer-template
html: "<h2>Integration Failed</h2>\n" +
"<p>Unfortunately your channel integration did not go as expected...</p>",
`<p>Unfortunately, your ${room ? "channel integration" : "account" } did not go as expected...</p>`,
};
}
return {
Expand Down
5 changes: 5 additions & 0 deletions src/datastore/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,9 @@ export interface Datastore {
* @param date The date of the action (defaults to the current date)
*/
upsertActivityMetrics(user: MatrixUser | SlackGhost, room: BridgedRoom, date?: Date): Promise<void>;

/**
* Get the number of connected rooms on this instance.
*/
getRoomCount(): Promise<number>;
}
4 changes: 4 additions & 0 deletions src/datastore/NedbDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,8 @@ export class NedbDatastore implements Datastore {
// no-op; activity metrics are not implemented for NeDB
return;
}

public async getRoomCount(): Promise<number> {
return (await this.getAllRooms()).length;
}
}
4 changes: 4 additions & 0 deletions src/datastore/postgres/PgDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ export class PgDatastore implements Datastore {
return usersByTeamAndRemote;
}

public async getRoomCount(): Promise<number> {
return Number.parseInt((await this.postgresDb.one("SELECT COUNT(*) FROM rooms")).count, 10);
}

private async updateSchemaVersion(version: number) {
log.debug(`updateSchemaVersion: ${version}`);
await this.postgresDb.none("UPDATE schema SET version = ${version};", {version});
Expand Down
4 changes: 4 additions & 0 deletions src/tests/utils/fakeDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,8 @@ export class FakeDatastore implements Datastore {
public async upsertActivityMetrics(user: MatrixUser | SlackGhost, room: BridgedRoom, date?: Date): Promise<void> {
return;
}

public async getRoomCount() {
return 0;
}
}