From b7c4d2701bd4e195fa8541405aedc253ce7de7e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Franti=C5=A1ek=20Dvo=C5=99=C3=A1k?= <valtri@civ.zcu.cz>
Date: Fri, 23 Aug 2024 14:40:55 +0000
Subject: [PATCH] Set AuthZ - development and testing instances

---
 cesnet-central/deployments/fullhub.yaml | 232 +++++++-----------------
 testing/deployments/hub.yaml            | 196 +++-----------------
 2 files changed, 91 insertions(+), 337 deletions(-)

diff --git a/cesnet-central/deployments/fullhub.yaml b/cesnet-central/deployments/fullhub.yaml
index bc0be22..5d02652 100644
--- a/cesnet-central/deployments/fullhub.yaml
+++ b/cesnet-central/deployments/fullhub.yaml
@@ -72,6 +72,49 @@ singleuser:
         image: "valtri/single-user:jupyter-4e-collab"
         extra_annotations:
           "egi.eu/flavor": "small-environment-2-vcpu-4-gb-ram"
+    - display_name: Small Environment - 2 vCPU / 4 GB RAM
+      description: >
+        The notebook environment includes Python, R, Julia and Octave kernels.
+      default: true
+      kubespawner_override:
+        args:
+          - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
+        extra_annotations:
+          "egi.eu/flavor": "small-environment-2-vcpu-4-gb-ram"
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:2-vcpu-4-gb-ram:act:ppa
+    - display_name: Medium Environment - 4 vCPU / 8 GB RAM
+      description: >
+        The notebook environment includes Python, R, Julia and Octave kernels.
+      kubespawner_override:
+        args:
+          - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
+        extra_annotations:
+          "egi.eu/flavor": "medium-environment-4-vcpu-8-gb-ram"
+        cpu_guarantee: 0.4
+        cpu_limit: 4
+        mem_guarantee: 1G
+        mem_limit: 8G
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:4-vcpu-8-gb-ram:act:ppa
+    - display_name: Large Environment - 8 vCPU / 16 GB RAM / GPU
+      description: >
+        The notebook environment includes Python, R, Julia and Octave kernels with GPU.
+      kubespawner_override:
+        args:
+          - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
+        cpu_guarantee: 0.8
+        cpu_limit: 8
+        mem_guarantee: 2G
+        mem_limit: 16G
+        extra_annotations:
+          "egi.eu/flavor": "large-environment-8-vcpu-16-gb-ram-gpu"
+        extra_resource_guarantees:
+          nvidia.com/gpu: 1
+        extra_resource_limits:
+          nvidia.com/gpu: 1
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:8-vcpu-16-gb-ram-gpu:act:ppa
   cmd: jupyterhub-singleuser-webdav-wrapper
   extraFiles:
     wait-remote-home.sh:
@@ -144,8 +187,12 @@ hub:
         # valtri@civ.zcu.cz
         - c36b18fe-e03a-4a22-ab14-5965e0171410@eosc-federation.eu
       allowed_groups:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:2-vcpu-4-gb-ram:act:ppa
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:4-vcpu-8-gb-ram:act:ppa
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:8-vcpu-16-gb-ram-gpu:act:ppa
         - urn:geant:eosc-federation.eu:testing:group:eosc
-      auto_login: true
+      admin_groups:
+        - urn:geant:eosc-federation.eu:group:asg:notebooks.open-science-cloud.ec.europa.eu:role=admin
       claim_groups_key: "entitlements"
     EGICheckinAuthenticator:
       checkin_host: "{{ secret['checkin_host'] }}"
@@ -174,84 +221,11 @@ hub:
       c.JupyterHub.extra_handlers = [(r'/welcome', WelcomeHandler)]
     egi-notebooks-b2drop: |-
 {%- raw %}
-      import base64
       import json
-      from jinja2 import BaseLoader
-      from jinja2 import Environment
       from egi_notebooks_hub.onedata import OnedataSpawner
-      from kubernetes_asyncio.client.rest import ApiException
       from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
 
