SpEL

Instead of defining hard-coded values in the specification of apps, ShinyProxy allows to use the Spring Expression Language in order to dynamically determine a value at runtime. This page describes this concept and gives an overview of supported configuration.

Example

Before diving into the details an example can be useful:

- id: example-app
  container-image: example-app
  container-volumes: [ "/workspaces/#{proxy.userId}/work:/home/user/workspace"]

When starting this app, ShinyProxy replaces the #{proxy.userId} expression by the user starting the app. Therefore, ShinyProxy mounts a unique directory per user as workspace into the container. This is just the beginning of what is possible with the Spring Expressions.

The Expression Context

Every expression you write, can make use of variables stored in the expression context. Every time you start an app, ShinyProxy fills this context with some (Java) objects.

As of ShinyProxy 2.6.0 this context exists of the following objects:

  • containerSpec: contains the specification of the container used in the app class definition.
  • proxySpec: contains the specification of the proxy (i.e. app) class definition
  • proxy: contains all runtime information regarding the proxy (i.e. app) class definition
  • oicdUser: contains a representation of the current user (when using OpenID Connect for authentication), see later javadoc
  • keycloakUser: contains a representation of the current user (when using Keycloak for authentication), see later javadoc
  • samlCredential: contains a representation of the current user (when using SAML for authentication), see later javadoc
  • ldapUser: contains a representation of the current user (when using LDAP for authentication), see later javadoc
  • groups: the list of groups the user belongs to

When using a property of an object in the context, you can use the getter function, e.g.:

  container-volumes: [ "/workspaces/#{proxy.getUserId()}/work:/home/user/workspace"]

but you can also omit the get and brackets (i.e. getUserId() -> userId):

  container-volumes: [ "/workspaces/#{proxy.userId}/work:/home/user/workspace"]

Properties which support expressions

The following list of properties of a proxy specification have support for spring expressions:

  • container-image
  • container-cmd
  • container-env (only the values, not the keys)
  • container-env-file
  • container-network
  • container-network-connections
  • container-dns
  • container-volumes
  • container-memory-request
  • container-memory-limit
  • container-cpu-request
  • container-cpu-limit
  • target-path
  • labels
  • kubernetes-additional-manifests
  • kubernetes-additional-persistent-manifests
  • kubernetes-pod-patches
  • access-expression

In addition, the proxy.openid.logout-url also supports SpEL, in order to support IDPs requiring extra information in the logout URL.

Runtime-values

The proxy object in the expression context contains a special function getRuntimeValue. As the name suggests, this function is used to retrieve a runtime-value. Internally, ShinyProxy uses runtime-values for storing a dynamic set of values of which the value can only be computed at the time an app is started. In other words, these are values that aren’t defined in the specification of an app. Some of these values are automatically added as an environment variable, as a label or as annotation to the started container. This concept was introduced in ShinyProxy 2.6.0. The current set of available runtime-values are:

Environment variable name Label name Env Docker label K8S label K8S annotation
SHINYPROXY_USERNAME openanalytics.eu/sp-user-id Yes Yes No Yes
SHINYPROXY_USERGROUPS openanalytics.eu/sp-user-groups Yes Yes No Yes
SHINYPROXY_REALM_ID openanalytics.eu/sp-realm-id No Yes No Yes
SHINYPROXY_SPEC_ID openanalytics.eu/sp-spec-id No Yes No Yes
SHINYPROXY_PROXY_ID openanalytics.eu/sp-proxy-id No Yes No Yes
SHINYPROXY_PROXIED_APP openanalytics.eu/sp-proxied-app No Yes Yes No
SHINYPROXY_INSTANCE openanalytics.eu/sp-instance No Yes Yes No
SHINYPROXY_CREATED_TIMESTAMP openanalytics.eu/sp-proxy-created-timestamp No Yes No Yes
SHINYPROXY_APP_INSTANCE openanalytics.eu/sp-app-instance No Yes No Yes
SHINYPROXY_PUBLIC_PATH openanalytics.eu/sp-public-path Yes Yes No Yes

