Skip to content

Permissions Domain (permissions_v1)

The Permissions domain (permissions_v1) is a bounded context responsible for managing fine-grained access control and authorization across the CO2 target asset management system. It implements role-based access control (RBAC) with hierarchical permissions spanning multiple resource types—from estates down to individual features and catalogues. The domain enforces business rules around permission grants, role hierarchies, and multi-admin limitations.

The Permissions domain implements a comprehensive authorization model where:

  1. Users receive permissions to access Resources (estates, sites, layers, features, catalogues)
  2. Roles define what actions a user can perform on each resource type
  3. Grants are immutable once created; modifications require revocation + re-granting
  4. Business rules enforce constraints like maximum estate admin roles per user and preventing duplicate permissions
  5. Audit trails capture who granted/revoked permissions and when through domain events

This is not a session or authentication domain—it focuses purely on authorization: determining what an authenticated user is allowed to do.

Permissions are granted on five resource types, forming a hierarchy:

Resource TypePurposeTypical Hierarchy
estateA workspace/organizationTop-level container
siteLocation within an estateOwned by one estate
layerFloor plan or map layerOwned by one site
featureIndividual element on a layerOwned by one layer
catalogueAsset type catalogCross-cutting, estate-scoped

Each resource type has a specific set of roles, organized hierarchically. Roles at higher levels grant greater permission.

// Hierarchy: read < write < admin < owner
Role.estateRead // Read-only access to estate
Role.estateWrite // Can create and modify assets
Role.estateAdmin // Can grant/revoke permissions, manage users
Role.estateOwner // Full control (rarely used)
// Hierarchy: read < write < admin
Role.siteRead // Read-only access to site
Role.siteWrite // Can create and modify assets within site
Role.siteAdmin // Can grant/revoke site permissions
// Hierarchy: none < read < write < admin
Role.layerNone // No access
Role.layerRead // Can view layer/feature
Role.layerWrite // Can modify layer/feature
Role.layerAdmin // Can grant layer/feature permissions
Role.featureNone
Role.featureRead
Role.featureWrite
Role.featureAdmin
// Hierarchy: read < write < admin
Role.catalogueRead // Can browse catalogue
Role.catalogueWrite // Can add/modify catalogue items
Role.catalogueAdmin // Can manage catalogue access

A permission has a lifecycle with three possible states:

class PermissionStatus {
static const active = PermissionStatus._('active'); // Currently effective
static const revoked = PermissionStatus._('revoked'); // Explicitly disabled
static const suspended = PermissionStatus._('suspended'); // Temporarily disabled
}

The Role class provides methods for comparing roles within the same type:

// Role comparison
role.hasMinimumPermission(minimumRole) // true if role >= minimumRole
// Example:
if (userRole.hasMinimumPermission(Role.siteAdmin)) {
// User can perform admin actions
}
// Permission granting
role.canGrant(targetRole) // true if role can grant targetRole to others

The root aggregate managing all permissions for a specific user.

Identity:

  • AggregateId: System-generated ID (format: perm-{userId})
  • userId: UserId: The user whose permissions are tracked

Core Attributes:

class UserPermissionAggregate extends Aggregate<UserPermissionAggregate> {
final UserId userId; // User identity
final Map<String, UserPermission> _permissions; // permissionId -> permission
final DateTime createdAt; // Aggregate creation time
final DateTime updatedAt; // Last modification time
final DateTime? lastViewedAt; // Optional access timestamp
}

UserPermission Value Object:

Each permission is a value object containing:

class UserPermission {
final PermissionId permissionId; // Unique permission identifier
final UserId userId; // User receiving the permission
final ResourceType resourceType; // Type of resource (estate, site, etc.)
final ResourceId resourceId; // ID of the specific resource
final Role role; // Role for this resource
final UserId grantedBy; // Admin who granted it
final DateTime grantedAt; // Grant timestamp
final PermissionStatus status; // active, revoked, or suspended
// Revocation tracking
final UserId? revokedBy; // Admin who revoked it
final DateTime? revokedAt; // Revocation timestamp
final PermissionReasonDescription? revocationReason;
// Last modification
final UserId? lastModifiedBy; // Admin who last changed role
final DateTime? lastModifiedAt; // Modification timestamp
// Metadata for UI display
final DateTime? lastViewed; // When user last accessed resource
final int? assetCount; // Number of assets (for estates)
final DomainText? displayName; // Display name (e.g., estate name)
}

