casl 1.0.0 copy "casl: ^1.0.0" to clipboard
casl: ^1.0.0 copied to clipboard

Isomorphic authorisation for Dart. Define what a user can do with rules, ask `ability.can(...)`, and share the exact same rules with a CASL.js backend.

casl #

Isomorphic authorisation for Dart, wire-compatible with CASL.js v7.

Your server already decides what a user may do. This lets a Dart client ask the same rules the same question — so a button and an endpoint cannot disagree, and nobody has to maintain the policy twice.

final ability = createMongoAbility([
  RawRule.of(action: 'read', subject: 'Article'),
  RawRule.of(
    action: 'update',
    subject: 'Article',
    conditions: {'authorId': currentUserId},
  ),
]);

ability.can('read', 'Article');     // may I read articles at all?
ability.can('update', article);     // may I update *this* one?
Wire-compatible, and checked 94 cases run through the real @casl/ability@7.0.1 and replayed here, so a divergence fails a build rather than reaching a user
Type-safe at no cost declare your actions and a typo stops compiling — and they are still plain strings on the wire
Pure Dart, one dependency meta. Runs in Flutter, on a server, in a build script
Fast can in ~50 ns against a subject type, ~130 ns against an instance, over a thousand rules
Widgets casl_flutter

Contents #


Install #

dependencies:
  casl: ^1.0.0
import 'package:casl/casl.dart';

Coming from 0.1.0? MIGRATING.md.


The model in one minute #

A rule grants (or forbids) an action on a subject, optionally narrowed by conditions and fields.

RawRule.of(
  action: 'update',                       // what
  subject: 'Article',                     // to what kind of thing
  conditions: {'authorId': 7},            // only those matching
  fields: ['title', 'body'],              // only these parts of them
  inverted: false,                        // grant, or forbid
  reason: null,                           // shown when it forbids
);

An ability is an ordered list of rules that answers can.

The pieces, and where each is decided:

Term Is Decided by
Action a verb — read, update, manage you
Subject type a noun — Article, all you
Subject a subject type, or an instance of one the call site
Condition a MongoDB-shaped test over the instance the rule
Field one property of the subject the call site

Actions and subject types are strings on the wire, because the set is the server's rather than the client's — a new subject type added on the backend must not need an app release to be checkable, only to be used. They do not have to be strings in your code; see making a typo stop compiling.


Writing rules #

Rules usually arrive from a server. When you write them by hand, defineAbility is the shortest way:

final ability = defineAbility((can, cannot) {
  can('read', 'Article');
  can.each(['update', 'delete'], 'Article', {'authorId': userId});
  cannot('delete', 'Article', {'published': true})
      .because('a published article cannot be deleted');
});

can and cannot are the same objects AbilityBuilder exposes, so name them whatever reads best where you are — allow and forbid if can next to ability.can confuses people. can(…) writes a rule about one action; can.each([…]) writes one rule covering several.

AbilityBuilder is the same thing spread out, for when the rules are assembled across several branches:

final builder = AbilityBuilder()
  ..can('read', 'Article')
  ..can.each(['update', 'delete'], 'Article', {'authorId': userId});

if (user.isModerator) builder.can('publish', 'Article');

final moderatorAbility = builder.build();

Conditions and fields are positional, in that order — can(action, subject, conditions, fields). CASL.js overloads its third parameter on the type it is given; Dart cannot, so the order is fixed and the analyzer checks it. Conditions come first because they are far more common; a rule with fields and no conditions passes null for them.

Order is meaning. Write the broad grant first and narrow it afterwards — see below.


Making a typo stop compiling #

ability.can('reed', article) compiles. It denies. It denies forever, quietly, and surfaces weeks later as a support ticket about a missing button. Nothing in the type system, the tests or the analyzer catches it, because 'reed' is a perfectly good String.

Declare your vocabulary once and it does not compile:

extension type const AppAction(String wire) implements String {
  static const read = AppAction('read');
  static const create = AppAction('create');
  static const update = AppAction('update');
  static const delete = AppAction('delete');
  static const manage = AppAction('manage');

  /// For a screen that lists them. Extension types have no `values` of their
  /// own — and unlike an enum's, this one costs nothing at runtime.
  static const values = <AppAction>[read, create, update, delete, manage];
}

extension type const AppSubject(String wire) implements String {
  static const article = AppSubject('Article');
  static const user = AppSubject('User');
  static const all = AppSubject('all');
}

typedef AppAbility = Ability<AppAction, AppSubject>;
final AppAbility typed = defineAbility<AppAction, AppSubject>((can, cannot) {
  can(AppAction.read, AppSubject.article);
  can(AppAction.update, AppSubject.article, {'authorId': userId});
});

typed.can(AppAction.update, article);
final wrong = createMongoAbility<AppAction, AppSubject>(const []);

wrong.can('reed', article);      // ✗ does not compile

This costs nothing. Extension types erase completely: an AppAction is a String at runtime. Not one byte changes on the wire, there is no conversion per check, and rules from a server need no adaptation — including rules mentioning actions your vocabulary has never heard of, which still index and still deny.

Leaving the type arguments off gives Ability<String, String>, which is exactly what it always was. Typed and untyped code interoperate: Ability<AppAction, AppSubject> is an Ability<String, String>, so anything written against the untyped form keeps working.

Two things it does not catch, and they are the same two TypeScript does not catch either: a typo inside the declaration (static const read = AppAction('reed')), and anything reached through dynamic.


Three things that surprise people #

1. The last matching rule wins #

Not "cannot beats can".

..cannot('read', 'Article')
..can('read', 'Article')      // → can('read', 'Article') is TRUE

Rules are prioritised in reverse, and can returns the first match that is not inverted. So a rule written later overrides one written earlier, in either direction.

This is what makes rule sets composable — a role can be layered on top of a base grant and actually change it:

final layered = defineAbility((can, cannot) {
  can('manage', 'all');                            // base: an administrator
  cannot('delete', 'Invoice');                     // except: finance keeps them
  can('delete', 'Invoice', {'status': 'draft'});   // unless it is a draft
});

It is also CASL.js's behaviour exactly. A library implementing "any matching cannot forbids" would answer differently from your server, on precisely the accounts whose rules are interesting.

2. manage and all are not special-cased #

They are ordinary entries in the rule index that every lookup merges with. One rule, manage:all, is an administrator's entire grant:

final everything = createMongoAbility([
  RawRule.of(action: 'manage', subject: 'all'),
]);

everything.can('destroy', 'Invoice');   // true

Which is why a permission check written as permissions.contains('read:User') locks out precisely the account allowed to do everything, and nobody else — a bug that survives testing, because the developer testing it is usually the administrator.

Both names are configurable, because they are conventions rather than laws:

Ability(const [], anyActionName: 'ALL', anySubjectTypeName: 'EVERYTHING');

3. Asking about a type is a different question from asking about an instance #

ability.can('update', 'Article');   // is there ANY article I may update?
ability.can('update', article);     // may I update this one?

With no instance there is nothing to evaluate conditions against. A permitting rule therefore answers yes — there may well be an article you can edit, so do not hide the menu item — while a forbidding rule answers no only if its conditions restrict nothing.

Without that asymmetry, "you cannot edit articles you did not write" would read as "you cannot edit articles", and the feature would disappear for everyone.

Use the type form for navigation and menus, the instance form for the control attached to a particular row.


Subject types, and why Dart needs care here #

CASL.js reads object.constructor.name. The Dart equivalent is runtimeType.toString() — and --obfuscate renames it. A direct translation would authorise correctly in debug and silently differently in the build that ships, with nothing in the logs either way.

So declare the name. On a class you own:

class Article with CaslSubject {
  @override
  String get caslSubjectType => 'Article';
}
ability.can('update', Article());