You can use these variables in every spring expression as you wish. For example, by default the `SHINYPROXY_PROXY_ID` variable is not added as an environment variable to the container. In case you need this, you can use:
container-env:
    SHINYPROXY_PROXY_ID: "#{proxy.getRuntimeValue('SHINYPROXY_PROXY_ID')}"

This also allows you to rename an environment variable, which is sometimes needed for the SHINYPROXY_PUBLIC_PATH variable:

container-env:
    WWW_ROOT_PATH: "#{proxy.getRuntimeValue('SHINYPROXY_PUBLIC_PATH')}"

Of course, you can also add these variables to the labels of your container:

labels:
    CUSTOM_USER_ID_LABEL: "#{proxy.getRuntimeValue('SHINYPROXY_USERNAME')}"

Authentication objects

As described earlier, the expression context contains some objects representing the current user. This can be useful to extract additional information from the user profile and attach these to the app containers. This section gives some examples for the supported authentication backends.

OpenID Connect

  • #{oidcUser.attributes['sub']}": the sub attribute (i.e. claim)
  • #{oidcUser.attributes['email']}": the email attribute (i.e. claim)
  • #{oidcUser.idToken.tokenValue}: the ID token of the user
  • #{oidcUser.refreshToken}: the refresh token of the user

Note: you can extract any claim which your Authorization Server provides in the ID token (or UserInfo endpoint).

Keycloak

  • #{keycloakUser.keycloakSecurityContext.realm}: the Keycloak realm name
  • #{keycloakUser.keycloakSecurityContext.tokenString}: the access token
  • #{keycloakUser.keycloakSecurityContext.idTokenString}: the ID token
  • #{keycloakUser.keycloakSecurityContext.refreshToken}: the refresh token
  • #{keycloakUser.keycloakSecurityContext.token.email}: the e-mail
  • #{keycloakUser.keycloakSecurityContext.token.birthdate}: the birthdate
  • #{keycloakUser.keycloakSecurityContext.token.emailVerified}: whether the email of the user is verified

Note: you can extract much more information from the keycloakUser.keycloakSecurityContext.token object. You can find all fields at the Keycloak javadoc . Also have a look at the fields inherited from the IDToken class.

SAML

  • #{samlCredential.remoteEntityID}: the identifier of the IDP where the assertion came from
  • #{samlCredential.nameID}: the nameID of the user
  • #{samlCredential.getAttributeAsString('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email')}: the e-mail

Note: you can extract any claim which your IDP Server provides in the SAML response.

LDAP

  • #{ldapUser.dn}: the full DN of the user

Access Expression

Since ShinyProxy 2.6.0, each application can specify an access-expression property. This feature allows creating arbitrary complex expressions in order to determine whether a user has access to an app or not. This is useful in two cases: 1) when you need access control based on a different attribute than the groups which a user belongs too and 2) when you require a user to belong to multiple groups at once before allowing to use an application.

Let’s start with an example:

  - id: 01_hello
    display-name: Hello Application
    description: Application which demonstrates the basics of a Shiny app
    container-cmd: ["R", "-e", "shinyproxy::run_01_hello()"]
    container-image: openanalytics/shinyproxy-demo
    access-expression: "#{groups.contains('SCIENTISTS') and groups.contains('COMMON')}"

This app definition has a property access-expression. Whenever ShinyProxy needs to check whether a user has access to this app, ShinyProxy will evaluate this expression. Only when the expression returns true, the user has access to the app. Therefore, in this example, the user must be part of both the SCIENTISTS and COMMON group.

