SpEL
Instead of defining hard-coded values in the specification of apps, ShinyProxy allows to use the Spring Expression Language 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’s
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 3.2.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 definitionproxy
: contains all runtime information regarding the proxy (i.e. app) class definitionoidcUser
: contains a representation of the current user (when using OpenID Connect for authentication), see later javadocsamlCredential
: contains a representation of the current user (when using SAML for authentication), see later javadocldapUser
: contains a representation of the current user (when using LDAP for authentication), see later javadocwebServiceUser
: contains a representation of the current user (when using Web Service authentication), see later class definitiongroups
: the list of groups the user belongs touserId
: the userId of the userserverName
: the host name of the server (e.g.example.com
), only available during the evaluation of anaccess-expression
field
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 an app specification have support for spring expressions:
container-cmd
container-cpu-limit
container-cpu-request
container-dns
container-env-file
container-env
(only the values, not the keys)container-image
container-memory-limit
container-memory-request
container-network-connections
container-network
container-volumes
custom-app-details
(only in thevalue
property)docker-group-add
docker-ipc
docker-runtime
docker-swarm-secrets
docker-user
ecs-cpu-architecture
ecs-efs-volumes
ecs-enable-execute-command
ecs-ephemeral-storage-size
ecs-execution-role
ecs-managed-secret.name
ecs-managed-secret.value-from
ecs-operation-system-family
ecs-repository-credentials-parameter
ecs-task-role
heartbeat-timeout
http-headers
kubernetes-additional-manifests
kubernetes-additional-persistent-manifests
kubernetes-authorized-additional-manifests
kubernetes-authorized-additional-persistent-manifests
kubernetes-authorized-pod-patches
kubernetes-pod-patches
labels
max-instances
(thecontainerSpec
,proxySpec
andproxy
objects can’t be used in these expression)max-lifetime
resource-name
target-path
(also when specified as part ofadditional-port-mappings
)
In addition, the following global properties support SpEL:
proxy.logo
proxy.openid.logout-url
proxy.title
proxy.usage-stats-attributes
proxy.usage-stats-password
proxy.usage-stats-table-name
proxy.usage-stats-url
proxy.usage-stats-username
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 all
runtime information about an app. As soon as an app is started, ShinyProxy no
longer uses the specification of an app for running the app. Therefore, all
information that’s required for running an app are stored as runtime-values,
hence the name. Some of these runtime-values are automatically added as a label,
annotation, or environment variable to the container. The following table is a
complete overview of all runtime-values, including an example SpEL expression:
Environment variable name | Label name | SPeL Support | Env | Docker label | K8S label | K8S annotation | Usage |
---|---|---|---|---|---|---|---|
SHINYPROXY_APP_INSTANCE |
openanalytics.eu/sp-app-instance |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_APP_INSTANCE')} |
SHINYPROXY_BACKEND_CONTAINER_NAME |
openanalytics.eu/sp-backend-container-name |
No | No | No | No | No | N/A |
SHINYPROXY_CACHE_HEADERS_MODE |
openanalytics.eu/sp-cache-headers-mode |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_CACHE_HEADERS_MODE')} |
SHINYPROXY_CONTAINER_IMAGE |
openanalytics.eu/sp-container-image |
Subset | No | No | No | No | #{proxy.containers[0].getRuntimeValue('SHINYPROXY_CONTAINER_IMAGE')} |
SHINYPROXY_CONTAINER_INDEX |
openanalytics.eu/sp-container-index |
Subset | No | Yes | No | Yes | #{proxy.containers[0].getRuntimeValue('SHINYPROXY_CONTAINER_INDEX')} |
SHINYPROXY_CREATED_TIMESTAMP |
openanalytics.eu/sp-proxy-created-timestamp |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_CREATED_TIMESTAMP')} |
SHINYPROXY_CUSTOM_APP_DETAILS |
openanalytics.eu/sp-custom-app-details |
All | No | Yes | No | Yes | `#{proxy.getRuntimeValue(‘SHINYPROXY_CUSTOM_APP_DETAILS’)} |
SHINYPROXY_DISPLAY_NAME |
openanalytics.eu/sp-display-name |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_DISPLAY_NAME')} |
SHINYPROXY_FORCE_FULL_RELOAD |
openanalytics.eu/sp-shiny-force-full-reload |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_FORCE_FULL_RELOAD')} |
SHINYPROXY_HEARTBEAT_TIMEOUT |
openanalytics.eu/sp-heartbeat-timeout |
Subset | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_HEARTBEAT_TIMEOUT')} |
SHINYPROXY_HTTP_HEADERS |
openanalytics.eu/sp-http-headers |
Subset | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_HTTP_HEADERS')} |
SHINYPROXY_INSTANCE |
openanalytics.eu/sp-instance |
All | No | Yes | Yes | No | #{proxy.getRuntimeValue('SHINYPROXY_INSTANCE')} |
SHINYPROXY_MAX_LIFETIME |
openanalytics.eu/sp-max-lifetime |
Subset | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_MAX_LIFETIME')} |
SHINYPROXY_PARAMETERS |
openanalytics.eu/sp-parameters |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_PARAMETERS')} |
SHINYPROXY_PARAMETER_NAMES |
openanalytics.eu/sp-parameters-names |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_PARAMETER_NAMES')} |
SHINYPROXY_PORT_MAPPINGS |
openanalytics.eu/sp-port-mappings |
Subset | No | Yes | No | Yes | #{proxy.containers[0].getRuntimeValue('SHINYPROXY_PORT_MAPPINGS')} |
SHINYPROXY_PROXIED_APP |
openanalytics.eu/sp-proxied-app |
All | No | Yes | Yes | No | #{proxy.getRuntimeValue('SHINYPROXY_PROXIED_APP')} |
SHINYPROXY_PROXY_ID |
openanalytics.eu/sp-proxy-id |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_PROXY_ID')} |
SHINYPROXY_PUBLIC_PATH |
openanalytics.eu/sp-public-path |
All | Yes | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_PUBLIC_PATH')} |
SHINYPROXY_REALM_ID |
openanalytics.eu/sp-realm-id |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_REALM_ID')} |
SHINYPROXY_SPEC_ID |
openanalytics.eu/sp-spec-id |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_SPEC_ID')} |
SHINYPROXY_TARGET_ID |
openanalytics.eu/sp-target-id |
No | No | Yes | No | Yes | N/A |
SHINYPROXY_TRACK_APP_URL |
openanalytics.eu/sp-track-app-url |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_TRACK_APP_URL')} |
SHINYPROXY_USERGROUPS |
openanalytics.eu/sp-user-groups |
All | Yes | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_USERGROUPS')} |
SHINYPROXY_USERNAME |
openanalytics.eu/sp-user-id |
All | Yes | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_USERNAME')} |
SHINYPROXY_USER_TIMEZONE |
openanalytics.eu/sp-user-timezone |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_USER_TIMEZONE')} |
SHINYPROXY_WEBSOCKET_RECONNECTION_MODE |
openanalytics.eu/sp-websocket-reconnection-mode |
All | No | Yes | No | Yes | #{proxy.getRuntimeValue('SHINYPROXY_WEBSOCKET_RECONNECTION_MODE')} |
Almost all runtime-values can be used in the SpEL expression of all properties.
However, some runtime-values can only be used in a subset of configuration
properties. The reason is that some runtime-values are created using the value of
configuration options that support SpEL. For example, the container-image
configuration property supports SpEL, therefore you can’t use
the SHINYPROXY_CONTAINER_IMAGE
runtime-value in all the other configuration
properties, since this would require that the expression in
the container-image
property is resolved before these other properties. In
order to keep things simple and fast, such runtime-values can be used in only the
following subset of the configuration properties:
container-env
http-headers
kubernetes-additional-manifests
kubernetes-additional-persistent-manifests
kubernetes-authorized-additional-manifests
kubernetes-authorized-additional-persistent-manifests
kubernetes-authorized-pod-patches
kubernetes-pod-patches
labels
resource-name
As an example, you can add the value of the SHINYPROXY_PROXY_ID
variable as an
environment variable to the container:
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')}"
Yet another great use-case is automatically configuring the timezone of a
container using the timezone of the user. When a user starts an app,
ShinyProxy retrieves the timezone of the user from their browser and store this
in the SHINYPROXY_USER_TIMEZONE
runtime-value. The following configuration sets
the TZ
environment variable of an app. A typical Linux container uses
this environment variable to configure the timezone:
container-env:
TZ: "#{proxy.getRuntimeValue('SHINYPROXY_USER_TIMEZONE')}"
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}
: thesub
attribute (i.e. claim)#{oidcUser.attributes.email}
: theemail
attribute (i.e. claim)#{oidcUser.idToken.tokenValue}
: the ID token of the user#{oidcUser.refreshToken}
: the refresh token of the user#{oidcUser.accessToken}
: the access token of the user (the raw token as string, the token is automatically refreshed by ShinyProxy)#{oidcUser.accessTokenAsJwt}
: the access token of the user parsed as a JWT. This only works if the access token provided by the IDP is a JWT, this is not guaranteed by the OAuth or OpenID standards. In order to follow the standards, it’s better to use the ID token to obtain information about the user.#{oidcUser.accessTokenAsJwt.claims}
: all claims as a map (can be used to find out which claims are available)#{oidcUser.accessTokenAsJwt.getClaimAsString('sub')}
: get thesub claim
Note:
- you can extract any claim which your Authorization Server provides in the ID
token (or the
UserInfo
endpoint). - some providers (such as Azure B2C) require you to add the
offline_access
scope to get a refresh token (e.g to pass the refresh token to a container). See thescopes
property.
SAML
#{samlCredential.nameID}
: the nameID of the user#{samlCredential.getFirstAttribute('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email')}
: the email#{samlCredential.getAttribute('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email')}
: all email attributes
Note: you can extract any claim which your IDP Server provides in the SAML response.
LDAP
#{ldapUser.dn}
: the full DN of the user
Webservice authentication
-
#{webServiceUser.response}
: the response returned by the webservice (as a string) -
#{webServiceUser.jsonResponse}
: the response returned by the webservice as (parsed) JSON, for example if the web service returns:{ "data": { "uuid": "30cc6338-cbc0-4b12-bd31-4cc4ddd2d5e5", "groups": [ "scientists", "mathematicians" ] } }
you can extract the UUID using:
#{webServiceUser.jsonResponse.get('data').get('uuid')}
Access expression
Note
Refer to the Authorization and Authentication concept for more background info on this topic.Each app can optionally have 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 app.
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 evaluates
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 regular expression 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 shouldn’t depend on the exact time when it’s executed. For example, you shouldn’t depend on the current time, 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.
In addition to the access-expression
, apps can also use the
access-strict-expression
property. This expression is always evaluated and
must always return true
, even if other access control settings allow the user
to access the app. This is useful to combine filtering apps (e.g.
by server name)
and regular access control.
Examples
The examples in this section are using additional attributes provided in the OIDC token. Of course, it’s 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’s possible to use any Spring Bean in the spring expression:
volumes: [ "/home/#{@userService.currentUserId}/myworkspace:/var/myworkspace" ]
-
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’s 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’s compatible with your app. For example, to strip the last slash:SCRIPT_NAME: "#{proxy.getRuntimeValue('SHINYPROXY_PUBLIC_PATH').replaceFirst('/$','')}"