WIP: Computing opinions

This commit is contained in:
David Teller 2022-07-28 16:39:18 +02:00
parent 81eba65ae3
commit 9425118b60
4 changed files with 117 additions and 31 deletions

View File

@ -355,7 +355,7 @@ export class Mjolnir {
this.currentState = STATE_SYNCING;
if (config.syncOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(config.verboseLogging);
await this.syncPolicyLists(config.verboseLogging);
await this.registerProtections();
}
@ -434,7 +434,7 @@ export class Mjolnir {
const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
await this.syncLists(config.verboseLogging);
await this.syncPolicyLists(config.verboseLogging);
}
public async removeProtectedRoom(roomId: string) {
@ -482,7 +482,7 @@ export class Mjolnir {
this.applyUnprotectedRooms();
if (withSync) {
await this.syncLists(config.verboseLogging);
await this.syncPolicyLists(config.verboseLogging);
}
}
@ -882,7 +882,7 @@ export class Mjolnir {
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
* @param verbose Whether to report any errors to the management room.
*/
public async syncLists(verbose = true) {
public async syncPolicyLists(verbose = true) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);

View File

@ -163,7 +163,7 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (unbannedSomeone) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
await mjolnir.syncLists(config.verboseLogging);
await mjolnir.syncPolicyLists(config.verboseLogging);
}
}

View File

@ -16,7 +16,7 @@ limitations under the License.
import { extractRequestError, LogService, MatrixClient, UserID } from "matrix-bot-sdk";
import { EventEmitter } from "events";
import { ALL_RULE_TYPES, EntityType, PolicyRule, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./PolicyRule";
import { ALL_RULE_TYPES, EntityType, PolicyRule, PolicyRuleOpinion, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./PolicyRule";
export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode";
@ -61,8 +61,13 @@ declare interface PolicyList {
}
/**
* The PolicyList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc.
* This cannot be used to update events in the modeled room, it is a readonly model of the policy room.
* A readonly view of the rules within a policy room.
*
* The interpretation of these rules is left to the client of this API, so e.g. a `m.ban` within a `PolicyList`
* could represent a room ban or a server ban.
*
* To update events in the policy room, send `m.policy.rule.*` events into the room. This will (eventually) cause
* a `/sync`, which will update the `PolicyList`.
*/
class PolicyList extends EventEmitter {
private shortcode: string | null = null;
@ -167,24 +172,14 @@ class PolicyList extends EventEmitter {
/**
* Return all of the rules in this list that will match the provided entity.
* If the entity is a user, then we match the domain part against server rules too.
* @param ruleKind The type of rule for the entity e.g. `RULE_USER`.
* @param ruleKind The type of rule for the entity e.g. `RULE_USER`. If unspecified, extract the type from `entity`.
* @param entity The entity to test e.g. the user id, server name or a room id.
* @returns All of the rules that match this entity.
*/
public rulesMatchingEntity(entity: string, recommendation: Recommendation | "*", ruleKind?: EntityType): PolicyRule[] {
const ruleTypeOf: (entityPart: string) => EntityType = (entityPart: string) => {
if (ruleKind) {
return ruleKind;
} else if (entityPart.startsWith("#") || entityPart.startsWith("!")) {
return EntityType.RULE_ROOM;
} else if (entity.startsWith("@")) {
return EntityType.RULE_USER;
} else {
return EntityType.RULE_SERVER;
}
};
ruleKind = ruleKind || extractEntityType(entity);
if (ruleTypeOf(entity) === RULE_USER) {
if (ruleKind === RULE_USER) {
// We special case because want to see whether a server ban is preventing this user from participating too.
const userId = new UserID(entity);
return [
@ -192,10 +187,58 @@ class PolicyList extends EventEmitter {
...this.getServerRules(recommendation).filter(rule => rule.isMatch(userId.domain))
]
} else {
return this.rulesOfKind(ruleTypeOf(entity), recommendation).filter(rule => rule.isMatch(entity));
return this.rulesOfKind(ruleKind, recommendation).filter(rule => rule.isMatch(entity));
}
}
/**
* Return the opinion for a given entity.
* @returns The opinion specified by this list or `0` if no opinion was defined.
*/
public opinionForEntity(entity: string, ruleKind?: EntityType): number {
ruleKind = ruleKind || extractEntityType(entity);
// Find all rules for this entity.
const allRules = this.rulesMatchingEntity(entity, Recommendation.Opinion, ruleKind);
// Split across server rules and specialized rules.
const byGenericity: PolicyRule[][] = [
/* User/room rules without wildcards */ [],
/* User/room rules with wildcards */ [],
/* Server rules without wildcards */ [],
/* Server rules with wildcards */ [],
];
for (let rule of allRules) {
const baseIndex = rule.kind === EntityType.RULE_SERVER ?
2 : 0;
const finalIndex = rule.isGeneric ?
baseIndex + 1 : baseIndex;
byGenericity[finalIndex].push(rule);
}
// Find the most specific rule for this entity.
// If there is more than one, pick the most recent entry.
for (let rules of byGenericity) {
let mostRecentRule = null;
for (let rule of rules) {
if (!mostRecentRule || rule.ts > mostRecentRule.ts) {
mostRecentRule = rule;
}
}
if (mostRecentRule) {
if (mostRecentRule instanceof PolicyRuleOpinion) {
return mostRecentRule.opinion;
} else {
// This should be impossible.
throw new TypeError();
}
}
}
// By default, the opinion is 0.
return 0;
}
/**
* Remove all rules in the banList for this entity that have the same state key (as when we ban them)
* by searching for rules that have legacy state types.
@ -404,3 +447,19 @@ class UpdateBatcher {
this.checkBatch(eventId);
}
}
/**
* Extract the entity type best matching a given entity.
*
* @param entity If this entity is a user id, return `RULE_USER`. If it is a room id or alias, return `RULE_ROOM`.
* Otherwise, return `RULE_SERVER`.
*/
function extractEntityType(entity: string): EntityType {
if (entity.startsWith("#") || entity.startsWith("!")) {
return EntityType.RULE_ROOM;
} else if (entity.startsWith("@")) {
return EntityType.RULE_ROOM;
} else {
return EntityType.RULE_SERVER;
}
}

View File

@ -86,6 +86,11 @@ export abstract class PolicyRule {
* A glob for `entity`.
*/
private glob: MatrixGlob;
/**
* `true` if the rule contains at least one wildcard (`?` or `*`).
*/
public readonly isGeneric: boolean;
constructor(
/**
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
@ -95,6 +100,10 @@ export abstract class PolicyRule {
* A human-readable reason for this rule, for audit purposes.
*/
public readonly reason: string,
/**
* A Matrix timestamp for the instant this rule was issued.
*/
public readonly ts: number,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
@ -103,8 +112,10 @@ export abstract class PolicyRule {
* The recommendation for this rule, e.g. "ban" or "opinion", or `null`
* if the recommendation is one that Mjölnir doesn't understand.
*/
public readonly recommendation: Recommendation | null) {
public readonly recommendation: Recommendation | null,
) {
this.glob = new MatrixGlob(entity);
this.isGeneric = entity.includes("?") || entity.includes("*");
}
/**
@ -120,9 +131,13 @@ export abstract class PolicyRule {
* @param event An *untrusted* event.
* @returns null if the PolicyRule is invalid or not recognized by Mjölnir.
*/
public static parse(event: {type: string, content: any}): PolicyRule | null {
public static parse(event: {type: string, origin_server_ts: number, content: any}): PolicyRule | null {
// Parse common fields.
// If a field is ill-formed, discard the rule.
const ts = event['origin_server_ts'];
if (!ts || typeof ts !== 'number') {
return null;
}
const content = event['content'];
if (!content || typeof content !== "object") {
return null;
@ -155,17 +170,17 @@ export abstract class PolicyRule {
// From this point, we may need specific fields.
if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) {
return new PolicyRuleBan(entity, reason, kind);
return new PolicyRuleBan(entity, reason, ts, kind);
} else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) {
let opinion = content['opinion'];
if (!Number.isInteger(opinion)) {
return null;
}
return new PolicyRuleOpinion(entity, reason, kind, opinion);
return new PolicyRuleOpinion(entity, reason, ts, kind, opinion);
} else {
// As long as the `recommendation` is defined, we assume
// that the rule is correct, just unknown.
return new PolicyRuleUnknown(entity, reason, kind, content);
return new PolicyRuleUnknown(entity, reason, ts, kind, content);
}
}
}
@ -183,12 +198,16 @@ export class PolicyRuleBan extends PolicyRule {
* A human-readable reason for this rule, for audit purposes.
*/
reason: string,
/**
* A Matrix timestamp for the instant this rule was issued.
*/
ts: number,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
kind: EntityType,
) {
super(entity, reason, kind, Recommendation.Ban)
super(entity, reason, ts, kind, Recommendation.Ban)
}
}
@ -205,6 +224,10 @@ export class PolicyRuleOpinion extends PolicyRule {
* A human-readable reason for this rule, for audit purposes.
*/
reason: string,
/**
* A Matrix timestamp for the instant this rule was issued.
*/
ts: number,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
@ -214,9 +237,9 @@ export class PolicyRuleOpinion extends PolicyRule {
* on the entity (e.g. toxic user or community) and +100 represents the best
* possible opinion on the entity (e.g. pillar of the community).
*/
public readonly opinion: number
public readonly opinion: number,
) {
super(entity, reason, kind, Recommendation.Opinion);
super(entity, reason, ts, kind, Recommendation.Opinion);
if (!Number.isInteger(opinion)) {
throw new TypeError(`The opinion must be an integer, got ${opinion}`);
}
@ -240,6 +263,10 @@ export class PolicyRuleUnknown extends PolicyRule {
*/
reason: string,
/**
* A Matrix timestamp for the instant this rule was issued.
*/
ts: number,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
kind: EntityType,
@ -248,6 +275,6 @@ export class PolicyRuleUnknown extends PolicyRule {
*/
public readonly content: any,
) {
super(entity, reason, kind, null);
super(entity, reason, ts, kind, null);
}
}