Permissions
Authorization in the entity system is expression-based and synchronous. A single AuthorizationProvider holds the actor's permissions, roles, and user-group memberships as cached sets, and surfaces evaluate composable Authorize expressions against that snapshot. The framework does not serialise authorization checks behind futures — once the provider is hydrated, every gate is a synchronous boolean.
The two pieces
Authorize(invyuh_entity_annotations) is the closed expression language. It carries no runtime state.AuthorizationProvider(invyuh_entity_system) holds the cached state and evaluates expressions throughbool can(Authorize, {subject, ownership}).- Backend and regulated runtime adapters should use the entity blueprint,
vyuh_iam, andvyuh_policy_enginecontracts directly. They do not replace the FlutterAuthorizegates used by layouts, actions, editors, and routes.
AuthorizationProvider is held by the plugin and exposed via vyuh.entity?.authorizationProvider. It defaults to OpenAuthorizationProvider, which reports every check as allowed — useful for development and tests.
The Authorize DSL
Authorize is a sealed class with three groups of constructors:
| Group | Atoms | Meaning |
|---|---|---|
| Atoms | Authorize.permission('p') | Actor holds permission p. |
Authorize.role('code') | Actor holds role with that code. | |
Authorize.userGroup('id') | Actor is a member of that group. | |
Authorize.self({as}) | Actor is the owner of the subject. | |
| List sugar | anyPermission([...]) / allPermissions([...]) | Lifts to anyOf / allOf over permission atoms. |
anyRole([...]) / allRoles([...]) | Same for roles. | |
| Composition | Authorize.allOf([a, b]) | Logical AND. |
Authorize.anyOf([a, b]) | Logical OR. | |
Authorize.not(a) | Logical NOT. | |
| Singletons | Authorize.allow | Always true (the implicit value when no authorize: is provided). |
Authorize.deny | Always false. |
Authorize.self() requires EntityOwnership metadata on the entity (or a subject: + ownership: pair when calling provider.can directly), so the provider knows which field carries the owner id. Multi-relation entities use the as: parameter to pick the relation.
A typical composed expression:
Authorize.allOf([
Authorize.permission('lms.batch.approve'),
Authorize.not(Authorize.self()), // submitter cannot self-approve
])AuthorizationProvider
The provider exposes:
- State:
permissions,roles,userGroups,currentUserId. - Synchronous primitives:
hasPermission,hasRole,isMemberOf. - Expression evaluation:
bool can(Authorize, {subject, ownership}). - Readiness:
isReady(true after the first refresh attempt resolves),isRefreshing,whenReady(aFuture),changes(a broadcastStream<void>). - Refresh:
Future<void> refresh()andbindRefreshStream(Stream<void>). Every emission of the bound stream triggers a refresh.
The plugin wires bindRefreshStream(vyuh.auth.userChanges) automatically, so login/logout always re-hydrates authorization. Add additional triggers (server-broadcast role changes, manual "Refresh" buttons) by binding more streams.
Denial logging
Set provider.onDeny = (expr, context) { ... } once at startup to capture every denial. The callback fires synchronously after evaluation, so implementations should unawaited(...) any I/O.
OpenAuthorizationProvider
Reports {'*'} for every set and returns true for every check. The plugin uses it by default so a fresh app boots without authorization wiring. Replace it in production:
EntitySystemPlugin(
baseUrl: 'https://api.example.com',
authorizationProvider: MyAuthorizationProvider(),
)Where Authorize is evaluated
Layouts
Every LayoutBase<T> (collection, entity, aggregate, item, comparison) carries an optional authorize: field. EntityLayouts.filterByPermission takes an authorizeResolver (typically a closure that calls provider.can(...)) and returns a copy with denied layouts stripped:
final visible = layouts.filterByPermission(provider.can);The resolver is invoked once per layout per filter pass; the workspace shell caches the result through EntityPermissionCache so detail tabs are not re-evaluated on every rebuild.
Actions
EntityAction<T> (and its specialisations) carries both isVisible (state predicate) and authorize (Authorize expression). checkAvailability runs the visibility check first, then evaluates the expression — both must pass for the action to render. The evaluation goes through vyuh.entity?.authorizationProvider.can(...).
Editors and workflows
SignatureDrivenEditor resolves a LifecycleRequirements snapshot during init (verification, remarks, approvals). The verification gate then runs through the SignatureVerificationService, which is registered only when a signatureProvider is supplied to the plugin.
Routes
EntityRoutePermissions is the legacy slot for guarding standard routes (list, create, view, edit, dashboard) with permission strings. It remains a List<String>? per route and is checked by the route builder before the page mounts. New code should prefer Authorize expressions on layouts and actions for everything below the route level.
Wrap the app shell in AuthorizationGate so routed UI waits for the first authorization refresh. The gate passes anonymous routes through, shows the app-provided loader while AuthorizationProvider.isReady is false, and can overlay AuthorizationRefreshingIndicator during background refreshes.
Permission cache
EntityPermissionCache is a static cache keyed by entity identifier. It stores three kinds of result:
- Detail tab identifiers (
getDetailTabs/setDetailTabs) - Action identifiers (
getActions/setActions) - Route name → boolean (
getRoutePermission/setRoutePermission)
EntityConfiguration.clearAllPermissionCaches() (or EntityPermissionCache.clearAll()) must be called on logout so a fresh sign-in does not see the previous user's allowed surfaces. Per-entity-type clearing is also available via clearEntityType(identifier).
Implementing a provider
A production implementation usually wraps a backend that returns the actor's permission set:
final class CompanyAuthorizationProvider extends AuthorizationProvider {
Set<String> _permissions = const {};
Set<String> _roles = const {};
Set<String> _userGroups = const {};
String? _currentUserId;
bool _isReady = false;
@override Set<String> get permissions => _permissions;
@override Set<String> get roles => _roles;
@override Set<String> get userGroups => _userGroups;
@override String? get currentUserId => _currentUserId;
@override bool get isReady => _isReady;
@override bool get isRefreshing => /* tracked via Completer */;
@override
bool hasPermission(String perm) =>
_permissions.contains(perm) || _permissions.contains('*');
@override
bool hasRole(String code) => _roles.contains(code);
@override
bool isMemberOf(String userGroupId) => _userGroups.contains(userGroupId);
@override
Future<void> refresh() async {
final snapshot = await _api.fetchAuthorizationSnapshot();
_permissions = snapshot.permissions;
_roles = snapshot.roles;
_userGroups = snapshot.userGroups;
_currentUserId = snapshot.userId;
_isReady = true;
/* emit on changes stream */
}
}Concrete providers do not normally override can — the base class handles expression evaluation and denial dispatch.
Next steps
- Authorize API — the full DSL and provider surface.
- Configuration — where
EntityRoutePermissionsand action authorization slot in. - Layouts — how layouts use
Authorize.