mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00
Add support for a tiered permission system (#167)
* Add basic permission model * Add permission mapping implementation * Start integrating config checks * Add permission checking for commands * Add warnings for legacy behaviour * changelog * Linting * Fix config build step * Tests * Add documentation * Add complete tests * Add documentation * Add room for room membership permissions * Fixup error * Update sampleConfig
This commit is contained in:
parent
e541f44c8a
commit
5b294bc05f
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16
|
||||
- run: yarn --ignore-scripts
|
||||
- run: yarn # Need to build to get rust bindings
|
||||
- run: yarn --silent ts-node src/Config/Defaults.ts --config | diff config.sample.yml -
|
||||
|
||||
metrics-docs:
|
||||
|
3
changelog.d/167.feature
Normal file
3
changelog.d/167.feature
Normal file
@ -0,0 +1,3 @@
|
||||
New configuraion option `permissions` to control who can use the bridge.
|
||||
**Please note**: By default, all users on the same homeserver will be given `admin` permissions (to reflect previous behaviour). Please adjust
|
||||
your config when updating.
|
@ -91,6 +91,13 @@ logging:
|
||||
# (Optional) Logging settings. You can have a severity debug,info,warn,error
|
||||
#
|
||||
level: info
|
||||
permissions:
|
||||
# (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
|
||||
#
|
||||
- actor: example.com
|
||||
services:
|
||||
- service: "*"
|
||||
level: admin
|
||||
listeners:
|
||||
# (Optional) HTTP Listener configuration.
|
||||
# Bind resource endpoints to ports and addresses.
|
||||
|
@ -61,6 +61,84 @@ Copy `registration.sample.yml` into `registration.yml` and fill in:
|
||||
You will need to link the registration file to the homeserver. Consult your homeserver documentation
|
||||
on how to add appservices. [Synapse documents the process here](https://matrix-org.github.io/synapse/latest/application_services.html).
|
||||
|
||||
### Permissions
|
||||
|
||||
The bridge supports fine grained permission control over what services a user can access.
|
||||
By default, any user on the bridge's own homeserver has full permission to use it.
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
- actor: example.com
|
||||
services:
|
||||
- service: "*"
|
||||
level: admin
|
||||
```
|
||||
|
||||
You must configure a set of "actors" with access to services. An `actor` can be:
|
||||
- A MxID (also known as a User ID) e.g. @Half-Shot:half-shot.uk
|
||||
- A homserver domain e.g. @alice:matrix.org
|
||||
- A roomId. This will allow any member of this room to complete actions. e.g. `!TlZdPIYrhwNvXlBiEk:half-shot.uk`
|
||||
- `*`, to match all users.
|
||||
|
||||
Each permission set can have a services. The `service` field can be:
|
||||
- `github`
|
||||
- `gitlab`
|
||||
- `jira`
|
||||
- `figma`
|
||||
- `webhooks`
|
||||
- `*`, for any service.
|
||||
|
||||
The `level` can be:
|
||||
- `commands` Can run commands within connected rooms, but NOT log into the bridge.
|
||||
- `login` All the above, and can also log into the bridge.
|
||||
- `notifications` All the above, and can also bridge their notifications.
|
||||
- `manageConnections` All the above, and can create and delete connections (either via the provisioner, setup commands, or state events).
|
||||
- `admin` All permissions. Currently there are no admin features so this exists as a placeholder.
|
||||
|
||||
When permissions are checked, if a user matches any of the permission set and one
|
||||
of those grants the right level for a service, they are allowed access. If none of the
|
||||
definitions match, they are denined.
|
||||
|
||||
#### Example
|
||||
|
||||
A typical setup might be.
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
# Allo all users to send commands to existing services
|
||||
- actor: *
|
||||
services:
|
||||
- service: *
|
||||
level: commands
|
||||
# Allow any user that is part of this space to manage github connections
|
||||
- actor: !TlZdPIYrhwNvXlBiEk:half-shot.uk
|
||||
services:
|
||||
- service: github
|
||||
level: manageConnections
|
||||
# Allow users on this domain to login to jira and github.
|
||||
- actor: support.example.com
|
||||
services:
|
||||
- service: jira
|
||||
level: login
|
||||
- service: github
|
||||
level: commands
|
||||
# Allow users on this domain to enable notifications on any service.
|
||||
- actor: engineering.example.com
|
||||
services:
|
||||
- service: *
|
||||
level: notifications
|
||||
# Allow users on this domain to create connections.
|
||||
- actor: management.example.com
|
||||
services:
|
||||
- service: *
|
||||
level: manageConnections
|
||||
# Allow this specific user to do any action
|
||||
- actor: @alice:example.com
|
||||
services:
|
||||
- service: *
|
||||
level: admin
|
||||
```
|
||||
|
||||
### Listeners configuration
|
||||
|
||||
You will need to configure some listeners to make the bridge functional.
|
||||
|
@ -2,7 +2,7 @@
|
||||
import "reflect-metadata";
|
||||
import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler";
|
||||
import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunction } from "./BotCommands";
|
||||
import { BridgeConfig } from "./Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel } from "./Config/Config";
|
||||
import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface";
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import { FormatUtil } from "./FormatUtil";
|
||||
@ -477,7 +477,8 @@ export class AdminRoom extends AdminRoomCommandHandler {
|
||||
}
|
||||
|
||||
public async handleCommand(eventId: string, command: string) {
|
||||
const result = await handleCommand(this.userId, command, AdminRoom.botCommands, this);
|
||||
const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(this.userId, service, level);
|
||||
const result = await handleCommand(this.userId, command, AdminRoom.botCommands, this, checkPermission);
|
||||
if (!result.handled) {
|
||||
return this.sendNotice("Command not understood");
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ import stringArgv from "string-argv";
|
||||
import { CommandError } from "./errors";
|
||||
import { ApiError } from "./provisioning/api";
|
||||
import { MatrixMessageContent } from "./MatrixEvent";
|
||||
import { BridgePermissionLevel } from "./Config/Config";
|
||||
import { PermissionCheckFn } from "./Connections";
|
||||
|
||||
const md = new markdown();
|
||||
|
||||
@ -28,6 +30,8 @@ export interface BotCommandOptions {
|
||||
optionalArgs?: string[],
|
||||
includeUserId?: boolean,
|
||||
category?: string,
|
||||
permissionLevel?: BridgePermissionLevel,
|
||||
permissionService?: string,
|
||||
}
|
||||
|
||||
|
||||
@ -84,7 +88,9 @@ export function compileBotCommands(...prototypes: Record<string, BotCommandFunct
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCommand(userId: string, command: string, botCommands: BotCommands, obj: unknown, prefix?: string)
|
||||
export async function handleCommand(
|
||||
userId: string, command: string, botCommands: BotCommands, obj: unknown, permissionCheckFn: PermissionCheckFn,
|
||||
defaultPermissionService?: string, prefix?: string)
|
||||
: Promise<{handled: false}|{handled: true, result: BotCommandResult}|{handled: true, error: string, humanError?: string}> {
|
||||
if (prefix) {
|
||||
if (!command.startsWith(prefix)) {
|
||||
@ -98,6 +104,10 @@ export async function handleCommand(userId: string, command: string, botCommands
|
||||
// We have a match!
|
||||
const command = botCommands[prefix];
|
||||
if (command) {
|
||||
const permissionService = command.permissionService || defaultPermissionService;
|
||||
if (permissionService && !permissionCheckFn(permissionService, command.permissionLevel || BridgePermissionLevel.commands)) {
|
||||
return {handled: true, error: "You do not have permission to use this command"};
|
||||
}
|
||||
if (command.requiredArgs && command.requiredArgs.length > parts.length - i) {
|
||||
return {handled: true, error: "Missing args"};
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AdminAccountData } from "./AdminRoomCommandHandler";
|
||||
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
|
||||
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient, IAppserviceStorageProvider, EventKind } from "matrix-bot-sdk";
|
||||
import { BridgeConfig, GitLabInstance } from "./Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
|
||||
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
|
||||
import { CommentProcessor } from "./CommentProcessor";
|
||||
import { ConnectionManager } from "./ConnectionManager";
|
||||
@ -632,24 +632,25 @@ export class Bridge {
|
||||
const processedReply = await this.replyProcessor.processEvent(event, this.as.botClient, EventKind.RoomEvent);
|
||||
const processedReplyMetadata: IRichReplyMetadata = processedReply?.mx_richreply;
|
||||
const adminRoom = this.adminRooms.get(roomId);
|
||||
const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level);
|
||||
|
||||
if (!adminRoom) {
|
||||
let handled = false;
|
||||
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
|
||||
try {
|
||||
if (connection.onMessageEvent) {
|
||||
handled = await connection.onMessageEvent(event, processedReplyMetadata);
|
||||
handled = await connection.onMessageEvent(event, checkPermission, processedReplyMetadata);
|
||||
}
|
||||
} catch (ex) {
|
||||
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
|
||||
}
|
||||
}
|
||||
if (!handled) {
|
||||
if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) {
|
||||
// Divert to the setup room code if we didn't match any of these
|
||||
try {
|
||||
await (
|
||||
new SetupConnection(roomId, this.as, this.tokenStore, this.config, this.github)
|
||||
).onMessageEvent(event);
|
||||
).onMessageEvent(event, checkPermission);
|
||||
} catch (ex) {
|
||||
log.warn(`Setup connection failed to handle:`, ex);
|
||||
}
|
||||
@ -694,6 +695,7 @@ export class Bridge {
|
||||
}
|
||||
|
||||
private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
|
||||
this.config.addMemberToCache(roomId, matrixEvent.sender);
|
||||
if (this.as.botUserId !== matrixEvent.sender) {
|
||||
// Only act on bot joins
|
||||
return;
|
||||
@ -716,14 +718,18 @@ export class Bridge {
|
||||
return;
|
||||
}
|
||||
if (event.state_key !== undefined) {
|
||||
if (event.type === "m.room.member" && event.content.membership !== "join") {
|
||||
this.config.removeMemberFromCache(roomId, event.state_key);
|
||||
return;
|
||||
}
|
||||
// A state update, hurrah!
|
||||
const existingConnections = this.connectionManager.getInterestedForRoomState(roomId, event.type, event.state_key);
|
||||
for (const connection of existingConnections) {
|
||||
try {
|
||||
if (event.content.disabled === true) {
|
||||
await this.connectionManager.removeConnection(connection.roomId, connection.connectionId);
|
||||
} else if (connection?.onStateUpdate) {
|
||||
connection.onStateUpdate(event);
|
||||
} else {
|
||||
connection.onStateUpdate?.(event);
|
||||
}
|
||||
} catch (ex) {
|
||||
log.warn(`Connection ${connection.toString()} failed to handle onStateUpdate:`, ex);
|
||||
|
@ -1,10 +1,23 @@
|
||||
import YAML from "yaml";
|
||||
import { promises as fs } from "fs";
|
||||
import { IAppserviceRegistration } from "matrix-bot-sdk";
|
||||
import { IAppserviceRegistration, MatrixClient } from "matrix-bot-sdk";
|
||||
import * as assert from "assert";
|
||||
import { configKey } from "./Decorators";
|
||||
import { configKey, hideKey } from "./Decorators";
|
||||
import { BridgeConfigListener, ResourceTypeArray } from "../ListenerService";
|
||||
import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo";
|
||||
import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
|
||||
const log = new LogWrapper("Config");
|
||||
|
||||
// Maps to permission_level_to_int in permissions.rs
|
||||
export enum BridgePermissionLevel {
|
||||
"commands" = 1,
|
||||
login = 2,
|
||||
notifications = 3,
|
||||
manageConnections = 4,
|
||||
admin = 5,
|
||||
}
|
||||
|
||||
interface BridgeConfigGitHubYAML {
|
||||
auth: {
|
||||
@ -154,13 +167,14 @@ export interface BridgeConfigMetrics {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface BridgeConfigRoot {
|
||||
export interface BridgeConfigRoot {
|
||||
bot?: BridgeConfigBot;
|
||||
bridge: BridgeConfigBridge;
|
||||
figma?: BridgeConfigFigma;
|
||||
generic?: BridgeGenericWebhooksConfig;
|
||||
github?: BridgeConfigGitHub;
|
||||
gitlab?: BridgeConfigGitLab;
|
||||
permissions?: BridgeConfigActorPermission[];
|
||||
provisioning?: BridgeConfigProvisioning;
|
||||
jira?: BridgeConfigJira;
|
||||
logging: BridgeConfigLogging;
|
||||
@ -179,6 +193,8 @@ export class BridgeConfig {
|
||||
public readonly queue: BridgeConfigQueue;
|
||||
@configKey("Logging settings. You can have a severity debug,info,warn,error", true)
|
||||
public readonly logging: BridgeConfigLogging;
|
||||
@configKey(`Permissions for using the bridge. See docs/setup.md#permissions for help`, true)
|
||||
public readonly permissions: BridgeConfigActorPermission[];
|
||||
@configKey(`A passkey used to encrypt tokens stored inside the bridge.
|
||||
Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate`)
|
||||
public readonly passFile: string;
|
||||
@ -208,6 +224,9 @@ export class BridgeConfig {
|
||||
'resources' may be any of ${ResourceTypeArray.join(', ')}`, true)
|
||||
public readonly listeners: BridgeConfigListener[];
|
||||
|
||||
@hideKey()
|
||||
private readonly bridgePermissions: BridgePermissions;
|
||||
|
||||
constructor(configData: BridgeConfigRoot, env: {[key: string]: string|undefined}) {
|
||||
this.bridge = configData.bridge;
|
||||
assert.ok(this.bridge);
|
||||
@ -232,6 +251,18 @@ export class BridgeConfig {
|
||||
this.logging = configData.logging || {
|
||||
level: "info",
|
||||
}
|
||||
this.permissions = configData.permissions || [{
|
||||
actor: this.bridge.domain,
|
||||
services: [{
|
||||
service: '*',
|
||||
level: BridgePermissionLevel[BridgePermissionLevel.admin],
|
||||
}]
|
||||
}];
|
||||
this.bridgePermissions = new BridgePermissions(this.permissions);
|
||||
|
||||
if (!configData.permissions) {
|
||||
log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`);
|
||||
}
|
||||
|
||||
|
||||
if (!this.github && !this.gitlab && !this.jira && !this.generic && !this.figma) {
|
||||
@ -254,7 +285,8 @@ export class BridgeConfig {
|
||||
resources: ['webhooks'],
|
||||
port: configData.webhook.port,
|
||||
bindAddress: configData.webhook.bindAddress,
|
||||
})
|
||||
});
|
||||
log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
|
||||
}
|
||||
|
||||
if (this.provisioning?.port) {
|
||||
@ -263,6 +295,7 @@ export class BridgeConfig {
|
||||
port: this.provisioning.port,
|
||||
bindAddress: this.provisioning.bindAddress,
|
||||
})
|
||||
log.warn("The `provisioning` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
|
||||
}
|
||||
|
||||
if (this.metrics?.port) {
|
||||
@ -271,15 +304,39 @@ export class BridgeConfig {
|
||||
port: this.metrics.port,
|
||||
bindAddress: this.metrics.bindAddress,
|
||||
})
|
||||
log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
|
||||
}
|
||||
|
||||
if (this.widgets?.port) {
|
||||
this.listeners.push({
|
||||
resources: ['widgets'],
|
||||
port: this.widgets.port,
|
||||
})
|
||||
});
|
||||
log.warn("The `widgets` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
|
||||
}
|
||||
}
|
||||
|
||||
public async prefillMembershipCache(client: MatrixClient) {
|
||||
for(const roomEntry of this.bridgePermissions.getInterestedRooms()) {
|
||||
const membership = await client.getJoinedRoomMembers(await client.resolveRoom(roomEntry));
|
||||
membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId));
|
||||
}
|
||||
}
|
||||
|
||||
public addMemberToCache(roomId: string, userId: string) {
|
||||
this.bridgePermissions.addMemberToCache(roomId, userId);
|
||||
}
|
||||
|
||||
public removeMemberFromCache(roomId: string, userId: string) {
|
||||
this.bridgePermissions.removeMemberFromCache(roomId, userId);
|
||||
}
|
||||
|
||||
public checkPermissionAny(mxid: string, permission: BridgePermissionLevel) {
|
||||
return this.bridgePermissions.checkActionAny(mxid, BridgePermissionLevel[permission]);
|
||||
}
|
||||
|
||||
public checkPermission(mxid: string, service: string, permission: BridgePermissionLevel) {
|
||||
return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]);
|
||||
}
|
||||
|
||||
static async parseConfig(filename: string, env: {[key: string]: string|undefined}) {
|
||||
@ -296,6 +353,7 @@ export async function parseRegistrationFile(filename: string) {
|
||||
|
||||
// Can be called directly
|
||||
if (require.main === module) {
|
||||
LogWrapper.configureLogging("info");
|
||||
BridgeConfig.parseConfig(process.argv[2] || "config.yml", process.env).then(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Config successfully validated.');
|
||||
|
@ -9,4 +9,15 @@ export function configKey(comment?: string, optional = false) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function getConfigKeyMetadata(target: any, propertyKey: string): [string, boolean] {
|
||||
return Reflect.getMetadata(configKeyMetadataKey, target, propertyKey);
|
||||
}
|
||||
|
||||
const hideKeyMetadataKey = Symbol("hideKey");
|
||||
export function hideKey() {
|
||||
return Reflect.metadata(hideKeyMetadataKey, true);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function keyIsHidden(target: any, propertyKey: string): boolean {
|
||||
return Reflect.getMetadata(hideKeyMetadataKey, target, propertyKey) !== undefined;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { BridgeConfig } from "./Config";
|
||||
import YAML from "yaml";
|
||||
import { getConfigKeyMetadata } from "./Decorators";
|
||||
import { getConfigKeyMetadata, keyIsHidden } from "./Decorators";
|
||||
import { Node, YAMLSeq } from "yaml/types";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
@ -20,6 +20,13 @@ export const DefaultConfig = new BridgeConfig({
|
||||
logging: {
|
||||
level: "info",
|
||||
},
|
||||
permissions: [{
|
||||
actor: "example.com",
|
||||
services: [{
|
||||
service: "*",
|
||||
level: "admin"
|
||||
}],
|
||||
}],
|
||||
passFile: "passkey.pem",
|
||||
widgets: {
|
||||
publicUrl: "https://example.com/bridge_widget/",
|
||||
@ -105,6 +112,10 @@ export const DefaultConfig = new BridgeConfig({
|
||||
function renderSection(doc: YAML.Document, obj: Record<string, unknown>, parentNode?: YAMLSeq) {
|
||||
const entries = Object.entries(obj);
|
||||
entries.forEach(([key, value]) => {
|
||||
if (keyIsHidden(obj, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newNode: Node;
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
newNode = YAML.createNode({});
|
||||
@ -112,7 +123,7 @@ function renderSection(doc: YAML.Document, obj: Record<string, unknown>, parentN
|
||||
} else {
|
||||
newNode = YAML.createNode(value);
|
||||
}
|
||||
|
||||
|
||||
const metadata = getConfigKeyMetadata(obj, key);
|
||||
if (metadata) {
|
||||
newNode.commentBefore = `${metadata[1] ? ' (Optional)' : ''} ${metadata[0]}\n`;
|
||||
@ -141,7 +152,7 @@ async function renderRegistrationFile(configPath?: string) {
|
||||
if (configPath) {
|
||||
bridgeConfig = await BridgeConfig.parseConfig(configPath, process.env);
|
||||
} else {
|
||||
bridgeConfig = new BridgeConfig(DefaultConfig, process.env);
|
||||
bridgeConfig = DefaultConfig;
|
||||
}
|
||||
const obj = {
|
||||
as_token: randomBytes(32).toString('hex'),
|
||||
|
1
src/Config/mod.rs
Normal file
1
src/Config/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod permissions;
|
164
src/Config/permissions.rs
Normal file
164
src/Config/permissions.rs
Normal file
@ -0,0 +1,164 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[napi(object)]
|
||||
pub struct BridgeConfigServicePermission {
|
||||
pub service: Option<String>,
|
||||
pub level: String,
|
||||
pub targets: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[napi(object)]
|
||||
pub struct BridgeConfigActorPermission {
|
||||
pub actor: String,
|
||||
pub services: Vec<BridgeConfigServicePermission>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn permission_level_to_int(level: String) -> napi::Result<u32> {
|
||||
match level.as_str() {
|
||||
"commands" => Ok(1),
|
||||
"login" => Ok(2),
|
||||
"notifications" => Ok(3),
|
||||
"manageConnections" => Ok(4),
|
||||
"admin" => Ok(5),
|
||||
_ => Err(napi::Error::new(
|
||||
napi::Status::InvalidArg,
|
||||
"provided level wasn't valid".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
struct BridgePermissions {
|
||||
config: Vec<BridgeConfigActorPermission>,
|
||||
room_membership: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BridgePermissions {
|
||||
#[napi(constructor)]
|
||||
pub fn new(config: Vec<BridgeConfigActorPermission>) -> Self {
|
||||
let mut room_membership = HashMap::new();
|
||||
for entry in config.iter() {
|
||||
if entry.actor.starts_with("!") {
|
||||
room_membership.insert(entry.actor.clone(), HashSet::new());
|
||||
}
|
||||
}
|
||||
BridgePermissions {
|
||||
config: config,
|
||||
room_membership: room_membership,
|
||||
}
|
||||
}
|
||||
|
||||
fn match_actor(
|
||||
&self,
|
||||
actor_permission: &BridgeConfigActorPermission,
|
||||
domain: &String,
|
||||
mxid: &String,
|
||||
) -> bool {
|
||||
if actor_permission.actor.starts_with("!") {
|
||||
match self.room_membership.get(&actor_permission.actor) {
|
||||
Some(set) => {
|
||||
if !set.contains(mxid) {
|
||||
// User not in set.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No cached data stored...odd.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return actor_permission.actor.eq(domain)
|
||||
|| actor_permission.actor.eq(mxid)
|
||||
|| actor_permission.actor == "*";
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_interested_rooms(&self) -> Vec<String> {
|
||||
self.room_membership.keys().map(|k| k.clone()).collect()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn add_member_to_cache(&mut self, room_id: String, mxid: String) {
|
||||
match self.room_membership.get_mut(&room_id) {
|
||||
Some(set) => {
|
||||
set.insert(mxid);
|
||||
}
|
||||
None => { /* Do nothing, not interested in this one. */ }
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn remove_member_from_cache(&mut self, room_id: String, mxid: String) {
|
||||
match self.room_membership.get_mut(&room_id) {
|
||||
Some(set) => {
|
||||
set.remove(&mxid);
|
||||
}
|
||||
None => { /* Do nothing, not interested in this one. */ }
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn check_action(
|
||||
&self,
|
||||
mxid: String,
|
||||
service: String,
|
||||
permission: String,
|
||||
) -> napi::Result<bool> {
|
||||
let parts: Vec<&str> = mxid.split(':').collect();
|
||||
let domain: String;
|
||||
let permission_int = permission_level_to_int(permission)?;
|
||||
if parts.len() > 1 {
|
||||
domain = parts[1].to_string();
|
||||
} else {
|
||||
domain = parts[0].to_string();
|
||||
}
|
||||
for actor_permission in self.config.iter() {
|
||||
// Room_id
|
||||
if !self.match_actor(actor_permission, &domain, &mxid) {
|
||||
continue;
|
||||
}
|
||||
for actor_service in actor_permission.services.iter() {
|
||||
match &actor_service.service {
|
||||
Some(actor_service_service) => {
|
||||
if actor_service_service != &service && actor_service_service != "*" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if permission_level_to_int(actor_service.level.clone())? >= permission_int {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn check_action_any(&self, mxid: String, permission: String) -> napi::Result<bool> {
|
||||
let parts: Vec<&str> = mxid.split(':').collect();
|
||||
let domain: String;
|
||||
let permission_int = permission_level_to_int(permission)?;
|
||||
if parts.len() > 1 {
|
||||
domain = parts[1].to_string();
|
||||
} else {
|
||||
domain = parts[0].to_string();
|
||||
}
|
||||
for actor_permission in self.config.iter() {
|
||||
if !self.match_actor(actor_permission, &domain, &mxid) {
|
||||
continue;
|
||||
}
|
||||
for actor_service in actor_permission.services.iter() {
|
||||
if permission_level_to_int(actor_service.level.clone())? >= permission_int {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
|
||||
import { Appservice, StateEvent } from "matrix-bot-sdk";
|
||||
import { CommentProcessor } from "./CommentProcessor";
|
||||
import { BridgeConfig, GitLabInstance } from "./Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
|
||||
import { GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections";
|
||||
import { GenericHookAccountData } from "./Connections/GenericHook";
|
||||
import { GithubInstance } from "./Github/GithubInstance";
|
||||
@ -72,6 +72,9 @@ export class ConnectionManager {
|
||||
if (!this.config.jira) {
|
||||
throw Error('JIRA is not configured');
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) {
|
||||
throw new ApiError('User is not permitted to provision connections for Jira', ErrCode.ForbiddenUser);
|
||||
}
|
||||
const res = await JiraProjectConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore);
|
||||
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
|
||||
this.push(res.connection);
|
||||
@ -85,6 +88,9 @@ export class ConnectionManager {
|
||||
if (!this.config.github || !this.config.github.oauth || !this.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) {
|
||||
throw new ApiError('User is not permitted to provision connections for GitHub', ErrCode.ForbiddenUser);
|
||||
}
|
||||
const res = await GitHubRepoConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore, this.github, this.config.github);
|
||||
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, GitHubRepoConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
|
||||
this.push(res.connection);
|
||||
@ -94,6 +100,9 @@ export class ConnectionManager {
|
||||
if (!this.config.generic) {
|
||||
throw Error('Generic hook support not supported');
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "webhooks", BridgePermissionLevel.manageConnections)) {
|
||||
throw new ApiError('User is not permitted to provision connections for generic webhooks', ErrCode.ForbiddenUser);
|
||||
}
|
||||
const res = await GenericHookConnection.provisionConnection(roomId, this.as, data, this.config.generic, this.messageClient);
|
||||
const existing = this.getAllConnectionsOfType(GenericHookConnection).find(c => c.stateKey === res.connection.stateKey);
|
||||
if (existing) {
|
||||
@ -109,16 +118,27 @@ export class ConnectionManager {
|
||||
throw new ApiError(`Connection type not known`);
|
||||
}
|
||||
|
||||
private assertStateAllowed(state: StateEvent<any>, serviceType: "github"|"gitlab"|"jira"|"figma"|"webhooks") {
|
||||
if (state.sender === this.as.botUserId) {
|
||||
return;
|
||||
}
|
||||
if (!this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections)) {
|
||||
throw new Error(`User ${state.sender} is disallowed to create state for ${serviceType}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createConnectionForState(roomId: string, state: StateEvent<any>) {
|
||||
if (state.content.disabled === true) {
|
||||
log.debug(`${roomId} has disabled state for ${state.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (GitHubRepoConnection.EventTypes.includes(state.type)) {
|
||||
if (!this.github || !this.config.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "github");
|
||||
return new GitHubRepoConnection(roomId, this.as, state.content, this.tokenStore, state.stateKey, this.github, this.config.github);
|
||||
}
|
||||
|
||||
@ -126,6 +146,7 @@ export class ConnectionManager {
|
||||
if (!this.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "github");
|
||||
return new GitHubDiscussionConnection(
|
||||
roomId, this.as, state.content, state.stateKey, this.tokenStore, this.commentProcessor,
|
||||
this.messageClient,
|
||||
@ -136,6 +157,7 @@ export class ConnectionManager {
|
||||
if (!this.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "github");
|
||||
|
||||
return new GitHubDiscussionSpace(
|
||||
await this.as.botClient.getSpace(roomId), state.content, state.stateKey
|
||||
@ -146,6 +168,8 @@ export class ConnectionManager {
|
||||
if (!this.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
|
||||
this.assertStateAllowed(state, "github");
|
||||
const issue = new GitHubIssueConnection(roomId, this.as, state.content, state.stateKey || "", this.tokenStore, this.commentProcessor, this.messageClient, this.github);
|
||||
await issue.syncIssueState();
|
||||
return issue;
|
||||
@ -155,6 +179,8 @@ export class ConnectionManager {
|
||||
if (!this.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
|
||||
this.assertStateAllowed(state, "github");
|
||||
return new GitHubUserSpace(
|
||||
await this.as.botClient.getSpace(roomId), state.content, state.stateKey
|
||||
);
|
||||
@ -164,6 +190,8 @@ export class ConnectionManager {
|
||||
if (!this.config.gitlab) {
|
||||
throw Error('GitLab is not configured');
|
||||
}
|
||||
|
||||
this.assertStateAllowed(state, "gitlab");
|
||||
const instance = this.config.gitlab.instances[state.content.instance];
|
||||
if (!instance) {
|
||||
throw Error('Instance name not recognised');
|
||||
@ -175,6 +203,7 @@ export class ConnectionManager {
|
||||
if (!this.config.gitlab) {
|
||||
throw Error('GitLab is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "gitlab");
|
||||
const instance = this.config.gitlab.instances[state.content.instance];
|
||||
return new GitLabIssueConnection(
|
||||
roomId,
|
||||
@ -191,6 +220,7 @@ export class ConnectionManager {
|
||||
if (!this.config.jira) {
|
||||
throw Error('JIRA is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "jira");
|
||||
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.tokenStore);
|
||||
}
|
||||
|
||||
@ -198,6 +228,7 @@ export class ConnectionManager {
|
||||
if (!this.config.figma) {
|
||||
throw Error('Figma is not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "figma");
|
||||
return new FigmaFileConnection(roomId, state.stateKey, state.content, this.config.figma, this.as, this.storage);
|
||||
}
|
||||
|
||||
@ -205,6 +236,7 @@ export class ConnectionManager {
|
||||
if (!this.config.generic) {
|
||||
throw Error('Generic webhooks are not configured');
|
||||
}
|
||||
this.assertStateAllowed(state, "webhooks");
|
||||
// Generic hooks store the hookId in the account data
|
||||
const acctData = await this.as.botClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
|
||||
// hookId => stateKey
|
||||
|
@ -3,6 +3,7 @@ import LogWrapper from "../LogWrapper";
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import { PermissionCheckFn } from ".";
|
||||
const log = new LogWrapper("CommandConnection");
|
||||
|
||||
/**
|
||||
@ -18,6 +19,7 @@ export abstract class CommandConnection extends BaseConnection {
|
||||
private readonly botCommands: BotCommands,
|
||||
private readonly helpMessage: (prefix: string) => MatrixMessageContent,
|
||||
protected readonly stateCommandPrefix: string,
|
||||
protected readonly serviceName?: string,
|
||||
) {
|
||||
super(roomId, stateKey, canonicalStateType);
|
||||
}
|
||||
@ -26,8 +28,11 @@ export abstract class CommandConnection extends BaseConnection {
|
||||
return this.stateCommandPrefix + " ";
|
||||
}
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
|
||||
const commandResult = await handleCommand(ev.sender, ev.content.body, this.botCommands, this, this.commandPrefix);
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn) {
|
||||
const commandResult = await handleCommand(
|
||||
ev.sender, ev.content.body, this.botCommands, this,checkPermission,
|
||||
this.serviceName, this.commandPrefix
|
||||
);
|
||||
if (commandResult.handled !== true) {
|
||||
// Not for us.
|
||||
return false;
|
||||
|
@ -19,6 +19,7 @@ import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { GitHubIssueConnection } from "./GithubIssue";
|
||||
import { BridgeConfigGitHub } from "../Config/Config";
|
||||
import { ApiError, ErrCode } from "../provisioning/api";
|
||||
import { PermissionCheckFn } from ".";
|
||||
const log = new LogWrapper("GitHubRepoConnection");
|
||||
const md = new markdown();
|
||||
|
||||
@ -277,7 +278,8 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
|
||||
as.botClient,
|
||||
GitHubRepoConnection.botCommands,
|
||||
GitHubRepoConnection.helpMessage,
|
||||
state.commandPrefix || "!gh"
|
||||
state.commandPrefix || "!gh",
|
||||
"github",
|
||||
);
|
||||
}
|
||||
|
||||
@ -302,8 +304,8 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
|
||||
}
|
||||
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, reply?: IRichReplyMetadata) {
|
||||
if (await super.onMessageEvent(ev)) {
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn, reply?: IRichReplyMetadata) {
|
||||
if (await super.onMessageEvent(ev, checkPermission)) {
|
||||
return true;
|
||||
}
|
||||
if (!reply) {
|
||||
|
@ -51,7 +51,8 @@ export class GitLabRepoConnection extends CommandConnection {
|
||||
as.botClient,
|
||||
GitLabRepoConnection.botCommands,
|
||||
GitLabRepoConnection.helpMessage,
|
||||
state.commandPrefix || "!gl"
|
||||
state.commandPrefix || "!gl",
|
||||
"gitlab",
|
||||
)
|
||||
if (!state.path || !state.instance) {
|
||||
throw Error('Invalid state, missing `path` or `instance`');
|
||||
|
@ -2,7 +2,9 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
|
||||
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
|
||||
import { GetConnectionsResponseItem } from "../provisioning/api";
|
||||
import { IRichReplyMetadata } from "matrix-bot-sdk";
|
||||
import { BridgePermissionLevel } from "../Config/Config";
|
||||
|
||||
export type PermissionCheckFn = (service: string, level: BridgePermissionLevel) => boolean;
|
||||
export interface IConnection {
|
||||
/**
|
||||
* The roomId that this connection serves.
|
||||
@ -26,7 +28,7 @@ export interface IConnection {
|
||||
* When a room gets a message event.
|
||||
* @returns Was the message handled
|
||||
*/
|
||||
onMessageEvent?: (ev: MatrixEvent<MatrixMessageContent>, replyMetadata?: IRichReplyMetadata) => Promise<boolean>;
|
||||
onMessageEvent?: (ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn, replyMetadata: IRichReplyMetadata) => Promise<boolean>;
|
||||
|
||||
onIssueCreated?: (ev: IssuesOpenedEvent) => Promise<void>;
|
||||
|
||||
|
@ -138,7 +138,8 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
|
||||
as.botClient,
|
||||
JiraProjectConnection.botCommands,
|
||||
JiraProjectConnection.helpMessage,
|
||||
state.commandPrefix || "!jira"
|
||||
state.commandPrefix || "!jira",
|
||||
"jira"
|
||||
);
|
||||
if (state.url) {
|
||||
this.projectUrl = new URL(state.url);
|
||||
|
@ -8,7 +8,7 @@ import { CommandError } from "../errors";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { BridgeConfig } from "../Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
|
||||
import markdown from "markdown-it";
|
||||
import { FigmaFileConnection } from "./FigmaFileConnection";
|
||||
const md = new markdown();
|
||||
@ -43,6 +43,9 @@ export class SetupConnection extends CommandConnection {
|
||||
if (!this.githubInstance || !this.config.github) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support GitHub");
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) {
|
||||
throw new CommandError('You are not permitted to provision connections for GitHub');
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
|
||||
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to setup new integrations.");
|
||||
}
|
||||
@ -68,6 +71,9 @@ export class SetupConnection extends CommandConnection {
|
||||
if (!this.config.jira) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support Jira");
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) {
|
||||
throw new CommandError('You are not permitted to provision connections for Jira');
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
|
||||
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to setup new integrations.");
|
||||
}
|
||||
@ -94,6 +100,9 @@ export class SetupConnection extends CommandConnection {
|
||||
if (!this.config.generic?.enabled) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support webhooks");
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "webhooks", BridgePermissionLevel.manageConnections)) {
|
||||
throw new CommandError('You are not permitted to provision connections for generic webhooks');
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
|
||||
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to setup new integrations.");
|
||||
}
|
||||
@ -115,6 +124,9 @@ export class SetupConnection extends CommandConnection {
|
||||
if (!this.config.figma) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support Figma");
|
||||
}
|
||||
if (!this.config.checkPermission(userId, "figma", BridgePermissionLevel.manageConnections)) {
|
||||
throw new CommandError('You are not permitted to provision connections for Figma');
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
|
||||
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to setup new integrations.");
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { NotificationWatcherTask } from "./NotificationWatcherTask";
|
||||
import { GitHubWatcher } from "./GitHubWatcher";
|
||||
import { GitHubUserNotification } from "../Github/Types";
|
||||
import { GitLabWatcher } from "./GitLabWatcher";
|
||||
import { BridgeConfig } from "../Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
|
||||
import Metrics from "../Metrics";
|
||||
export interface UserNotificationsEvent {
|
||||
roomId: string;
|
||||
@ -25,7 +25,7 @@ export class UserNotificationWatcher {
|
||||
private matrixMessageSender: MessageSenderClient;
|
||||
private queue: MessageQueue;
|
||||
|
||||
constructor(config: BridgeConfig) {
|
||||
constructor(private readonly config: BridgeConfig) {
|
||||
this.queue = createMessageQueue(config);
|
||||
this.matrixMessageSender = new MessageSenderClient(this.queue);
|
||||
}
|
||||
@ -74,6 +74,9 @@ Check your token is still valid, and then turn notifications back on.`, "m.notic
|
||||
}
|
||||
|
||||
public addUser(data: NotificationsEnableEvent) {
|
||||
if (!this.config.checkPermission(data.userId, data.type, BridgePermissionLevel.notifications)) {
|
||||
throw Error('User does not have permission enable notifications');
|
||||
}
|
||||
let task: NotificationWatcherTask;
|
||||
const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl);
|
||||
if (data.type === "github") {
|
||||
|
@ -6,9 +6,10 @@ import { publicEncrypt, privateDecrypt } from "crypto";
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import { JiraClient } from "./Jira/Client";
|
||||
import { JiraOAuthResult } from "./Jira/Types";
|
||||
import { BridgeConfig } from "./Config/Config";
|
||||
import { BridgeConfig, BridgePermissionLevel } from "./Config/Config";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { GitHubOAuthToken } from "./Github/Types";
|
||||
import { ApiError, ErrCode } from "./provisioning/api";
|
||||
|
||||
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:";
|
||||
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:";
|
||||
@ -50,6 +51,9 @@ export class UserTokenStore {
|
||||
}
|
||||
|
||||
public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise<void> {
|
||||
if (!this.config.checkPermission(userId, type, BridgePermissionLevel.login)) {
|
||||
throw new ApiError('User does not have permission to login to service', ErrCode.ForbiddenUser);
|
||||
}
|
||||
const key = tokenKey(type, userId, false, instanceUrl);
|
||||
const tokenParts: string[] = [];
|
||||
while (token && token.length > 0) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub mod Config;
|
||||
pub mod Github;
|
||||
pub mod Jira;
|
||||
pub mod format_util;
|
||||
|
123
tests/config/permissions.ts
Normal file
123
tests/config/permissions.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { BridgePermissions } from "../../src/libRs";
|
||||
import { expect } from "chai";
|
||||
|
||||
function genBridgePermissions(actor: string, service: string, level: string) {
|
||||
return new BridgePermissions([
|
||||
{
|
||||
actor,
|
||||
services: [
|
||||
{
|
||||
service,
|
||||
level
|
||||
}
|
||||
],
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
describe("Config/BridgePermissions", () => {
|
||||
describe("checkAction", () => {
|
||||
it("will return false for an empty actor set", () => {
|
||||
const bridgePermissions = new BridgePermissions([]);
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "empty-service", "commands")).to.be.false;
|
||||
});
|
||||
it("will return false for an insufficent level", () => {
|
||||
const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "notifications")).to.be.false;
|
||||
});
|
||||
it("will return false if the there are no matching services", () => {
|
||||
const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "other-service", "login")).to.be.false;
|
||||
});
|
||||
it("will return false if the target does not match", () => {
|
||||
const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:baz", "my-service", "login")).to.be.false;
|
||||
});
|
||||
it("will return true if there is a matching level and service", () => {
|
||||
const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true;
|
||||
});
|
||||
it("will return true for a matching actor domain", () => {
|
||||
const bridgePermissions = genBridgePermissions('bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true;
|
||||
});
|
||||
it("will return true for a wildcard actor", () => {
|
||||
const bridgePermissions = genBridgePermissions('*', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true;
|
||||
});
|
||||
it("will return true for a wildcard service", () => {
|
||||
const bridgePermissions = genBridgePermissions('@foo:bar', '*', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true;
|
||||
});
|
||||
it("will return false if a user is not present in a room", () => {
|
||||
const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false;
|
||||
});
|
||||
it("will return true if a user is present in a room", () => {
|
||||
const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login');
|
||||
bridgePermissions.addMemberToCache('!foo:bar', '@foo:bar');
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false;
|
||||
});
|
||||
it("will fall through and return true for multiple permission sets", () => {
|
||||
const bridgePermissions = new BridgePermissions([
|
||||
{
|
||||
actor: "not-you",
|
||||
services: [
|
||||
{
|
||||
service: "my-service",
|
||||
level: "login"
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
actor: "or-you",
|
||||
services: [
|
||||
{
|
||||
service: "my-service",
|
||||
level: "login"
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
actor: "@foo:bar",
|
||||
services: [
|
||||
{
|
||||
service: "my-service",
|
||||
level: "commands"
|
||||
}
|
||||
],
|
||||
}
|
||||
]);
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "commands")).to.be.true;
|
||||
expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false;
|
||||
});
|
||||
})
|
||||
|
||||
describe("permissionsCheckActionAny", () => {
|
||||
it("will return false for an empty actor set", () => {
|
||||
const bridgePermissions = new BridgePermissions([]);
|
||||
expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be.false;
|
||||
});
|
||||
it(`will return false for a service with an insufficent level`, () => {
|
||||
const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands");
|
||||
expect(
|
||||
bridgePermissions.checkActionAny(
|
||||
"@foo:bar",
|
||||
"login"
|
||||
)
|
||||
).to.be.false;
|
||||
});
|
||||
const checkActorValues = ["@foo:bar", "bar", "*"];
|
||||
checkActorValues.forEach(actor => {
|
||||
it(`will return true for a service defintion of '${actor}' that has a sufficent level`, () => {
|
||||
const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands");
|
||||
expect(
|
||||
bridgePermissions.checkActionAny(
|
||||
"@foo:bar",
|
||||
"commands"
|
||||
)
|
||||
).to.be.true;
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user