yetki 0.2.0
yetki: ^0.2.0 copied to clipboard
Role-based access control (RBAC) for Dart: role inheritance, wildcard permissions, pluggable persistence and change notifications.
yetki #
Role-based access control (RBAC) for Dart: users get roles, roles grant permissions, and your code asks one question — may this user do that?
This package is pure Dart, so it also runs on the server and in CLI tools.
For Flutter widgets (PermissionGuard, YetkiScope, …) and
shared_preferences storage, use yetki_flutter,
which re-exports everything here.
Features #
- Role inheritance: an
editorcan inherit everything aviewercan do. - Wildcard grants:
posts.*grants everyposts.…permission, and*grants everything. - Validated policy: typos and dangling references throw instead of silently denying access. Batch operations and imports are all-or-nothing.
- Immutable models that compare by value.
- Change notifications through a
Stream, so UI and routers can react. - Pluggable persistence with any
YetkiStorage. Writes are serialized and coalesced. - Versioned JSON import and export, so a policy can come from your backend.
Installation #
dart pub add yetki # Dart
flutter pub add yetki_flutter # Flutter
Quick start #
import 'package:yetki/yetki.dart';
final yetki = Yetki()
..addPermissions(const [
Permission(id: 'posts.read'),
Permission(id: 'posts.write'),
Permission(id: 'posts.delete'),
])
..addRoles([
Role(id: 'viewer', permissions: {'posts.read'}),
Role(id: 'editor', permissions: {'posts.write'}, inherits: {'viewer'}),
Role(id: 'admin', permissions: {'*'}),
])
..setUser(YetkiUser(id: 'u1', roles: {'editor'}));
yetki.hasPermission('posts.read'); // true, inherited from viewer
yetki.hasPermission('posts.delete'); // false
yetki.hasRole('viewer'); // true, through inheritance
Concepts #
| Concept | What it is |
|---|---|
Permission |
A capability with a dot-separated id such as posts.edit. Segments must be non-empty and contain no whitespace or *. |
| Grant | What a role or user is given: a permission id, prefix.* or *. posts.* matches posts.edit and posts.comments.delete, but not posts itself. |
Role |
A named set of grants, plus the ids of roles it inherits from. Cycles are rejected. |
YetkiUser |
A user with assigned roles and optional directPermissions. |
Yetki |
The engine. It holds the policy (permissions and roles) and the current user, validates every change and answers checks. |
Checking access #
yetki.hasPermission('posts.write');
yetki.hasAnyPermission(['posts.write', 'posts.delete']);
yetki.hasAllPermissions(['posts.read', 'posts.write']);
yetki.hasRole('editor');
yetki.hasAnyRole(['editor', 'admin']);
yetki.grantedPermissions(); // {posts.read, posts.write}
// Throws YetkiAccessDeniedException when the permission is missing.
yetki.requirePermission('posts.write');
// Every check can target any user, not only the current one. This is
// useful on a server.
yetki.hasPermission('posts.delete', user: someOtherUser);
Changing the policy and the user #
Models are immutable, and all changes go through Yetki, which validates and
publishes them:
yetki.grantPermissionToRole('viewer', 'comments.read');
yetki.updateRole(yetki.getRole('editor')!.copyWith(name: 'Author'));
yetki.removeRole('viewer'); // also removed from users and child roles
yetki.assignRole('admin'); // on the current user
yetki.grantDirectPermission('billing.view');
yetki.clearUser(); // sign out
Invalid changes throw a subtype of the sealed YetkiException and leave the
policy untouched:
| Exception | When |
|---|---|
YetkiDuplicateException |
The id is already registered. |
YetkiNotFoundException |
A referenced permission or role is not registered. |
YetkiInvalidPolicyException |
An id or wildcard is invalid, roles inherit in a cycle, or the JSON is malformed. |
YetkiAccessDeniedException |
Thrown by requirePermission. |
Reacting to changes #
final subscription = yetki.changes.listen((_) => rebuildMenus());
Changes made in the same synchronous block produce a single event. Call
await yetki.dispose() when you no longer need the instance.
Loading the policy from your backend #
yetki.importFromJson(await api.fetchPolicyJson()); // atomic, throws on error
final json = yetki.exportToJson(); // {"version": 1, ...}
The format contains permissions and roles, never the user. Roles and grants that the current user holds but the new policy lacks are removed from the user.
Persistence #
final yetki = await Yetki.create(
storage: MyStorage(), // implements YetkiStorage
onError: (error, stack) => log(error),
);
Yetki.create finishes restoring before it returns, so no change can race
with the load. After that, every policy change is written back. Writes run
one at a time, and changes made in the same synchronous block are saved in a
single write. await yetki.flush() waits for pending writes, and
await yetki.reset() clears memory and storage.
InMemoryYetkiStorage is included for tests. yetki_flutter provides
SharedPreferencesYetkiStorage.
Security #
Checks that run on a user's device only decide what to show. A user who controls the device can change the app or its storage. Always enforce authorization on your server. To match that model, yetki:
- never persists the current user. Who the user is and which roles they hold must come from your authentication backend on every start;
- persists only when you opt in with
Yetki.create(storage: ...).
Migration from 0.1.x #
0.2.0 is a redesign that fixes several correctness and security problems in 0.1.x. Every breaking change is listed below.
Flutter users: depend on yetki_flutter #
yetki no longer depends on Flutter or shared_preferences. Flutter apps
should depend on yetki_flutter instead. It re-exports yetki, so you only
change imports:
- import 'package:yetki/yetki.dart';
+ import 'package:yetki_flutter/yetki_flutter.dart';
The minimum Dart SDK is now 3.8.
Singleton and caching are replaced #
The useSingleton and useCache parameters and Yetki.clearInstance()
are gone. Yetki() now creates a purely in-memory instance.
- Singleton: create one instance and pass it around, for example through
YetkiScopein Flutter or your DI container. - Caching is now opt-in, and it is awaited so it cannot race with your
changes. The default shared_preferences key is still
yetki_cache, so an existing 0.1.x cache is picked up:
- final yetki = Yetki(useSingleton: true); // cached by default
+ final yetki = await Yetki.create(storage: SharedPreferencesYetkiStorage());
clearCache()is replaced byreset(), which clears memory too.- The current user is no longer persisted. In 0.1.x a revoked role could
come back after a restart. Call
setUseron every start with data from your backend.
Models are immutable; change them through Yetki #
In 0.1.x, changing a Role or YetkiUser after registering it bypassed
validation and was never saved. Those mutating methods are removed:
| 0.1.x | 0.2.0 |
|---|---|
role.addPermission(id) |
yetki.grantPermissionToRole(role.id, id), or role.withPermission(id) before addRole |
role.removePermission(id) |
yetki.revokePermissionFromRole(role.id, id) |
role.hasPermission(id) |
role.permissions.contains(id) |
role.clearPermissions() |
yetki.updateRole(role.copyWith(permissions: {})) |
user.assignRole(id) |
yetki.assignRole(id) for the current user, or user.withRole(id) |
user.revokeRole(id) |
yetki.revokeRole(id) or user.withoutRole(id) |
user.grantDirectPermission(id) |
yetki.grantDirectPermission(id) or user.withDirectPermission(id) |
user.revokeDirectPermission(id) |
yetki.revokeDirectPermission(id) or user.withoutDirectPermission(id) |
user.clearRoles() / clearDirectPermissions() |
user.copyWith(roles: {}) / copyWith(directPermissions: {}) |
The usual 0.1.x pattern becomes:
- final editor = Role(id: 'editor', name: 'Editor');
- editor.addPermission('edit_users');
- yetki.addRole(editor);
- final user = YetkiUser(id: '1', name: 'Jane');
- user.assignRole('editor');
- yetki.setUser(user);
+ yetki.addRole(Role(id: 'editor', name: 'Editor', permissions: {'edit_users'}));
+ yetki.setUser(YetkiUser(id: '1', name: 'Jane', roles: {'editor'}));
Renamed fields #
| 0.1.x | 0.2.0 |
|---|---|
Role(permissionIds: ...), role.permissionIds (List) |
Role(permissions: ...), role.permissions (Set) |
YetkiUser(roleIds: ...) |
YetkiUser(roles: ...) |
YetkiUser(directPermissionIds: ...) |
YetkiUser(directPermissions: ...) |
user.roles, user.directPermissions (List) |
same names, now unmodifiable Sets |
yetki.getCurrentUser() |
yetki.currentUser (old name deprecated) |
yetki.getAllRoles() |
yetki.roles (old name deprecated) |
yetki.getAllPermissions() |
yetki.permissions (old name deprecated) |
Permission.name, Role.name and YetkiUser.name are now optional. The
first two default to the id.
Stricter validation #
- Ids must be non-empty, dot-separated segments without whitespace or
*.view_usersandusers.vieware valid;view usersis not. - References must exist.
addRolethrowsYetkiNotFoundExceptionwhen a role grants an unregistered permission or inherits an unregistered role.setUser,assignRoleandgrantDirectPermissionthrow when the role or permission is unregistered. Register permissions before roles, and parent roles before children, or useaddPermissionsandaddRoles, which accept any order. updateRolerejects inheritance cycles.
Behavior changes #
removeRolealso unassigns the role from the current user and removes it from other roles'inherits.removePermissionalso revokes the permission from the current user. In 0.1.x, re-adding a removed id silently restored access.hasRoleincludes inherited roles and isfalsefor unregistered roles.YetkiUser.hasRolestill only checks direct assignment.==compares all fields, not just the id.hasAll*andhasAny*accept anyIterable<String>. This is not breaking.
Import and export #
importFromJsonthrows aYetkiExceptioninstead of returningfalse, and it is atomic: on error, nothing changes.exportToJsonwrites a versioned format ({"version": 1, "permissions": [...], "roles": [...]}).importFromJsonstill reads the 0.1.x format and ignores itscurrentUser.- The models'
fromJsonconstructors takeMap<String, Object?>and throwYetkiInvalidPolicyExceptionon malformed input. They also accept 0.1.x field names.
Exceptions #
YetkiException is now a sealed base class and cannot be constructed
directly. on YetkiException still catches everything. Catch
YetkiDuplicateException, YetkiNotFoundException,
YetkiInvalidPolicyException or YetkiAccessDeniedException for specific
cases.
Logging #
yetki no longer calls print. Storage errors go to the onError callback
of Yetki.create, or to dart:developer's log by default.
License #
MIT