At the call site, for anything else — a JSON map, a generated model, a type from another package:

ability.can('update', subject('Article', json));

Or supply your own detection for a whole application. Returning null defers to the default, so subject() and CaslSubject keep working underneath it:

class ApiModel {
  ApiModel(this.type);
  final String type;
}

final detected = createMongoAbility(
  const [],
  detectSubjectType: (value) => value is ApiModel ? value.type : null,
);

runtimeType remains the last resort, and is documented as one.


Conditions #

MongoDB-shaped, and the same fourteen operators CASL.js ships with:

$eq $ne $lt $lte $gt $gte $in $nin $all $size $regex $options $elemMatch $exists

final scoped = defineAbility((can, _) {
  can('read', 'Article', {
    'authorId': userId,
    'status': {r'$in': ['draft', 'review']},
    'views': {r'$gte': 100},
    'title': {r'$regex': '^draft', r'$options': 'i'},
  });
});

Several keys are an implicit AND. Several operators on one field are an AND too, so {'views': {r'$gte': 10, r'$lte': 50}} is a range.

Nested paths use dots, and a list part-way along is flattened — so 'comments.author' over a list of comments reads as a list of authors, and the condition matches if any of them does. That is MongoDB's behaviour and the reason the dotted form does the useful thing.

There are no top-level operators #

No $or, no $and, no $nor. A query is an implicit AND of field tests, which is CASL's default exactly. An unknown operator throws rather than being ignored — ignoring one turns a restrictive rule into a permissive one.

If your server sends them, switch them on rather than refusing them:

final withLogical = createMongoAbility(
  const [],
  parser: const MongoQueryParser().withOperators(logicalOperators),
);

The parts worth reading twice #

// a list matches if it CONTAINS the value
{'tags': 'draft'}                  // matches {'tags': ['draft', 'new']}

// or if the whole list is equal
{'tags': ['a', 'b']}               // matches {'tags': ['a', 'b']} exactly

// null means absent OR null — of something that could have had the field
{'deletedAt': null}                // matches {} and {'deletedAt': null}
{'deletedAt': {r'$exists': false}} // absent only
{'deletedAt': {r'$exists': true}}  // present, even if null

Reading a model directly, with no conversion to a map on every check:

class Invoice with CaslSubject, CaslRecord {
  Invoice(this.total);
  final int total;

  @override
  String get caslSubjectType => 'Invoice';

  @override
  Object? caslField(String name) => switch (name) {
    'total' => total,
    _ => null,
  };
}

Or supply a reader for a shape you do not control:

final rows = createMongoAbility(
  const [],
  read: (target, field) => (target as Map<String, Object?>)[field],
);

Field-level rules #

A rule may name the fields it covers. * stops at a dot and ** crosses them, as in a shell glob:

final form = defineAbility((can, cannot) {
  can('update', 'Article', null, ['title', 'body', 'address.**']);
  cannot('update', 'Article', {'published': true}, ['title']);
});

form.can('update', 'Article', 'title');

Asking "which fields, then?" is what a form needs — and asking rule by rule would get it wrong, because a later rule can take a field back:

final editable = permittedFieldsOf(
  ability,
  'update',
  article,
  allFields: const ['title', 'body', 'published'],
);

allFields cannot be inferred: a rule with no fields means every field, and only you know what a subject's fields are. Dart has no runtime reflection to fall back on, and generating the list would tie it to your class's identifiers rather than to the names the server writes rules about.

For a repository asking per row, hold the parts that do not change:

final readable = AccessibleFields(
  ability,
  'read',
  allFieldsOf: (type) => const {
    'Article': ['title', 'body'],
    'User': ['name', 'email'],
  }[type]!,
);

readable.ofType('Article');   // every article's readable fields
readable.of(article);         // this one's, conditions evaluated

Turning rules into a database query #

