Capability based extension in Draupnir
Table of Contents
Disclaimer & Introduction
I knocked this post out in 3 hours as a complement to J.Ryan Stinnet's entry to the malleable systems fearless extensibility challange.
The only possibly novel thing I am presenting here is the use of capabilities themselves as an extension mechanism ontop of a plugin system. And exposing the option to hot-swap the capabilities used by each plugin to the end-user in an application.
What is Draupnir and why extension is important for us
Draupnir is a Matrix moderation bot, and extension is important because threats are always changing and even adapting to the current state of safety tooling. Extensibility is very related to modularity and these are both important if rapid changes need to be made to tools to respond to new threats in a timely way while under pressure.
Both extensibility and modularity help us to make changes or write more code quickly with ease. And this is without the risk of disrupting or changing any existing behaviour. Including any underlying abstractions or dependencies used by other parts of the program.
In safety tooling there is also constant change in the current threat model, and what sorts of threats are targeting an online community within a given period. For example, if a longstanding member of a community needs to be removed and they have threatened in advance they will retaliate by creating sock puppets or doing something else, then the threats this community is going to be facing will be drastically different in the hours or days proceeding the ban. Even if the threats themselves are already well understood and their nature is not new.
So in this instance, moderators also need to be able to adapt their tooling to the current threats they are facing to keep disruption and harm to a minimum.
How is Draupnir modular and extensible?
Draupnir uses two concepts to provide modularity and extension, the first is by offering plugins, which Draupnir calls protections. The second is by expressing the behaviour of plugins through capabilities which can also be hot-swapped, configured, and modified in a similar way to plugins themselves. If you can imagine using a standard plugin system, and being able to configure plugins individually, turn them on and off etc. Configuring capabilities is exactly the same as that, except they are dependencies to plugins, and capabilities of the same interface can be changed.
How this looks and what it does
To provide a concrete example of how this works (or would work, it's
not quite finished yet). We have a protection in Draupnir called
TrustedReporters
, essentially this allows you to provide some trust
to members of a Matrix room. When they submit reports against a user,
either by using a !report
command or going through their client UX,
the protection takes special note. When a report hits a certain
threshold, Draupnir can enact a consequence relevant to the report.
This consequence is expressed through a capability, which is a dependency the protection receives when it is instantiated.
Figure 1: Choosing a capability for the threshold of five reporters from a list of capability providers that match the UserConsequence
capability interface.
Here's a mockup I did of what this would look like in a GUI1.
Now there's nothing special about this functionality being provided
here, we could standardise what the consequences are and provide them
in a list like this in the protection. But we have gone a step further
here, capabilities and their interfaces are a first class concept in
Draupnir. So if another protection needs a UserConsequence
they can
reuse the same capabilities and the experience will be
consistent. However, the protection developer can also just create
their own capability interface, so another protection might need to
introduce a ServerConsequence
interface, or they might need to
create something specific to an entirely new domain that isn't
managing Matrix users.
Implementation detail
How this works isn't too complicated tbh. It just involves a little
pinch of meta-programming. For some context before we start, Draupnir
is a TypeScript project, but because of the nature of JavaScript, the
way we use the type system is by inserting checks or parsing code
where we need narrow types from unknown
. You'll need to keep this in
mind as we show you the implementation.
An older and more complete explanation of the capability system in Draupnir can be found in an older blog update.
Overview
Figure 2: An overview of Draupnir's capability metaobjects
For a brief overview that you can keep referring back to as I explain
through the next section, there are three parts to the
system. CapabilityInterfaces
describe the shape of a Capability
,
CapabilityProviders
construct capabilities of a specific
implementation, and capabilities are the objects protections use
themselves2.
The active CapabilityProvider
associated with a protection is what a
user changes when they want to hot-swap the capabilities in use by a
protection.
Capability Interface
export type CapabilityInterfaceDescription = {
name: string;
description: string;
schema: TSchema;
};
The first piece we have is the capability interface, it's pretty self explanatory. Protections use a capability interface to describe the shape of the capability that they expect. The schema is used at runtime to verify that a capability provided to the protection matches the expected shape at runtime.
You can find an example of the UserConsequences
interface here.
Capability Provider
export interface CapabilityProviderDescription<Context = unknown> { /** Used by the user to identify the description */ name: string; description: string; interface: CapabilityInterfaceDescription; /** * Returns an instance of the provider. * @param protectionDescription A description of the protection that we are making the provider for. * Mostly so that the capability provider can audit the protection. * @param context Anything used to create the capability, usually the ProtectedRoomsSet context, * like Draupnir. */ factory(protectionDescription: DescriptionMeta, context: Context): Capability; }
The capability provider is what a user chooses when they are "changing the capability" that is provided to a protection. It is basically a factory for the capability that accepts a context object, which is sort of like a god object/platform/lobby object that various dependencies can be destrutured from to create the attenuated capability. In Draupnir's case, this object would be an instance of the Draupnir class itself. This isn't really a capability-safe way of creating capabilities, but it's the magic glue we use to get some semblance of a capability based language into TypeScript.
These Capability providers can be defined and created in much the same way as protections themselves. So in a conventional plugin system, you'd expect third party developers to create plugins, but here they can create both plugins and capability providers, so that the behaviour of existing plugins can be changed without needing to fork them or literally edit their code.
As you can see we also associate the interface that the provider is for with the description object. I don't really like this, and if you are making a capability-safe programming language3 do not do this because you will be creating a nominal type system by mistake and you want a structural one for this. I don't do this because I haven't written the right pattern matching code and it's out of scope x3
Protection description
To finish off I am going to explain how the capability interfaces get associated with a protection.
export type ProtectionFactoryMethod< Context = unknown, TSettings extends Record<string, unknown> = Record<string, unknown>, TCapabilitySet extends CapabilitySet = CapabilitySet, > = ( description: ProtectionDescription<Context, TSettings, TCapabilitySet>, protectedRoomsSet: ProtectedRoomsSet, context: Context, capabilities: TCapabilitySet, settings: TSettings ) => Result< Protection<ProtectionDescription<Context, TSettings, TCapabilitySet>> >; export interface ProtectionDescription< Context = unknown, TSettings extends UnknownSettings<string> = UnknownSettings<string>, TCapabilitySet extends CapabilitySet = CapabilitySet, > { readonly name: string; readonly description: string; readonly capabilities: CapabilityInterfaceSet<TCapabilitySet>; readonly factory: ProtectionFactoryMethod<Context, TSettings, TCapabilitySet>; readonly protectionSettings: ProtectionSettings<TSettings>; readonly defaultCapabilities: CapabilityProviderSet<TCapabilitySet>; }
There's a lot to look at in these type definitions but the important
part really is the CapabilityInterfaceSet
under the property
capabilities
. All this does is describe a Record
of capability
names and capability interfaces. This will get used to provide another
Record
of capability names and capabilities to the protection
factory method in order to create the protection.
The protection description also allows us to provide a default
CapabilityProviderSet
, which will be used to create a set of
capabilities when the user hasn't configured any for the protection.
Reflection so far and my own questions
While the capability system is currently available in the Draupnir v2.0.0-beta, the user is unable to change the active capability provider for protections. We're getting there on our roadmap.
Composing capabilities
I feel like it should be made possible for an end user and not just a
developer to easily compose capabilities. So for example,
composing a capability for UserConsequence
that pings room
moderators with one that removes a user.
How would this work in a capability-safe language?
In this instance we added some kind of capability system onto a capability-unsafe language, and we aren't really following the principle of least authority. While we try isolate protections by encouraging them to express their behaviour through capabilities, the capabilities themselves are still entirely trustful. They have access to anything available through JavaScript and the context object (in our case Draupnir). Protections themselves aren't forced to use capabilities either, they can still access resources and other modules through typescript imports.
In a capability-safe language, a developer would have the ability to substitute or attenuate any dependency. But it's not clear to me how this same feature can be exported to the end-user in an application without them needing to be a programmer. Would the programmer have to choose what capabilities are important for the end-user to change, similar to what I have done here? I'm not comfortable with that, I think we can and should do better.
It's something I'll be considering in my work on the Utena Abstract Machine4.
How has this changed Draupnir so far?
The reason why Draupnir moved to this architecture was actually to modularise its core functionality and break out of a feature freeze, and we've had success here already. In the Matrix safety tooling space, Draupnir's core behaviour is described as a Policy Application Engine or PAE for short. This is a tool that takes a bunch of policies from a blocklist and implements them over a set of rooms that are being protected by the tool. Usually this just means banning any users that are on the blocklist from the rooms that are protected.
In order to move this core-behaviour to a protection or plugin, it forced us to give API parity to the plugin interface with comparison to the various internal APIs that were in use before, and also provide the information in a clean safe way. As a result of this work the natural way to add a feature to Draupnir is to create a protection.
If you want to read more about the history of Draupnir and the challanges that have been faced, then you should look to the blog series that I started at the beginning of the year.
Footnotes:
I don't know anything about design but that's ok.
Source below:
@startuml skin rose hide empty members metaclass CapabilityInterface { name: string schema: TSchema } entity Capability implements CapabilityInterface metaclass CapabilityProvider { name: string interface: CapabilityInterface description: string factory(context): Capability } CapabilityInterface "1" o-- "0..*" CapabilityProvider @enduml