Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.
Table of Contents
stylenone

Link management between Capabilities and Roles

...

Overview

This page describes algorithms that assign capabilities to a user and role, capability sets to a user, and role.

Capabilities and capability sets are assigned individually

Example:

There is a capability: foo.item.view [endpoints: GET /foo/item/{id}], foo.item.create [endpoints: POST /foo/item], foo.item.update [endpoints: PUT /foo/item/{id}] and capability set foo.item.manage uniting foo.item.view, foo.item.create and foo.item.update.

Usage Scenarios

Scenario #1 (role-capability and role-capabilitySet management)

  1. User assigned capability set foo.item.manage to a role Foo management role (sampleRoleId). The following resources will be created:

  • Entities in mod-roles-keycloak database:

    • policy: [policyId, 'Policy for role: sampleRoleId']

    • roleCapabilitySet: [policyId, fooItemManageCapabilitySetId]

  • Entities in Keycloak:

    • Policy: [policyId, 'Policy for role: sampleRoleId']

    • Permission: [name: 'GET access for role: sampleRoleId to /foo/item/{id}', scope: 'GET', resource: '/foo/item/{id}', policy: policyId]

    • Permission: [name: 'POST access for role: sampleRoleId to /foo/item', scope: 'POST', resource: '/foo/item', policy: policyId]

    • Permission: [PUT access for role: sampleRoleId to /foo/item/{id}, scope: 'GET', resource: '/foo/item/{id}', policy: policyId]

  1. Then a user assigns explicitly foo.item.view capability to a role with a sampleRoleId. The following resources will be created:

  • Entities in mod-roles-keycloak database:

    • roleCapability: [policyId, fooItemViewCapabilityId]

Info

Note that keycloak entities are not affected, because permission: Permission: [name: 'GET access for role: sampleRoleId to /foo/item/{id}', scope: 'GET', resource: '/foo/item/{id}', policy: policyId] already exists

  1. Then a user removes assignment foo.item.manage from a role with a sampleRoleId. The following resources will be deleted:

  • Entities in mod-roles-keycloak database:

    • roleCapabilitySet: [policyId, fooItemManageCapabilitySetId]

  • Entities in Keycloak:

    • Permission: [name: 'POST access for role: sampleRoleId to /foo/item', scope: 'POST', resource: '/foo/item', policy: policyId]

    • Permission: [PUT access for role: sampleRoleId to /foo/item/{id}, scope: 'GET', resource: '/foo/item/{id}', policy: policyId]

Info

Not that Permission: [name: 'GET access for role: sampleRoleId to /foo/item/{id}', scope: 'GET', resource: '/foo/item/{id}', policy: policyId] is not deleted, because it was explicitly assigned to a role via relation roleCapability: [policyId, fooItemViewCapabilityId]

Info

The same process works for user-capability, user-capabilitySet link management

Capability Relation Management

Role

Capability assignment process

safeCreate flag is used only internally, for outside requests a user must always assign a set of new capability ids, if not - an update operation allows the creation and delete capabilities in a single request

role-capability-assigment-20241014-152818.pngImage Added