can answers about one record. A list screen needs the other question — which records — and asking can per row means fetching rows the user may not see and throwing them away. That is wrong on a page of ten and impossible on a table of ten million.

List<Object?> readableArticles() {
  final where = rulesToAst(ability, 'read', 'Article');

  if (where == null) return const [];     // nothing at all is permitted
  return database.select(where);
}

null means "fetch nothing" and is not the same as an empty filter, which means "fetch everything". Conflating them is the bug this function exists to prevent.

The result is a Condition tree — a sealed type you pattern-match to produce whatever your database speaks:

String toSql(Condition condition) => switch (condition) {
  FieldCondition(:final operator, :final field, :final value) =>
    '$field $operator $value',
  CompoundCondition(:final operator, :final conditions) =>
    '(${conditions.map(toSql).join(' $operator ')})',
};

rulesToCondition is the same algorithm over any target language, if you would rather build a Drift Expression than walk a tree.

It is not simply "OR the permitting rules together". can stops at the first matching rule, so a permitting rule is only reached when no higher-priority forbidding rule caught the record first — and a query has no such ordering. Each permitting rule becomes its own OR branch, bounded by the negation of every forbidding rule above it:

..can('read', 'Article', {'published': true})     // lowest priority
..cannot('read', 'Article', {'secret': true})
..can('read', 'Article', {'pinned': true})        // highest

// → or(pinned, and(published, not(secret)))

Taking rules from a server #

packRules and unpackRules read and write CASL.js's compact array format — the one meant for a JWT:

final compact = packRules(ability.rules);
final restored = unpackRules(compact);

RawRule.fromJson and toJson handle the verbose object form. Everything that can go wrong reading a rule raises a FormatException, including a condition your build cannot parse — so one catch clause covers the boundary:

void applyRules(List<Object?> incoming) {
  try {
    ability
      ..update(unpackRules(incoming))
      ..validateRules();
  } on FormatException catch (error) {
    log.warning('the server sent rules this build cannot read', error);
  }
}

validateRules is worth the line. Conditions compile lazily, and more lazily than it looks — a rule checked only against a subject type never compiles its conditions at all — so a rule carrying an operator this build does not know can pass can('read', 'Article') and only throw later, when somebody opens a list. validateRules moves that to a point you chose.

The other half of the answer, for a client that may be older than its server:

final tolerant = createMongoAbility(
  const [],
  onUnparsableCondition: UnparsableCondition.deny,
);

A rule it cannot read then grants nothing, instead of throwing. Denying rather than ignoring is deliberate: a condition nobody can evaluate must not be read as "no condition".


Refusing, with a reason #

Screens ask can and draw accordingly. The layer underneath — a use case invoked from somewhere that should already have checked — wants to throw:

ability.throwUnlessCan('delete', article);
// ForbiddenError: published articles are kept

For a message the rule could not know:

ForbiddenError.from(ability)
    .setMessage('You cannot delete posts')
    .throwUnlessCan('delete', article);

And to report rather than raise — a disabled button that explains itself:

final refusal = ability.errorUnlessCan('delete', article);
final tooltip = refusal?.reason;   // the rule's own words, or null

reason is what the rule said. message is that, or the default if it said nothing. Set the default once at startup — it is called at throw time, so reading the current locale inside it works:

ForbiddenError.setDefaultMessage('Not authorised');
ForbiddenError.describe = (e) => 'You cannot ${e.action} a ${e.subjectType}';

Action aliases #

final resolve = createAliasResolver({
  'modify': ['update', 'delete'],
  'access': ['read', 'modify'],     // aliases may chain
});

final aliased = createMongoAbility(const [], resolveActions: resolve);

Aliases work in one direction only: granting modify grants update and delete, but granting both does not grant modify. They are resolved once, when the ability is built, which is what keeps can fast.

Cycles and aliases of manage are rejected — including indirect cycles, which CASL.js documents itself as not detecting.


