Skip to content

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 (in vyuh_entity_annotations) is the closed expression language. It carries no runtime state.
  • AuthorizationProvider (in vyuh_entity_system) holds the cached state and evaluates expressions through bool can(Authorize, {subject, ownership}).
  • Backend and regulated runtime adapters should use the entity blueprint, vyuh_iam, and vyuh_policy_engine contracts directly. They do not replace the Flutter Authorize gates 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:

GroupAtomsMeaning
AtomsAuthorize.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 sugaranyPermission([...]) / allPermissions([...])Lifts to anyOf / allOf over permission atoms.
anyRole([...]) / allRoles([...])Same for roles.
CompositionAuthorize.allOf([a, b])Logical AND.
Authorize.anyOf([a, b])Logical OR.
Authorize.not(a)Logical NOT.
SingletonsAuthorize.allowAlways true (the implicit value when no authorize: is provided).
Authorize.denyAlways 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:

dart
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 (a Future), changes (a broadcast Stream<void>).
  • Refresh: Future<void> refresh() and bindRefreshStream(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:

dart
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:

dart
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:

dart
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 EntityRoutePermissions and action authorization slot in.
  • Layouts — how layouts use Authorize.