Expand
titleRole-Capability assigment assignment PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group RoleCapabilityServiceImpl {
  :get role by id;<<input>>
  :get ""roleCapabilities"" by ""roleId"";<<task>>
  :extract existing ""capabilityIds"" from ""roleCapabilities"";<<task>>
  if (not ""safeCreate"" AND ""capabilityIds"" not empty)) then (yes)
    :throw EntityExistsException;<<output>>
    end
  else (no)
    :storeget difference between new and existing ""capabilityIds"" sets as ""newCapabilityIds"";<<input>>
  end if

  group assignCapabilities {
    :storeget assigned capabilities through capability sets as ""assignedCapabilityIds"";<<input>>

    :store union of ""assignedCapabilityIds"" and ""assignedIdsnewCapabilityIds"" as ""assignedCapabilityIds"";<<task>><<input>>

    group #LightSteelBlue CapabilityEndpointService.getByCapabilityIds {
      :get difference with ""newCapabilityIds"" and """assignedIdsassignedCapabilityIds"" as ""changedIdentifiers"";<<input>>

      :get changed capability endpoints by querying capabilities by ""changedIdentifiers""
      and extracting distinct list of assigned endpoints and store it as ""changedCapabilityEndpoints"";<<task>>

      :get assigned capability endpoints by querying capabilities by ""assignedIds""
      and extracting distinct list of assigned endpoints and store it as ""assignedCapabilityEndpoints"";<<task>>

      :return subtraction ""assignedCapabilityEndpoints"" from ""changedCapabilityEndpoints"";<<output>>
    }

    :rolePermissionsService.createPermissions for ""roleId"" and ""endpoints"";<<task>>

    group #LightSteelBlue CapabilityEndpointService.getByCapabilityIds {
      if (""endpoints"" are empty) then (yes)
        :do nothing;<<output>>
      else (no)
        :get role by ""roleId"";<<input>>
        :generate policy name for ""role"";<<input>>
        :getOrCreate role policy by name
        //Policy name template:// ""Policy for role: {{roleId}}"";<<task>>

        :create permissions in Keycloak with role policy, list of endpoint and using permissionNameGenerator

        //Permission name template:// ""{{httpMethod}} access for role '{{roleId}}' to '{{path}}'"";
      end if
    }

    :generate RoleCapabilityEntity for ""newIds"" and store them as ""entities"";<<task>>
    :upsert entities to table ""role_capability"" in database;<<output>>
    :convert ""RoleCapabilityEntity"" to ""RoleCapability"" and return them as ""PageResult"";<<output>>
  }
}

@enduml

Capability remove process

...

Expand
titleRole-Capability removal process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group RoleCapabilityServiceImpl {
  :get existing ""roleCapabilities"" by ""roleId"";<<task>>
  :extract existing ""capabilityIds"" from ""roleCapabilities"";<<task>>

  if (""capabilityIds"" is empty) then (yes)
    :do nothing;<<output>>
    end
  else (no)
    :get intersection between ""capabilityIds"" and existing ""capabilityIds"" sets as ""deprecatedIds"";<<input>>
    :get subtraction between ""assignedCapabilityIds"" and existing ""deprecatedIds"" sets as ""assignedIds"";<<input>>
  end if

  group removeCapabilities {
    :get assigned capabilities through capability sets as ""assignedCapabilityIds"";<<input>>
    :store union of ""assignedCapabilityIds"" and ""assignedIds"" as ""assignedCapabilityIds"";<<task>>

    group #LightSteelBlue CapabilityEndpointService.getByCapabilityIds {
      :get difference with ""deprecatedIds"" and ""assignedIds"" as ""changedIdentifiers"";<<input>>

      :get changed capability endpoints by querying capabilities by ""changedIdentifiers""
      and extracting distinct list of assigned endpoints and store it as ""changedCapabilityEndpoints"";<<task>>

      :get assigned capability endpoints by querying capabilities by ""assignedIds""
      and extracting distinct list of assigned endpoints and store it as ""assignedCapabilityEndpoints"";<<task>>

      :return subtraction ""assignedCapabilityEndpoints"" from ""changedCapabilityEndpoints"";<<output>>
    }

    :rolePermissionsService.deletePermissions for ""roleId"" and ""endpoints"";<<task>>

    group #LightSteelBlue CapabilityEndpointService.getByCapabilityIds {
      if (""endpoints"" are empty) then (yes)
        :do nothing;<<output>>
      else (no)
        :get role by ""roleId"";<<input>>
        :generate policy name for ""role"";<<input>>
        :get policy by ""name"" and type == ""ROLE"";<<task>>

        :delete permissions in Keycloak with role policy, list of endpoint and using permissionNameGenerator

        //Permission name template:// ""{{httpMethod}} access for role '{{roleId}}' to '{{path}}'"";
      end if
    }


    :delete entities from table ""role_capability"" in database by ""roleId"" and ""deprecatedIds"";<<output>>
  }
}

@enduml

Capability update process

A capability update process combines capability assignment and capability remove processes, where unmodified capabilities and corresponding keycloak permissions remain intact.

CapabilitySet assignment process

...

Expand
titleCapability Set assignment process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group RoleCapabilityServiceImpl {
  :get role by id;<<input>>
  :get ""roleCapabilitySets"" by ""roleId"";<<task>>
  :extract existing ""capabilitySetIds"" from ""roleCapabilitySets"";<<task>>
  if (not ""safeCreate"" AND ""capabilitySetIds"" not empty)) then (yes)
    :throw EntityExistsException;<<output>>
    end
  else (no)
    :get difference between new and existing ""capabilitySetIds"" sets as ""newSetIds"";<<input>>
  end if

  group #LightSteelBlue assignCapabilitySets {
    group getChangedEndpoints {
      :retrieve ""Capability"" entities by ""roleId"" as ""directlyAssignedCapabilities"";<<input>>
      :extract endpoints from ""directlyAssignedCapabilities"" and store them as ""excludedEndpoints"";<<task>>

      group capabilityEndpointService.getByCapabilitySetIds {
        :retrieve ""Capability"" entities by ""newSetIds"" as ""changedCapabilities"";<<input>>
        :retrieve ""Capability"" entities by ""assignedSetIds"" as ""assignedCapabilities"";<<input>>

        :extract endpoints from ""changedCapabilities"" and store them as ""changedCapabilitySetEndpoints"";<<task>>
        :extract endpoints from ""assignedCapabilities"" and store them as ""assignedCapabilitySetEndpoints"";<<task>>

        :return subtraction ""assignedCapabilitySetEndpoints"" and ""excludedEndpoints"" from ""changedCapabilitySetEndpoints"" as ""List<Endpoint"";<<output>>
      }
    }

    :create keycloak permissions in ""RolePermissionsService"" for ""roleId"" and ""endpoints"";<<task>>
    :generate ""RoleCapabilitySetEntity"" for ""newSetIds"" and store them as ""entities"";<<task>>
    :upsert entities to table ""role_capability_set"" in database;<<output>>
    :convert ""RoleCapabilitySetEntity"" to ""RoleCapabilitySet"" and return them as ""PageResult"";<<output>>
  }
}

@enduml

CapabilitySet removal process

image-20241015-153200.pngImage Added

Expand
titleCapability Set removal process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group RoleCapabilityServiceImpl {
  :get role by id;<<input>>
  :get ""roleCapabilitySets"" by ""roleId"" as ""assignedRoleCapabilitySetEntities"";<<task>>
  :extract existing ""assignedCapabilitySetIds"" from ""assignedRoleCapabilitySetEntities"";<<task>>
  if (""assignedCapabilitySetIds"" is empty) then (yes)
    :do nothing;<<output>>
    end
  else (no)
    :get intersection between ""assignedCapabilitySetIds"" and given ""capabilitySetIds"" sets as ""deprecatedSetIds"";<<input>>
    :get subtraction between ""assignedCapabilityIds"" and existing ""deprecatedSetIds"" sets as ""assignedSetIds"";<<input>>
  end if

  group removeCapabilitySets {

    group #LightSteelBlue getChangedEndpoints {
      :retrieve "Capability" entities by ""roleId"" as ""directlyAssignedCapabilities"";<<input>>
      :extract endpoints from ""directlyAssignedCapabilities"" and store them as ""excludedEndpoints"";<<task>>

      group capabilityEndpointService.getByCapabilitySetIds {
        :retrieve "Capability" entities by ""deprecatedSetIds"" as ""changedCapabilities"";<<input>>
        :extract endpoints from ""changedCapabilities"" and store them as ""changedCapabilitySetEndpoints"";<<task>>

        :retrieve "Capability" entities by ""assignedSetIds"" as ""assignedCapabilities"";<<input>>
        :extract endpoints from ""assignedCapabilities"" and store them as ""assignedCapabilitySetEndpoints"";<<task>>

        :return subtraction ""assignedCapabilitySetEndpoints"" and ""excludedEndpoints"" from ""changedCapabilitySetEndpoints"" as ""List<Endpoint"";<<output>>
      }

      :remove keycloak permissions using ""RolePermissionsService"" for ""roleId"" and ""endpoints"";<<task>>
      :delete entities from table ""user_capability"" by ""roleId"" and ""deprecatedIds"";<<output>>
    }
  }
}

@enduml

User

Capability assignment process

safeCreate flag is used only internally, for outside requests a user must always assign a set of new capability ids, if not - an update operation allows the creation and delete capabilities in a single request

...

Expand
titleUser-Capability assignment PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group UserCapabilityServiceImpl {
  :create keycloakUser If not exists by calling ""mod-users-keycloak"" API;<<task>>
  :extract existing ""capabilityIds"" from ""userCapabilities"";<<task>>
  if (not ""safeCreate"" AND ""capabilityIds"" not empty)) then (yes)
    :throw EntityExistsException;<<output>>
    end
  else (no)
    :get difference between new and existing ""capabilityIds"" sets as ""newCapabilityIds"";<<input>>
  end if

  group assignCapabilities {
    :get assigned capabilities through capability sets as ""assignedCapabilityIds"";<<input>>

    :store union of ""assignedCapabilityIds"" and ""newCapabilityIds"" as ""assignedCapabilityIds"";<<input>>

    group #PaleGreen CapabilityEndpointService.getByCapabilityIds {
      :get difference with ""newCapabilityIds"" and ""assignedCapabilityIds"" as ""changedIdentifiers"";<<input>>

      :get changed capability endpoints by querying capabilities by ""changedIdentifiers""
      and extracting distinct list of assigned endpoints and store it as ""changedCapabilityEndpoints"";<<task>>

      :get assigned capability endpoints by querying capabilities by ""assignedIds""
      and extracting distinct list of assigned endpoints and store it as ""assignedCapabilityEndpoints"";<<task>>

      :return subtraction ""assignedCapabilityEndpoints"" from ""changedCapabilityEndpoints"";<<output>>
    }

    :userPermissionsService.createPermissions for ""userId"" and ""endpoints"";<<task>>

    group #PaleGreen CapabilityEndpointService.getByCapabilityIds {
      if (""endpoints"" are empty) then (yes)
        :do nothing;<<output>>
      else (no)
        :get keycloak user by Folio ""userId"";<<input>>
        :generate policy name for ""user"";<<input>>
        :getOrCreate user policy by name
        //Policy name template:// ""Policy for user: {{userId}}"";<<task>>

        :create permissions in Keycloak with user policy, list of endpoint and using permissionNameGenerator

        //Permission name template:// ""{{httpMethod}} access for user '{{userId}}' to '{{path}}'"";
      end if
    }

    :generate UserCapabilityEntity for ""newIds"" and store them as ""entities"";<<task>>
    :upsert entities to table ""user_capability"" in database;<<output>>
    :convert ""UserCapabilityEntity"" to ""UserCapability"" and return them as ""PageResult"";<<output>>
  }
}

@enduml

Capability removal process

...

Expand
titleCapability removal process from a user process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group UserCapabilityServiceImpl {
  :get existing ""userCapabilities"" by ""userId"";<<task>>
  :extract existing ""capabilityIds"" from ""userCapabilities"";<<task>>

  if (""capabilityIds"" is empty) then (yes)
    :do nothing;<<output>>
    end
  else (no)
    :get intersection between ""capabilityIds"" and existing ""capabilityIds"" sets as ""deprecatedIds"";<<input>>
    :get subtraction between ""assignedCapabilityIds"" and existing ""deprecatedIds"" sets as ""assignedIds"";<<input>>
  end if

  group removeCapabilities {
    :get assigned capabilities through capability sets as ""assignedCapabilityIds"";<<input>>
    :store union of ""assignedCapabilityIds"" and ""assignedIds"" as ""assignedCapabilityIds"";<<task>>

    group #PaleGreen CapabilityEndpointService.getByCapabilityIds {
      :get difference with ""deprecatedIds"" and ""assignedIds"" as ""changedIdentifiers"";<<input>>

      :get changed capability endpoints by querying capabilities by ""changedIdentifiers""
      and extracting distinct list of assigned endpoints and store it as ""changedCapabilityEndpoints"";<<task>>

      :get assigned capability endpoints by querying capabilities by ""assignedIds""
      and extracting distinct list of assigned endpoints and store it as ""assignedCapabilityEndpoints"";<<task>>

      :return subtraction ""assignedCapabilityEndpoints"" from ""changedCapabilityEndpoints"";<<output>>
    }

    :userPermissionsService.deletePermissions for ""userId"" and ""endpoints"";<<task>>

    group #PaleGreen CapabilityEndpointService.getByCapabilityIds {
      if (""endpoints"" are empty) then (yes)
        :do nothing;<<output>>
      else (no)
        :get keycloak user by ""userId"";<<input>>
        :generate policy name for ""user"";<<input>>
        :get policy by ""name"" and type == ""USER"";<<task>>

        :delete permissions in Keycloak with user policy, list of endpoint and using permissionNameGenerator

        //Permission name template:// ""{{httpMethod}} access for user '{{userId}}' to '{{path}}'"";
      end if
    }


    :delete entities from table ""user_capability"" in database by ""userId"" and ""deprecatedIds"";<<output>>
  }
}

@enduml

Capability update process

A capability update process combines capability assignment and capability remove processes, where unmodified capabilities and corresponding keycloak permissions remain intact.

CapabilitySet assignment process

...

Expand
titleCapability Set assignment process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group UserCapabilityServiceImpl {
  :create keycloakUser If not exists by calling ""mod-users-keycloak"" API;<<task>>
  :get ""userCapabilitySets"" by ""userId"";<<task>>
  :extract existing ""capabilitySetIds"" from ""userCapabilitySets"";<<task>>
  if (not ""safeCreate"" AND ""capabilitySetIds"" not empty)) then (yes)
    :throw EntityExistsException;<<output>>
    end
  else (no)
    :get difference between new and existing ""capabilitySetIds"" sets as ""newSetIds"";<<input>>
  end if

  group #PaleGreen assignCapabilitySets {
    group getChangedEndpoints {
      :retrieve ""Capability"" entities by ""userId"" as ""directlyAssignedCapabilities"";<<input>>
      :extract endpoints from ""directlyAssignedCapabilities"" and store them as ""excludedEndpoints"";<<task>>

      group capabilityEndpointService.getByCapabilitySetIds {
        :retrieve ""Capability"" entities by ""newSetIds"" as ""changedCapabilities"";<<input>>
        :retrieve ""Capability"" entities by ""assignedSetIds"" as ""assignedCapabilities"";<<input>>

        :extract endpoints from ""changedCapabilities"" and store them as ""changedCapabilitySetEndpoints"";<<task>>
        :extract endpoints from ""assignedCapabilities"" and store them as ""assignedCapabilitySetEndpoints"";<<task>>

        :return subtraction ""assignedCapabilitySetEndpoints"" and ""excludedEndpoints"" from ""changedCapabilitySetEndpoints"" as ""List<Endpoint"";<<output>>
      }
    }

    :create keycloak permissions in ""UserPermissionsService"" for ""userId"" and ""endpoints"";<<task>>
    :generate ""UserCapabilitySetEntity"" for ""newSetIds"" and store them as ""entities"";<<task>>
    :upsert entities to table ""user_capability_set"" in database;<<output>>
    :convert ""UserCapabilitySetEntity"" to ""UserCapabilitySet"" and return them as ""PageResult"";<<output>>
  }
}

@enduml

CapabilitySet removal process

image-20241015-153016.pngImage Added

Expand
titleCapabilitySet removal process PlantUML
Code Block
@startuml

!theme mono
!pragma useVerticalIf on
skinparam conditionStyle inside
skinparam defaultTextAlignment center
skinparam defaultFontSize 12

<style>
activityDiagram {
  activity {
    MaximumWidth 600
    backgroundColor #f0f0f0
    LineColor DimGrey
  }

  diamond {
    HorizontalAlignment center
  }

  group {
    LineColor LightGrey
    FontSize 10
  }
}
</style>

group UserCapabilityServiceImpl {
  :get keycloak user by Folio ""userId"";<<input>>
  :get ""userCapabilitySets"" by ""userId"" as ""assignedUserCapabilitySetEntities"";<<task>>
  :extract existing ""assignedCapabilitySetIds"" from ""assignedUserCapabilitySetEntities"";<<task>>
  if (""assignedCapabilitySetIds"" is empty) then (yes)
    :do nothing;<<output>>
    end
  else (no)
    :get intersection between ""assignedCapabilitySetIds"" and given ""capabilitySetIds"" sets as ""deprecatedSetIds"";<<input>>
    :get subtraction between ""assignedCapabilityIds"" and existing ""deprecatedSetIds"" sets as ""assignedSetIds"";<<input>>
  end if

  group removeCapabilitySets {

    group #PaleGreen getChangedEndpoints {
      :retrieve "Capability" entities by ""userId"" as ""directlyAssignedCapabilities"";<<input>>
      :extract endpoints from ""directlyAssignedCapabilities"" and store them as ""excludedEndpoints"";<<task>>

      group capabilityEndpointService.getByCapabilitySetIds {
        :retrieve "Capability" entities by ""deprecatedSetIds"" as ""changedCapabilities"";<<input>>
        :extract endpoints from ""changedCapabilities"" and store them as ""changedCapabilitySetEndpoints"";<<task>>

        :retrieve "Capability" entities by ""assignedSetIds"" as ""assignedCapabilities"";<<input>>
        :extract endpoints from ""assignedCapabilities"" and store them as ""assignedCapabilitySetEndpoints"";<<task>>

        :return subtraction ""assignedCapabilitySetEndpoints"" and ""excludedEndpoints"" from ""changedCapabilitySetEndpoints"" as ""List<Endpoint"";<<output>>
      }

      :remove keycloak permissions using ""UserPermissionsService"" for ""userId"" and ""endpoints"";<<task>>
      :delete entities from table ""user_capability"" by ""userId"" and ""deprecatedIds"";<<output>>
    }
  }
}

@enduml