Reacting to a change of rules #

An ability is mutable. update replaces the whole grant, which is what a token refresh does when a role has changed:

final off = ability.on('updated', (event) {});
ability.update(const []);
off();   // stop listening

update fires before the change and updated after. A UI wants the second; a cache that needs to read the outgoing state wants the first. A listener may unsubscribe — itself or another — from inside the callback.

In Flutter, casl_flutter does this wiring for you.


Extending it #

Every part of the evaluation is a table you can add to. An operator takes two halves — parsing $name into a condition, and evaluating that condition:

Condition parseMod(OperatorCall call) {
  final value = call.value;
  return value is List && value.length == 2
      ? FieldCondition('mod', call.field, value)
      : call.refuse('expects a list of two whole numbers');
}

bool interpretMod(
  FieldCondition node,
  Object? subject,
  ConditionInterpreter it,
) {
  final [divisor as int, remainder as int] = node.value! as List;
  return it.anyOf(
    it.valueOf(node, subject),
    (v) => v is int && v % divisor == remainder,
  );
}
final extended = createMongoAbility(
  const [],
  parser: const MongoQueryParser().withOperators({r'$mod': parseMod}),
  interpreter: const ConditionInterpreter().withOperators(
    fields: {'mod': interpretMod},
  ),
);

Use call.refuse rather than throwing, so a client configured to tolerate rules it cannot read still does. Think first about whether the server sending these rules understands the same operator — a condition one side evaluates differently is worse than one neither does.

The other seams: read for how a field is read from an object, fieldsMatcher for the wildcard syntax, equals for how two values are compared, and conditionsMatcher for replacing MongoDB syntax entirely. A rule's conditions stay a Map so they can travel; a matcher may read that map however it likes, including as the name of a Dart predicate.


Testing #

Test the function that distributes rules, not can:

List<RawRule> rulesFor({required bool isModerator}) =>
    defineAbility((can, cannot) {
      can('read', 'Article');
      if (isModerator) can('publish', 'Article');
    }).rules;

can is pure — for the same rules it always answers the same thing, and testing it tests this package. What is worth testing is that a moderator gets the moderator rules, because that is the part your application wrote.


Performance #

Over a thousand rules, on an M-series laptop (dart run benchmark/casl_benchmark.dart):

can against a subject type 46 ns
can against an instance, conditions evaluated 133 ns
can for an administrator — manage:all 50 ns
can with a field, on a rule set that has per-field rules 97 ns
Build the index 137 µs
unpackRules of 1 000 rules 54 µs

API reference #

Defining defineAbility · defineAbilityAsync · AbilityBuilder · RuleAdder · RuleRef
Asking Ability.can · .cannot · .relevantRuleFor · .rulesFor · .possibleRulesFor · .actionsFor
Rules RawRule · RawRule.of · RawRule.fromJson · Rule · RuleIndex · .update · .validateRules
Subjects subject · ForcedSubject · CaslSubject · CaslRecord · detectSubjectTypeByRuntimeType
Conditions createMongoAbility · MongoQueryParser · ConditionInterpreter · Condition · FieldCondition · CompoundCondition · CaslFields
Fields permittedFieldsOf · AccessibleFields · fieldPatternMatcher
Queries rulesToAst · rulesToCondition · rulesToFields · QueryLanguage
Interop packRules · unpackRules · ForbiddenError · AbilityGuard
Extending logicalOperators · defaultOperators · UnparsableCondition · ConditionFormatException · ValueEquality · caslStrictJsEquality

Every exported member is documented; dart doc is the reference.


Compared with CASL.js #

Parity is tested, not claimed. Ninety-four cases run through a pinned @casl/ability@7.0.1 and are replayed here, so a divergence fails a build rather than reaching a user:

cd tool/parity && npm run generate   # records what CASL.js answers
cd packages/casl && dart test        # replays it