-
-      class B2DropSpawner(OnedataSpawner):
-          async def auth_state_hook(self, spawner, auth_state):
-              await super().auth_state_hook(spawner, auth_state)
-              self.b2drop_ready = False
-              self.b2drop_user = ""
-              self.b2drop_pwd = ""
-              try:
-                  secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace)
-              except ApiException:
-                  return
-              if secret and secret.data:
-                   self.b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode()
-                   self.b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode()
-                   self.b2drop_ready = (self.b2drop_user and self.b2drop_pwd)
-
-          def _render_options_form(self, profile_list):
-              # old:self._profile_list = self._init_profile_list(profile_list)
-              self._profile_list = self._get_initialized_profile_list(profile_list)
-
-              profile_form_template = Environment(loader=BaseLoader).from_string(
-                  self.profile_form_template
-              )
-              return profile_form_template.render(profile_list=self._profile_list, b2drop_ready=self.b2drop_ready, b2drop_user=self.b2drop_user, b2drop_pwd=self.b2drop_pwd)
-
-          async def pre_spawn_hook(self, spawner):
-              await super(B2DropSpawner, self).pre_spawn_hook(spawner)
-              b2drop_user = self.user_options.get("b2drop-user", "")
-              b2drop_pwd = self.user_options.get("b2drop-pwd", "")
-              if not (b2drop_user and b2drop_pwd):
-                  secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace)
-                  if secret and secret.data:
-                      b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode()
-                      b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode()
-              if b2drop_user and b2drop_pwd:
-                  volume_mounts = [
-                    {"mountPath": "/owncloud:shared", "name": "owncloud-home"},
-                  ]
-                  spawner.extra_containers.append(
-                    {
-                        "name": "b2drop",
-                        "image": "eginotebooks/webdav-rclone-sidecar:sha-0a62679",
-                        "env": [
-                            {"name": "WEBDAV_URL", "value": "https://b2drop.eudat.eu/remote.php/webdav"},
-                            {"name": "WEBDAV_PWD", "value": b2drop_pwd},
-                            {"name": "WEBDAV_USER", "value": b2drop_user},
-                            {"name": "WEBDAV_VENDOR", "value": "other"},
-                            {"name": "MOUNT_PATH", "value": "/owncloud/b2drop"},
-                            {"name": "MOUNT_WAIT_POINT", "value": "webdav-fs: /owncloud fuse.rclone"},
-                        ],
-                        "resources": self.sidecar_resources,
-                        "securityContext": {
-                            "runAsUser": 1000,
-                            "fsUser": 1000,
-                            "fsGroup": 100,
-                            "privileged": True,
-                            "capabilities": {"add": ["SYS_ADMIN"]},
-                        },
-                        "volumeMounts": volume_mounts,
-                    }
-                  )
-
-          def options_from_form(self, formdata):
-              data = super(B2DropSpawner, self)._options_from_form(formdata)
-              data.update({'b2drop-user': formdata.get('b2drop-user', [None])[0],
-                           'b2drop-pwd': formdata.get('b2drop-pwd', [None])[0]})
-              return data
-
-
-      class WebDavOIDCSpawner(B2DropSpawner):
+      class WebDavOIDCSpawner(OnedataSpawner):
           # ownCloud Infinite Scale parameters
           # (https://owncloud.dev/apis/http/graph/spaces/#list-my-spaces-get-medrives)
           OCIS_URL = "https://ocis.aaitest.owncloud.works"
@@ -346,100 +320,9 @@ hub:
               else:
                 self.log.info("No auth state, skipping ownCloud")
 
-
       c.JupyterHub.spawner_class = WebDavOIDCSpawner
