access_gate

Hide Flutter widgets behind feature flags, roles, permissions, and attribute-based access policies.

access_gate is intentionally provider-agnostic. Bring your own source of truth, such as Firebase Remote Config, LaunchDarkly, Supabase claims, local app state, or a backend policy response, then expose those facts through AccessContext.

Features

  • Feature flag gates with enabled flags and exact feature values.
  • RBAC gates with required roles or any-of role checks.
  • Permission gates for fine-grained access checks.
  • ABAC gates with exact attributes and custom predicates.
  • Policy composition with all-of, any-of, and not rules.
  • Context composition for combining auth, flag, account, and local facts.
  • Convenience constructors for any-of and exact value checks.
  • Optional policy labels for structured denial diagnostics.
  • Structured denial reasons for custom fallback UI.
  • AccessGuard for page-level access decisions.
  • JSON helpers for access contexts and serializable policies.
  • AccessScope and AccessController for inherited access state.
  • AccessHidden, a zero-size fallback for denied gates.

Getting started

Add the package, then create an AccessController from your app's current feature flag and authorization state.

final accessController = AccessController(
  AccessContext(
    enabledFeatures: {'advanced_reports'},
    roles: {'admin'},
    permissions: {'reports.view'},
    attributes: {'plan': 'pro', 'region': 'us'},
  ),
);

Place AccessScope above the widgets that should use the shared access state.

AccessScope(
  controller: accessController,
  child: const MyApp(),
);

Create the controller once in an application-owned lifecycle, such as a State object or dependency container. AccessScope listens to the controller but does not dispose it; the owner should call dispose() when it is no longer needed.

When access facts come from multiple places, combine them into one context:

final context = AccessContext.combine([
  AccessContext(enabledFeatures: {'advanced_reports'}),
  AccessContext(permissions: {'reports.view'}),
  AccessContext(attributes: {'plan': 'pro'}),
]);

accessController.update(context);

Basic usage

Require one feature flag:

AccessGate.feature(
  feature: 'advanced_reports',
  child: const AdvancedReportsButton(),
);

Require a role:

AccessGate.role(
  role: 'admin',
  fallback: const Text('Admin access required'),
  child: const AdminPanel(),
);

Require a permission:

AccessGate.permission(
  permission: 'reports.view',
  child: const ReportList(),
);

Require one of several roles:

AccessGate.anyRole(
  roles: {'admin', 'analyst'},
  child: const AnalystDashboard(),
);

Require an exact feature value or attribute:

AccessGate.featureValue(
  feature: 'checkout_variant',
  value: 'variant_b',
  child: const VariantCheckout(),
);

AccessGate.attribute(
  attribute: 'plan',
  value: 'pro',
  child: const ProReports(),
);

Combine feature flags, roles, permissions, and ABAC attributes:

AccessGate.when(
  allFeatures: {'advanced_reports'},
  anyRoles: {'admin', 'analyst'},
  allPermissions: {'reports.view'},
  attributes: {'plan': 'pro'},
  child: const AdvancedReportsPage(),
);

Use a custom predicate when exact attribute matching is not enough:

AccessGate.when(
  predicate: (context) {
    return context.attribute('teamId') == selectedTeamId &&
        context.attribute('plan') == 'enterprise';
  },
  predicateReason: 'Requires access to this team.',
  child: const TeamSettings(),
);

Policy composition

Compose policies when access can be granted through multiple paths, or when an allow rule needs an explicit exclusion.

final policy = AccessPolicy.allOf(
  [
    AccessPolicy.anyOf([
      AccessPolicy.role('admin', label: 'Admin role'),
      AccessPolicy(
        allPermissions: {'reports.view'},
        attributes: {'plan': 'pro'},
        label: 'Pro reports permission',
      ),
    ]),
    AccessPolicy.not(
      AccessPolicy.role('suspended'),
      reason: 'Suspended users cannot access reports.',
      label: 'Suspension exclusion',
    ),
  ],
  label: 'Advanced reports policy',
);

AccessGate(
  policy: policy,
  child: const AdvancedReportsPage(),
);

Page guards

Use AccessGuard when the whole page or route body should branch on an access decision.

AccessGuard(
  policy: AccessPolicy.permission('reports.view'),
  builder: (context, decision) {
    return const ReportsPage();
  },
  deniedBuilder: (context, decision) {
    return Text(decision.reasons.first);
  },
);

Typed keys

The core API stores provider-facing strings, but apps can define typed keys with enums by implementing the category marker interfaces.

enum AppFeature implements AccessFeature {
  advancedReports('advanced_reports');

  const AppFeature(this.accessKey);

  @override
  final String accessKey;
}

enum AppRole implements AccessRole {
  admin('admin');

  const AppRole(this.accessKey);

  @override
  final String accessKey;
}

Use fromKeys and *Key constructors when you want compile-time key names in app code:

final context = AccessContext.fromKeys(
  enabledFeatures: {AppFeature.advancedReports},
  roles: {AppRole.admin},
);

AccessGate.featureKey(
  feature: AppFeature.advancedReports,
  child: const AdvancedReportsButton(),
);

If an enum's case name already matches the provider key, the AccessEnumKey extension exposes myEnumValue.accessKey as a convenience.

Denied access

By default, denied gates render accessHidden, a zero-size widget that is safe inside layout widgets like Column, Row, and Stack. This is useful when a widget should simply disappear.

AccessGate.feature(
  feature: 'new_checkout',
  child: const CheckoutButton(),
);

Use fallback or fallbackBuilder when denied users should see something.

AccessGate.permission(
  permission: 'billing.manage',
  fallbackBuilder: (context, decision) {
    final reason = decision.denialReasons.first;
    return Text('${reason.key}: ${reason.message}');
  },
  child: const BillingSettings(),
);

decision.reasons remains available as a simple list of messages.

Use AccessBuilder when denied UI should remain visible but disabled:

AccessBuilder(
  policy: AccessPolicy.permission(
    'billing.manage',
    label: 'Billing permission',
  ),
  builder: (context, decision) {
    return FilledButton(
      onPressed: decision.allowed ? openBilling : null,
      child: Text(
        decision.allowed ? 'Manage billing' : decision.reasons.first,
      ),
    );
  },
);

JSON helpers

AccessContext can round-trip through JSON-compatible maps. Policies without custom predicates can also be serialized, including composed policies.

final context = AccessContext.fromJson(savedContextJson);
final policy = AccessPolicy.fromJson(savedPolicyJson);

final contextJson = context.toJson();
final policyJson = policy.toJson();

Policy labels are included in JSON when present. Custom predicate functions are runtime-only and cannot be serialized.

Loading and bootstrap

An empty context denies protected UI by design. During auth, claims, remote config, or backend bootstrap, show your app's loading shell until the first real AccessContext is ready, then mount gated UI or call accessController.update(context).

Agent-friendly usage

Coding agents integrating this package into Flutter apps should read doc/using-access-gate.md.

The shared Agent Skills-compatible skill lives at skills/access-gate/. Codex can use it from that location. Claude Code users can copy that folder to ~/.claude/skills/access-gate/, or use the project wrapper at .claude/skills/access-gate/ when working from this repository.

Repository-maintenance guidance is available in AGENTS.md for Codex and CLAUDE.md for Claude Code.

Important security note

access_gate controls client-side visibility. It is not a replacement for server-side authorization, database security rules, API checks, or audit controls. Use it to keep Flutter UI honest and ergonomic; enforce real access at your data and service boundaries too.

Libraries

access_gate
Widget-level access control for Flutter.