Everything matches — precedence, the fourteen operators, the packed wire format, rulesToCondition — except the differences below. Each is deliberate, each is pinned by a fixture carrying its reason, and none can be introduced by accident.

Behaviour #

Here CASL.js Why
{'tags': ['a','b']} against {'tags': ['a','b']} matches does not CASL.js compares with ===, so it cannot match a nested object or a whole array by value. We compare by value, as MongoDB does. Pass strictJsEquality: true to get their behaviour exactly
{'x': {r'$gt': 3}} against {'x': '10'} denies permits JavaScript coerces across types. A condition comparing a string to a number is a mistake, and denying is the safe reading of a mistake
An unknown operator ($or, $mod) throws silently never matches A rule nobody can evaluate is a rule nobody should trust. UnparsableCondition.deny and validateRules are the two levers
permittedFieldsOf with ['address.*'] ['address.city'] ['address.*'] A form needs field names, not patterns. Order follows allFields, because a form's field order is a design decision

Shape #

Here CASL.js Why
Subject types declared (CaslSubject / subject()) read from constructor.name runtimeType is wrong under --obfuscate, silently, in release builds only
Field matcher defaulted must be supplied * and ** mean one thing everywhere; defaulting cannot change what a rule means
Packing a rule with no subject ["read","all"] ["read"] Both unpack to the same ability and index identically. Remembering whether the subject was written down would be state carried for a cosmetic difference
Reading a bad rule always a FormatException mixed An Error means "a programmer wrote this wrong", and there is no programmer involved in a payload off a network

Naming follows v7: what v6 called PureAbility is Ability here, and the old Ability that bundled a Mongo matcher is createMongoAbility.


Porting from CASL.js #

@casl/ability casl
new Ability(rules, opts) Ability(rules, …)
createMongoAbility(rules) createMongoAbility(rules)
new AbilityBuilder(createMongoAbility) AbilityBuilder() the factory defaults
defineAbility((can, cannot) => …) defineAbility((can, cannot) { … }) plus defineAbilityAsync
can(['a','b'], 'S', conds) can.each(['a','b'], 'S', conds) Dart cannot overload on argument type
can('a', 'S', ['f'], conds) can('a', 'S', conds, ['f']) conditions before fields — the common case first
.because(reason) .because(reason) chainable, as there
ability.can(a, s, f) ability.can(a, s, f)
ability.update(rules) ability.update(rules) returns the ability, as there
ability.on('updated', fn) ability.on('updated', fn) returns an Unsubscribe
subject('Article', obj) subject('Article', obj)
ForbiddenError.from(a).setMessage(m).throwUnlessCan(…) same plus a.throwUnlessCan(…)
ForbiddenError.setDefaultMessage(str) same a function goes to ForbiddenError.describe
createAliasResolver(map) createAliasResolver(map) passed as resolveActions:
buildMongoQueryMatcher(instr, interp) MongoQueryParser.withOperators + ConditionInterpreter.withOperators both halves, explicitly
packRules / unpackRules same
permittedFieldsOf(a, act, sub, {fieldsFrom}) permittedFieldsOf(a, act, sub, allFields:) patterns expanded for you
AccessibleFields AccessibleFields
rulesToFields / rulesToAST / rulesToQuery rulesToFields / rulesToAst / rulesToCondition
MongoAbility<[Action, Subject]> Ability<AppAction, AppSubject> extension types over String
hkt, Generics, InferSubjects type-level only; the generics above solve what they solve
class as a subject type, modelName runtimeType is unsafe under obfuscation; declare the name instead

Licence #

MIT.

2
likes
160
points
158
downloads

Documentation

API reference

Publisher

verified publisherteispace.com

Weekly Downloads

Isomorphic authorisation for Dart. Define what a user can do with rules, ask `ability.can(...)`, and share the exact same rules with a CASL.js backend.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#authorization #permissions #security #casl #rbac

License

MIT (license)

Dependencies

meta

More

Packages that depend on casl