-      c.B2DropSpawner.token_mount_path = "/var/run/secrets/oidc/"
-      c.B2DropSpawner.http_timeout = 90
-      c.B2DropSpawner.profile_form_template = """
-        <style>
-            /*
-                .profile divs holds two div tags: one for a radio button, and one
-                for the profile's content.
-            */
-            #kubespawner-profiles-list .profile {
-                display: flex;
-                flex-direction: row;
-                font-weight: normal;
-                border-bottom: 1px solid #ccc;
-                padding-bottom: 12px;
-            }
-
-            #kubespawner-profiles-list .profile .radio {
-                padding: 12px;
-            }
-
-            /* .option divs holds a label and a select tag */
-            #kubespawner-profiles-list .profile .option {
-                display: flex;
-                flex-direction: row;
-                align-items: center;
-                padding-bottom: 12px;
-            }
-
-            #kubespawner-profiles-list .profile .option label {
-                font-weight: normal;
-                margin-right: 8px;
-                min-width: 96px;
-            }
-        </style>
-
-        <div class='form-group' id='kubespawner-profiles-list'>
-            {%- for profile in profile_list %}
-            {#- Wrap everything in a <label> so clicking anywhere selects the option #}
-            <label for='profile-item-{{ profile.slug }}' class='profile'>
-                <div class='radio'>
-                    <input type='radio' name='profile' id='profile-item-{{ profile.slug }}' value='{{ profile.slug }}' {% if profile.default %}checked{% endif %} />
-                </div>
-                <div>
-                    <h3>{{ profile.display_name }}</h3>
-
-                    {%- if profile.description %}
-                    <p>{{ profile.description }}</p>
-                    {%- endif %}
-
-                    {%- if profile.profile_options %}
-                    <div>
-                        {%- for k, option in profile.profile_options.items() %}
-                        <div class='option'>
-                            <label for='profile-option-{{profile.slug}}-{{k}}'>{{option.display_name}}</label>
-                            <select name="profile-option-{{profile.slug}}-{{k}}" class="form-control">
-                                {%- for k, choice in option['choices'].items() %}
-                                <option value="{{ k }}" {% if choice.default %}selected{%endif %}>{{ choice.display_name }}</option>
-                                {%- endfor %}
-                            </select>
-                        </div>
-                        {%- endfor %}
-                    </div>
-                    {%- endif %}
-                </div>
-            </label>
-            {%- endfor %}
-            <div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
-              <div class="panel panel-default">
-                <div class="panel-heading" role="tab" id="headingOne">
-                  <h4 class="panel-title">
-                    <a class="collabpsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
-                      B2DROP connection
-                    </a>
-                    {%if b2drop_ready %}<span class="label label-success">Already configured!</span>{% endif %}
-                  </h4>
-                </div>
-                <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne">
-                  <div class="panel-body">
-                    <div class='form-group'>
-                      <label for="b2drop-user" class="form-label">B2DROP app Username</label>
-                      <input type="text" class="form-control" name="b2drop-user" id="b2drop-user" aria-describedby="b2drop-user-help" value="{{ b2drop_user }}">
-                      <div id="b2drop-user-help" class="form-text">Create new app password at <a href="https://b2drop.eudat.eu/settings/user/security">B2DROP security configuration</a></div>
-                    </div>
-                    <div class='form-group'>
-                        <label for="b2drop-pwd" class="form-label">B2DROP app Password</label>
-                        <input type="password" class="form-control" name="b2drop-pwd" id="b2drop-pwd" value="{{ b2drop_pwd }}">
-                    </div>
-                  </div>
-                </div>
-              </div>
-        </div>
-        """
+      c.WebDavOIDCSpawner.token_mount_path = "/var/run/secrets/oidc/"
+      c.WebDavOIDCSpawner.http_timeout = 90
 {% endraw %}
   extraFiles:
     welcome.html:
@@ -448,6 +331,19 @@ hub:
 {%- raw %}
         {% extends "login.html" %}
 {% endraw %}
+    403.html:
+      mountPath: /usr/local/share/jupyterhub/templates/403.html
+      stringData: |-
+{%- raw %}
+        {% extends "error.html" %}
+        {% block main %}
+        <div class="error">
+          <h1>Unauthorized</h1>
+          <p>You don't have the correct entitlements to access this service.</p>
+          <p>If you think you should be granted access, please open an issue!</p>
+        </div>
+        {% endblock %}
+{% endraw %}
 
 debug:
   enabled: true
diff --git a/testing/deployments/hub.yaml b/testing/deployments/hub.yaml
index 4decb05..0b6f859 100644
--- a/testing/deployments/hub.yaml
+++ b/testing/deployments/hub.yaml
@@ -64,6 +64,8 @@ singleuser:
           - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
         extra_annotations:
           "egi.eu/flavor": "small-environment-2-vcpu-4-gb-ram"
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:2-vcpu-4-gb-ram:act:ppa
     - display_name: Medium Environment - 4 vCPU / 8 GB RAM
       description: >
         The notebook environment includes Python, R, Julia and Octave kernels.
@@ -76,6 +78,8 @@ singleuser:
         cpu_limit: 4
         mem_guarantee: 1G
         mem_limit: 8G
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:4-vcpu-8-gb-ram:act:ppa
     - display_name: Large Environment - 8 vCPU / 16 GB RAM / GPU
       description: >
         The notebook environment includes Python, R, Julia and Octave kernels with GPU.
@@ -92,6 +96,8 @@ singleuser:
           nvidia.com/gpu: 1
         extra_resource_limits:
           nvidia.com/gpu: 1
+      vo_claims:
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:8-vcpu-16-gb-ram-gpu:act:ppa
   cmd: jupyterhub-singleuser-webdav-wrapper
   extraFiles:
     wait-remote-home.sh:
@@ -164,8 +170,11 @@ hub:
         # valtri@civ.zcu.cz
         - c36b18fe-e03a-4a22-ab14-5965e0171410@eosc-federation.eu
       allowed_groups:
-        - urn:geant:eosc-federation.eu:testing:group:eosc
-      auto_login: true
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:2-vcpu-4-gb-ram:act:ppa
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:4-vcpu-8-gb-ram:act:ppa
+        - urn:geant:eosc-federation.eu:res:notebooks.open-science-cloud.ec.europa.eu:8-vcpu-16-gb-ram-gpu:act:ppa
+      admin_groups:
+        - urn:geant:eosc-federation.eu:group:asg:notebooks.open-science-cloud.ec.europa.eu:role=admin
       claim_groups_key: "entitlements"
     EGICheckinAuthenticator:
       checkin_host: "{{ secret['checkin_host'] }}"
@@ -194,84 +203,11 @@ hub:
       c.JupyterHub.extra_handlers = [(r'/welcome', WelcomeHandler)]
     egi-notebooks-b2drop: |-
 {%- raw %}
-      import base64
       import json
-      from jinja2 import BaseLoader
-      from jinja2 import Environment
       from egi_notebooks_hub.onedata import OnedataSpawner
-      from kubernetes_asyncio.client.rest import ApiException
       from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
 
-
-      class B2DropSpawner(OnedataSpawner):
-          async def auth_state_hook(self, spawner, auth_state):
-              await super().auth_state_hook(spawner, auth_state)
-              self.b2drop_ready = False
-              self.b2drop_user = ""
-              self.b2drop_pwd = ""
-              try:
-                  secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace)
-              except ApiException:
-                  return
-              if secret and secret.data:
-                   self.b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode()
-                   self.b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode()
-                   self.b2drop_ready = (self.b2drop_user and self.b2drop_pwd)
-
-          def _render_options_form(self, profile_list):
-              # old:self._profile_list = self._init_profile_list(profile_list)
-              self._profile_list = self._get_initialized_profile_list(profile_list)
-
-              profile_form_template = Environment(loader=BaseLoader).from_string(
-                  self.profile_form_template
-              )
-              return profile_form_template.render(profile_list=self._profile_list, b2drop_ready=self.b2drop_ready, b2drop_user=self.b2drop_user, b2drop_pwd=self.b2drop_pwd)
-
-          async def pre_spawn_hook(self, spawner):
-              await super(B2DropSpawner, self).pre_spawn_hook(spawner)
-              b2drop_user = self.user_options.get("b2drop-user", "")
-              b2drop_pwd = self.user_options.get("b2drop-pwd", "")
-              if not (b2drop_user and b2drop_pwd):
-                  secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace)
-                  if secret and secret.data:
-                      b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode()
-                      b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode()
-              if b2drop_user and b2drop_pwd:
-                  volume_mounts = [
-                    {"mountPath": "/owncloud:shared", "name": "owncloud-home"},
-                  ]
-                  spawner.extra_containers.append(
-                    {
-                        "name": "b2drop",
-                        "image": "eginotebooks/webdav-rclone-sidecar:sha-0a62679",
-                        "env": [
-                            {"name": "WEBDAV_URL", "value": "https://b2drop.eudat.eu/remote.php/webdav"},
-                            {"name": "WEBDAV_PWD", "value": b2drop_pwd},
-                            {"name": "WEBDAV_USER", "value": b2drop_user},
-                            {"name": "WEBDAV_VENDOR", "value": "other"},
-                            {"name": "MOUNT_PATH", "value": "/owncloud/b2drop"},
-                            {"name": "MOUNT_WAIT_POINT", "value": "webdav-fs: /owncloud fuse.rclone"},
-                        ],
-                        "resources": self.sidecar_resources,
-                        "securityContext": {
-                            "runAsUser": 1000,
-                            "fsUser": 1000,
-                            "fsGroup": 100,
-                            "privileged": True,
-                            "capabilities": {"add": ["SYS_ADMIN"]},
-                        },
-                        "volumeMounts": volume_mounts,
-                    }
-                  )
-
-          def options_from_form(self, formdata):
-              data = super(B2DropSpawner, self)._options_from_form(formdata)
-              data.update({'b2drop-user': formdata.get('b2drop-user', [None])[0],
-                           'b2drop-pwd': formdata.get('b2drop-pwd', [None])[0]})
-              return data
-
-
-      class WebDavOIDCSpawner(B2DropSpawner):
+      class WebDavOIDCSpawner(OnedataSpawner):
           # ownCloud Infinite Scale parameters
           # (https://owncloud.dev/apis/http/graph/spaces/#list-my-spaces-get-medrives)
           OCIS_URL = "https://ocis.aaitest.owncloud.works"
@@ -366,100 +302,9 @@ hub:
               else:
                 self.log.info("No auth state, skipping ownCloud")
 