ShinyProxy contains some helper functions that makes this feature more useful:

  • List<String> toList(String attribute, String regex): this function converts the first parameter (attribute) to a list of strings, by splitting according to the provided regex and trimming each result.
  • List<String> toList(String attribute): identical to the previous function, but always splits on ,.
  • List<String> toLowerCaseList(String attribute, String regex): similar to the previous functions, but converts each element to lower case. Therefore, you can do a comparison that ignores the casing of a string (see later).
  • List<String> toLowerCaseList(String attribute): identical to the previous function, but always splits on ,.
  • boolean isOneOf(String attribute, String... allowedValues): returns whether the first parameter is one of the allowed values. Again all strings are trimmed before being compared.
  • `boolean isOneOfIgnoreCase(String attribute, String… allowedValues): similar to the previous function, but ignores the casing of strings.

Note: the expression is only evaluated once per session of a user (the result is cached). This means that your expression should not depend on the exact time when it is executed. For example, you should not depend on the current tim, the time that the user has been active, the amount of apps the user has running etc. This would give a very confusing experience to the user.

Examples

The examples in this section are using additional attributes provided in the OIDC token. Of course, it is possible to achieve this with other authentication backends as well. See the Expression context for a list of variables you can use in the expression.

Only give access to pharmacists

access-expression: "#{oidcUser.attributes['function'] == 'Pharmacist'}"

Only give access to pharmacists from a certain province

access-expression: "#{oidcUser.attributes['function'] == 'Pharmacist' and oidcUser.attributes['province'] == 'Antwerp'}"

Only give access to pharmacists and general practitioners

access-expression: "#{isOneOf(oidcUser.attributes['function'], 'Pharmacist', 'General_practitioner')}"

or:

access-expression: "#{isOneOfIgnoreCase(oidcUser.attributes['function'], 'Pharmacist', 'General_practitioner')}"

Only give access to pharmacists and general practitioners from a certain province

access-expression: "#{isOneOfIgnoreCase(oidcUser.attributes['function'], 'Pharmacist', 'General_practitioner') and oidcUser.attributes['province'] == 'Antwerp'}"

Only give access to pharmacists and general practitioners from a certain province and all dentists

access-expression: "#{(isOneOfIgnoreCase(oidcUser.attributes['function'], 'Pharmacist', 'General_practitioner') and oidcUser.attributes['province'] == 'Antwerp') or oidcUser.attributes['function'] == 'Dentist'}"

Only give access to certain specializations

In this example the specialization attribute is a comma-separated list, for example a user may have the attribute, with the following value specialization:

oncology, pediatrics

The toList function can be used to parse this value into a list:

access-expression: "#{toList(oidcUser.attributes['specialization'])}"

Any method available on the java List type can be used on this object. For example, when you want only one specialization to have access to an app:

access-expression: "#{toList(oidcUser.attributes['specialization']).contains('oncology'))}"

Or when only users with at least two specializations may have access:

access-expression: "#{toList(oidcUser.attributes['specialization']).size() >= 2}"

The expression gets more complex when multiple specializations are allowed, for example to allow any user who has (at least) the oncology or pediatrics specialization.

access-expression: "#{!T(java.util.Collections).disjoint(toList(oidcUser.attributes['specialization']), {'oncology', 'pediatrics'})}"

Tips & Tricks

  • it is possible to use any Spring Bean in the spring expression:

    volumes: [ "/home/#{@userService.currentUserId}/myworkspace:/var/myworkspace" ]
    
  • in order to call static methods, wrap the fully qualified class name in T() and call the method, for example to encode a string to base64:

    ENCODED_USERNAME: "#{T(java.util.Base64).getEncoder().encodeToString(('USERNAME=' + proxy.getRuntimeValue('SHINYPROXY_USERNAME')).getBytes())}"
    
  • when working with strings in the expression, it is possible to call any method of the String java class. For example to get the length of a username use:

    USERNAME_LENGTH: "#{proxy.getRuntimeValue('SHINYPROXY_USER').length()}"
    

    This can also be useful to modify the SHINYPROXY_PUBLIC_PATH such that it is compatible with your application. For example, to strip the last slash:

    SCRIPT_NAME: "#{proxy.getRuntimeValue('SHINYPROXY_PUBLIC_PATH').replaceFirst('/$','')}"