Key Methods:

// Query methods
List<UserPermission> get activePermissions
bool hasPermissionFor(ResourceType type, ResourceId id, [Role? minimumRole])
UserPermission? getPermissionFor(ResourceType type, ResourceId id)
int countActivePermissions(ResourceType type)
// Specialized accessors (convenience methods)
bool hasEstateAccess(EstateId id, [Role? minimumRole])
bool hasSiteAccess(EstateId estateId, SiteId siteId, [Role? minimumRole])
bool hasLayerAccess(SiteId siteId, LayerId layerId, [Role? minimumRole])
bool hasFeatureAccess(SiteId siteId, ResourceId featureId, [Role? minimumRole])
bool hasCatalogueAccess(CatalogueId catalogueId, [Role? minimumRole])
// Access mapping
Map<SiteId, Role> siteAccessMap(EstateId estateId)
// Validation
bool canGrantPermission(ResourceType type, ResourceId id, Role role)

The aggregate enforces critical invariants via validate():

  1. User ID Required: userId cannot be empty or unresolved
  2. Estate Admin Limit: User cannot have more than 3 active estate admin roles
  3. No Duplicate Permissions: User cannot have multiple active permissions for the same resource
  4. DisplayName Not Enforced at Runtime: While required for estate permissions in production, the aggregate doesn’t throw to avoid breaking legacy permissions lacking displayName

These rules are checked when applying events and before persisting aggregates.

The permissions domain emits four event types to capture all state changes:

Fired when a user is granted access to a resource.

class PermissionGrantedEvent implements Event {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role role;
final UserId grantedBy; // Admin who performed the grant
final DateTime grantedAt;
final DomainText? displayName; // Optional display name (e.g., estate name)
}

Generated by: GrantPermissionDirective Example:

PermissionGrantedEvent(
permissionId: PermissionId('perm-user-123-estate-e1'),
userId: UserId('user-123'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-e1', ResourceType.estate),
role: Role.estateAdmin,
grantedBy: UserId('admin-456'),
grantedAt: DateTime.now(),
displayName: DomainText('Production Estate'),
)

Fired when a user’s permission is revoked.

class PermissionRevokedEvent implements Event {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role previousRole; // The role that was revoked
final UserId revokedBy; // Admin who performed the revocation
final PermissionReasonDescription? reason;
final DateTime revokedAt;
}

Generated by: RevokePermissionDirective

Fired when a user’s role for a resource is modified.

class PermissionRoleChangedEvent implements Event {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role previousRole;
final Role newRole;
final UserId changedBy; // Admin who made the change
final PermissionReasonDescription? reason;
final DateTime changedAt;
}

Generated by: ChangePermissionRoleDirective

Fired when metadata (lastViewed, assetCount, displayName) is updated. Used for UI-driven updates that don’t affect core permissions.

class PermissionMetadataUpdatedEvent implements Event {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final DateTime? lastViewed; // When user viewed this resource
final int? assetCount; // Number of assets in resource
final DomainText? displayName; // Updated display name
final DateTime updatedAt;
}

Generated by: UpdatePermissionMetadataDirective

Directives are command handlers that validate business rules and emit events. All directives target the UserPermissionAggregate.

Grants a new permission to a user.

Input Payload:

class GrantPermissionPayload {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role role;
final UserId grantedBy; // Which admin is granting
final DomainText? displayName; // Optional (e.g., estate name)
}

Business Rules:

  • Cannot grant duplicate: user must not already have an active permission for this resource
  • If a permission with same role exists (idempotent), no event is emitted
  • If a permission with different role exists, throws error (use ChangePermissionRole instead)
  • PermissionId is generated deterministically: perm-{userId}-{resourceType}-{resourceId}

Example:

final directive = GrantPermissionDirective(
payload: GrantPermissionPayload(
permissionId: PermissionId('perm-u1-estate-e1'),
userId: UserId('user-1'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-1', ResourceType.estate),
role: Role.estateAdmin,
grantedBy: UserId('admin-user'),
displayName: DomainText('My Estate'),
),
);
final result = await engine.execute<UserPermissionAggregate, GrantPermissionPayload>(
directive: directive,
timelineId: timelineId,
workspaceId: workspace,
);
// result.updatedAggregate now has the new permission
// result.events contains PermissionGrantedEvent

Revokes an existing permission.

Input Payload:

class RevokePermissionPayload {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role previousRole; // Expected previous role (for validation)
final UserId revokedBy; // Which admin is revoking
final PermissionReasonDescription? reason;
}

Business Rules:

  • Permission must exist and be active
  • Cannot revoke already-revoked or suspended permissions
  • Looks up the actual permission by resource (permissionId may be stale)

Example:

final directive = RevokePermissionDirective(
payload: RevokePermissionPayload(
permissionId: PermissionId('perm-u1-estate-e1'),
userId: UserId('user-1'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-1', ResourceType.estate),
previousRole: Role.estateAdmin,
revokedBy: UserId('admin-user'),
reason: PermissionReasonDescription('User left project'),
),
);

Changes a user’s role for an existing resource (e.g., estate admin → estate read).

Input Payload:

class ChangePermissionRolePayload {
final PermissionId permissionId;
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final Role previousRole;
final Role newRole;
final UserId changedBy; // Which admin is making the change
final PermissionReasonDescription? reason;
}

Business Rules:

  • Permission must exist
  • Role must be different from current
  • ChangedBy user must have permission to grant roles (via canGrant)

Example:

final directive = ChangePermissionRoleDirective(
payload: ChangePermissionRolePayload(
permissionId: PermissionId('perm-u1-estate-e1'),
userId: UserId('user-1'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-1', ResourceType.estate),
previousRole: Role.estateAdmin,
newRole: Role.estateRead,
changedBy: UserId('admin-user'),
),
);

Updates metadata without modifying the core permission.

Input Payload:

class UpdatePermissionMetadataPayload {
final UserId userId;
final ResourceType resourceType;
final ResourceId resourceId;
final DateTime? lastViewed; // When user last accessed resource
final int? assetCount; // Number of assets
final DomainText? displayName; // Display name update
}

Business Rules:

  • If no permission exists for this resource, no event is emitted (no-op)
  • Only updates existing permissions; doesn’t create new ones
  • Used for UI-driven metadata updates (e.g., marking estate as viewed)

Example:

final directive = UpdatePermissionMetadataDirective(
payload: UpdatePermissionMetadataPayload(
userId: UserId('user-1'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-1', ResourceType.estate),
lastViewed: DateTime.now(),
assetCount: 42,
),
);

The aggregate provides specialized accessor methods for checking permissions by resource:

final permissionAgg = userPermissions;
// Estate access
if (permissionAgg.hasEstateAccess(estateId, Role.estateAdmin)) {
// User can administer this estate
}
// Site access
if (permissionAgg.hasSiteAccess(estateId, siteId, Role.siteWrite)) {
// User can write to this site
}
// Layer access
if (permissionAgg.hasLayerAccess(siteId, layerId)) {
// User has some level of access to this layer
}
// Feature access
if (permissionAgg.hasFeatureAccess(siteId, featureId, Role.featureAdmin)) {
// User can administer this feature
}
// Catalogue access
if (permissionAgg.hasCatalogueAccess(catalogueId)) {
// User can access this catalogue
}
// Get all sites user has access to
final siteRoles = permissionAgg.siteAccessMap(estateId);
siteRoles.forEach((siteId, role) {
print('$siteId: $role');
});
final perm = permissionAgg.getPermissionFor(ResourceType.estate, resourceId);
if (perm != null && perm.isActive) {
print('Access granted as: ${perm.role}');
}
// Count active permissions by type
final siteCount = permissionAgg.countActivePermissions(ResourceType.site);
print('User has access to $siteCount sites');
// Get all active permissions
final active = permissionAgg.activePermissions;

Before granting new permissions, check if it’s allowed:

if (permissionAgg.canGrantPermission(ResourceType.estate, resourceId, Role.estateAdmin)) {
// Safe to grant; user hasn't hit admin limit
} else {
throw StateError('Cannot grant: maximum estate admins reached or duplicate permission');
}

The UserPermissionAggregate uses a deterministic, prefixed ID to avoid collisions with other aggregate types:

// ID format: perm-{userId}
final aggregateId = UserPermissionAggregateId.forUser(UserId('user-123')).value;
// Result: "perm-user-123"
// Permissions are stored by permissionId within the aggregate
final permissionId = 'perm-user-123-estate-e1';

This ensures:

  1. One aggregate per user (all their permissions in one place)
  2. No collision with UserAggregate IDs
  3. Deterministic lookup by user

The role hierarchy defines permission inheritance. A user with a higher role implicitly has permissions of all lower roles:

// If user has estateAdmin, they can also act as estateWrite and estateRead
if (role.hasMinimumPermission(Role.estateRead)) {
// Can read
}
if (role.hasMinimumPermission(Role.estateWrite)) {
// Can write (implies read)
}
if (role.hasMinimumPermission(Role.estateAdmin)) {
// Can admin (implies write and read)
}

Only users with admin or owner roles can grant permissions. They can only grant roles at or below their own level:

// Can this user grant a role?
if (myRole.canGrant(targetRole)) {
// Safe to grant
}
// Example:
final adminRole = Role.estateAdmin;
final writeRole = Role.estateWrite;
final readRole = Role.estateRead;
// Admin can grant write and read but not owner
print(adminRole.canGrant(writeRole)); // true
print(adminRole.canGrant(readRole)); // true
print(adminRole.canGrant(Role.estateOwner)); // false
import 'package:permissions_v1/permissions_v1.dart';
import 'package:contracts_v1/contracts_v1.dart';
import 'package:nomos_core/nomos_core.dart';
// Initialize the domain
registerPermissionsV1();
// Create and execute a grant directive
final directive = GrantPermissionDirective(
payload: GrantPermissionPayload(
permissionId: PermissionId('perm-alice-estate-prod'),
userId: UserId('alice-123'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-prod', ResourceType.estate),
role: Role.estateAdmin,
grantedBy: UserId('admin-system'),
displayName: DomainText('Production Estate'),
),
);
final result = await engine.execute<UserPermissionAggregate, GrantPermissionPayload>(
directive: directive,
timelineId: const NomosTimelineId('default'),
workspaceId: EstateWorkspace(EstateId('ws-prod')).toNomos,
);
// Check result
final updatedAgg = result.updatedAggregate;
assert(updatedAgg.hasEstateAccess(EstateId('estate-prod'), Role.estateAdmin));
// Events generated
for (final event in result.events) {
if (event is PermissionGrantedEvent) {
print('Permission granted: ${event.permissionId}');
}
}
// First, get current permission
final currentPerm = userPermissions.getPermissionFor(
ResourceType.estate,
ResourceId('estate-prod', ResourceType.estate),
);
// Change their role from write to admin
final directive = ChangePermissionRoleDirective(
payload: ChangePermissionRolePayload(
permissionId: currentPerm!.permissionId,
userId: currentPerm.userId,
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-prod', ResourceType.estate),
previousRole: Role.estateWrite,
newRole: Role.estateAdmin,
changedBy: UserId('admin-system'),
reason: PermissionReasonDescription('Promotion to admin'),
),
);
final result = await engine.execute<UserPermissionAggregate, ChangePermissionRolePayload>(
directive: directive,
timelineId: timelineId,
workspaceId: workspace,
);
// Updated aggregate now reflects new role
final updatedPerm = result.updatedAggregate.getPermissionFor(
ResourceType.estate,
ResourceId('estate-prod', ResourceType.estate),
);
assert(updatedPerm!.role == Role.estateAdmin);
final directive = RevokePermissionDirective(
payload: RevokePermissionPayload(
permissionId: PermissionId('perm-bob-estate-old'),
userId: UserId('bob-456'),
resourceType: ResourceType.estate,
resourceId: ResourceId('estate-old', ResourceType.estate),
previousRole: Role.estateRead,
revokedBy: UserId('admin-system'),
reason: PermissionReasonDescription('User transferred to different team'),
),
);
final result = await engine.execute<UserPermissionAggregate, RevokePermissionPayload>(
directive: directive,
timelineId: timelineId,
workspaceId: workspace,
);
// Check result
final revokedPerm = result.updatedAggregate.getPermissionFor(
ResourceType.estate,
ResourceId('estate-old', ResourceType.estate),
);
assert(revokedPerm == null); // No longer in active permissions
// Audit trail: check event for who, when, why
for (final event in result.events) {
if (event is PermissionRevokedEvent) {
print('Revoked by: ${event.revokedBy}');
print('Reason: ${event.reason}');
print('At: ${event.revokedAt}');
}
}
final agg = userPermissions;
// Check cascading permissions
bool canEditSite(EstateId estateId, SiteId siteId) {
// Need at least site write on the site
return agg.hasSiteAccess(estateId, siteId, Role.siteWrite);
}
bool canViewAllSitesInEstate(EstateId estateId) {
// Need at least estate read
return agg.hasEstateAccess(estateId, Role.estateRead);
}
// Get subset of accessible sites
final accessibleSites = agg.siteAccessMap(estateId)
.entries
.where((e) => e.value.hasMinimumPermission(Role.siteAdmin))
.map((e) => e.key)
.toList();

The aggregate and its contained permissions serialize to JSON for persistence:

final json = aggregate.toJson();
// {
// "id": "perm-user-123",
// "userId": "user-123",
// "permissions": {
// "perm-user-123-estate-e1": {
// "permissionId": "perm-user-123-estate-e1",
// "userId": "user-123",
// "resourceType": "estate",
// "resourceId": "estate-e1",
// "role": { "value": "admin", "type": "estate" },
// "grantedBy": "admin-456",
// "grantedAt": "2024-01-15T10:30:00Z",
// "status": "active",
// "displayName": "Production Estate"
// }
// },
// "createdAt": "2024-01-01T00:00:00Z",
// "updatedAt": "2024-01-15T10:30:00Z"
// }
// Deserialize
final restored = UserPermissionAggregate.fromJson(json, AggregateId('perm-user-123'));

The aggregate includes robust deserialization that handles:

  • Missing or malformed permission entries (marked as revoked, skipped)
  • Flexible date parsing (ISO strings, timestamps, epoch seconds/milliseconds)
  • Default fallbacks for missing role data

This allows graceful migration of legacy permissions without breaking.

Permissioning types must be registered with Nomos before use:

import 'package:permissions_v1/permissions_v1.dart';
void main() {
// Register all permissions domain types
registerPermissionsV1();
// Or use the DomainModule pattern
final module = PermissionsV1();
module.registerDomainTypes();
}

This registers:

  • Aggregate factories and type information
  • All four event types and their JSON deserializers
  • All four directive types and payload deserializers

The domain includes comprehensive tests covering:

Unit Tests (permission_aggregate_test.dart):

  • Business rule enforcement (estate admin limit, duplicate prevention)
  • Permission queries and selectors
  • Role hierarchy and granting logic

Integration Tests (permissions_v1_test.dart):

  • Grant → Revoke workflows
  • Role changes
  • Metadata updates
  • Multi-user scenarios

Example Test:

test('Business Rules: Estate Admin Limit', () {
final userId = UserId('USR-1');
var aggregate = UserPermissionAggregate(
userId: userId,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
// Grant 3 estate admin roles (allowed)
for (var i = 1; i <= 3; i++) {
aggregate = aggregate.apply(PermissionGrantedEvent(
permissionId: PermissionId('P-$i'),
userId: userId,
resourceType: ResourceType.estate,
resourceId: ResourceId('E-$i', ResourceType.estate),
role: Role.estateAdmin,
grantedBy: UserId('ADMIN'),
grantedAt: DateTime.now(),
));
}
expect(() => aggregate.validate(), returnsNormally);
// Grant 4th admin role (not allowed)
aggregate = aggregate.apply(PermissionGrantedEvent(
permissionId: PermissionId('P-4'),
userId: userId,
resourceType: ResourceType.estate,
resourceId: ResourceId('E-4', ResourceType.estate),
role: Role.estateAdmin,
grantedBy: UserId('ADMIN'),
grantedAt: DateTime.now(),
));
expect(() => aggregate.validate(), throwsStateError);
});

The permissions domain depends on:

  • nomos_core: Event sourcing framework (aggregates, events, directives)
  • contracts_v1: Shared value objects (UserId, Role, ResourceType, PermissionId, etc.)

It has no dependencies on other domains, enabling it to be used across the system without circular dependency risk.

dart_packages/co2/domains/permissions_v1/
├── lib/
│ ├── permissions_v1.dart # Main export file
│ ├── src/
│ │ ├── aggregates/
│ │ │ └── user_permission_aggregate.dart # Core aggregate and value objects
│ │ ├── events/
│ │ │ └── permission_events.dart # Four event types
│ │ └── directives/
│ │ └── permission_directives.dart # Four directive types
│ └── [exports]
├── test/
│ ├── permissions_v1_test.dart # Integration tests
│ ├── permission_aggregate_test.dart # Unit tests
│ └── coverage_booster_test.dart
├── pubspec.yaml
└── README.md
ConceptPurposeExample
UserPermissionAggregateRoot aggregate tracking one user’s permissionsAggregate for alice-123 contains 5 permissions
UserPermissionIndividual permission value objectPermission to admin estate-prod
PermissionIdDeterministic ID for a permissionperm-alice-123-estate-prod
RolePermission level for a resource typeRole.estateAdmin
ResourceTypeType of resource being controlledResourceType.estate
EventImmutable record of state changePermissionGrantedEvent, PermissionRevokedEvent
DirectiveCommand handler enforcing business rulesGrantPermissionDirective
PermissionStatusCurrent state of a permissionactive, revoked, suspended

To grant multiple permissions to a user across resources:

final userId = UserId('new-user-123');
final grantedBy = UserId('admin');
final estateId = EstateId('estate-1');
final siteIds = [SiteId('site-1'), SiteId('site-2')];
// Grant estate read
await engine.execute<UserPermissionAggregate, GrantPermissionPayload>(
directive: GrantPermissionDirective(
payload: GrantPermissionPayload(
userId: userId,
resourceType: ResourceType.estate,
resourceId: ResourceId(estateId.value, ResourceType.estate),
role: Role.estateRead,
grantedBy: grantedBy,
),
),
...
);
// Grant site write for multiple sites
for (final siteId in siteIds) {
await engine.execute<UserPermissionAggregate, GrantPermissionPayload>(
directive: GrantPermissionDirective(
payload: GrantPermissionPayload(
userId: userId,
resourceType: ResourceType.site,
resourceId: ResourceId(siteId.value, ResourceType.site),
role: Role.siteWrite,
grantedBy: grantedBy,
),
),
...
);
}

Trace who granted/revoked each permission:

final agg = userPermissions;
for (final perm in agg.activePermissions) {
print('${perm.resourceType}:${perm.resourceId} - ${perm.role}');
print(' Granted by: ${perm.grantedBy} at ${perm.grantedAt}');
if (perm.lastModifiedBy != null) {
print(' Last modified by: ${perm.lastModifiedBy} at ${perm.lastModifiedAt}');
}
}

Safely downgrade a user’s role while recording the reason:

final perm = agg.getPermissionFor(resourceType, resourceId);
if (perm != null && perm.role == Role.estateAdmin) {
await engine.execute<UserPermissionAggregate, ChangePermissionRolePayload>(
directive: ChangePermissionRoleDirective(
payload: ChangePermissionRolePayload(
permissionId: perm.permissionId,
userId: perm.userId,
resourceType: resourceType,
resourceId: resourceId,
previousRole: Role.estateAdmin,
newRole: Role.estateRead,
changedBy: UserId('compliance-audit'),
reason: PermissionReasonDescription('Quarterly audit: privilege reduction'),
),
),
...
);
}