-
       c.JupyterHub.spawner_class = WebDavOIDCSpawner
-      c.B2DropSpawner.token_mount_path = "/var/run/secrets/oidc/"
-      c.B2DropSpawner.http_timeout = 90
-      c.B2DropSpawner.profile_form_template = """
-        <style>
-            /*
-                .profile divs holds two div tags: one for a radio button, and one
-                for the profile's content.
-            */
-            #kubespawner-profiles-list .profile {
-                display: flex;
-                flex-direction: row;
-                font-weight: normal;
-                border-bottom: 1px solid #ccc;
-                padding-bottom: 12px;
-            }
-
-            #kubespawner-profiles-list .profile .radio {
-                padding: 12px;
-            }
-
-            /* .option divs holds a label and a select tag */
-            #kubespawner-profiles-list .profile .option {
-                display: flex;
-                flex-direction: row;
-                align-items: center;
-                padding-bottom: 12px;
-            }
-
-            #kubespawner-profiles-list .profile .option label {
-                font-weight: normal;
-                margin-right: 8px;
-                min-width: 96px;
-            }
-        </style>
-
-        <div class='form-group' id='kubespawner-profiles-list'>
-            {%- for profile in profile_list %}
-            {#- Wrap everything in a <label> so clicking anywhere selects the option #}
-            <label for='profile-item-{{ profile.slug }}' class='profile'>
-                <div class='radio'>
-                    <input type='radio' name='profile' id='profile-item-{{ profile.slug }}' value='{{ profile.slug }}' {% if profile.default %}checked{% endif %} />
-                </div>
-                <div>
-                    <h3>{{ profile.display_name }}</h3>
-
-                    {%- if profile.description %}
-                    <p>{{ profile.description }}</p>
-                    {%- endif %}
-
-                    {%- if profile.profile_options %}
-                    <div>
-                        {%- for k, option in profile.profile_options.items() %}
-                        <div class='option'>
-                            <label for='profile-option-{{profile.slug}}-{{k}}'>{{option.display_name}}</label>
-                            <select name="profile-option-{{profile.slug}}-{{k}}" class="form-control">
-                                {%- for k, choice in option['choices'].items() %}
-                                <option value="{{ k }}" {% if choice.default %}selected{%endif %}>{{ choice.display_name }}</option>
-                                {%- endfor %}
-                            </select>
-                        </div>
-                        {%- endfor %}
-                    </div>
-                    {%- endif %}
-                </div>
-            </label>
-            {%- endfor %}
-            <div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
-              <div class="panel panel-default">
-                <div class="panel-heading" role="tab" id="headingOne">
-                  <h4 class="panel-title">
-                    <a class="collabpsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
-                      B2DROP connection
-                    </a>
-                    {%if b2drop_ready %}<span class="label label-success">Already configured!</span>{% endif %}
-                  </h4>
-                </div>
-                <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne">
-                  <div class="panel-body">
-                    <div class='form-group'>
-                      <label for="b2drop-user" class="form-label">B2DROP app Username</label>
-                      <input type="text" class="form-control" name="b2drop-user" id="b2drop-user" aria-describedby="b2drop-user-help" value="{{ b2drop_user }}">
-                      <div id="b2drop-user-help" class="form-text">Create new app password at <a href="https://b2drop.eudat.eu/settings/user/security">B2DROP security configuration</a></div>
-                    </div>
-                    <div class='form-group'>
-                        <label for="b2drop-pwd" class="form-label">B2DROP app Password</label>
-                        <input type="password" class="form-control" name="b2drop-pwd" id="b2drop-pwd" value="{{ b2drop_pwd }}">
-                    </div>
-                  </div>
-                </div>
-              </div>
-        </div>
-        """
+      c.WebDavOIDCSpawner.token_mount_path = "/var/run/secrets/oidc/"
+      c.WebDavOIDCSpawner.http_timeout = 90
 {% endraw %}
   extraFiles:
     welcome.html:
@@ -467,4 +312,17 @@ hub:
       stringData: |-
 {%- raw %}
         {% extends "login.html" %}
+{% endraw %}
+    403.html:
+      mountPath: /usr/local/share/jupyterhub/templates/403.html
+      stringData: |-
+{%- raw %}
+        {% extends "error.html" %}
+        {% block main %}
+        <div class="error">
+          <h1>Unauthorized</h1>
+          <p>You don't have the correct entitlements to access this service.</p>
+          <p>If you think you should be granted access, please open an issue!</p>
+        </div>
+        {% endblock %}
 {% endraw %}
-- 
GitLab