Repository: CSCfi/swift-browser-ui

Issue:
Support project isolation for token
#### Proposed solution
<!-- A clear and concise description of what you want to happen. -->
There should be a possibility to limit project scope when necessary. Some services will require this due to regulation.

#### DoD (Definition of Done)
<!-- How to know this is implemented. Preferably one short sentence. -->
Project scoping can be configured to limit scope to a single project per session, when necessary.

#### Testing
<!-- How can someone else verify the task, can be test-suite or something else. -->
UI tests can be written if necessary.


Discussion:

Patch:
diff --git a/.github/config/.finnishwords.txt b/.github/config/.finnishwords.txt
index 3629ed43..397cc63b 100644
--- a/.github/config/.finnishwords.txt
+++ b/.github/config/.finnishwords.txt
@@ -1,5 +1,7 @@
+
 ajaksi
 alapuolisesta
+alla
 aloitettiin
 annetut
 apin
@@ -24,6 +26,7 @@ erillinen
 erissä
 esimerkiksi
 esitetty
+estetty
 etsi
 etsittyä
 että
@@ -32,6 +35,7 @@ etusivun
 hakea
 haku
 hakuindeksi
+haluat
 haluatko
 halutako
 haluttu
@@ -65,9 +69,11 @@ johtaa
 johtua
 jonka
 jos
+jota
 julkinen
 julkiset
 julkisia
+kaikki
 kansio
 kansioille
 kansioina
@@ -86,6 +92,7 @@ käyttäjän
 käyttäjänimestä
 käyttöä
 käyttöliittymä
+käyttöliittymän
 käyttörajoista
 kehittänyt
 kelpaa
@@ -96,6 +103,8 @@ keskus
 kestää
 kiellettyä
 kirjaudu
+kirjautuaksesi
+kirjautumalla
 kirjautumisen
 kirjoitus
 kirjoitusoikeus
@@ -162,6 +171,7 @@ mikä
 mikäli
 minuutin
 mitätöi
+muiden
 muita
 mukaan
 muokataan
@@ -172,7 +182,9 @@ muussa
 muutaman
 muuttaa
 muuttaaksesi
+näkyvissä
 nämä
+nappia
 navigointia
 näytä
 näytetään
@@ -205,6 +217,7 @@ omistavan
 ongelmiin
 onnistui
 onnistunut
+onnistuu
 operaatio
 operaation
 osoite
@@ -237,13 +250,18 @@ polku
 postamista
 poudan
 projekteihin
+projekteja
 projekti
+projektia
+projektien
+projektien
 projektilla
 projektille
 projektilta
 projektin
 projektissa
 projektista
+projektit
 projektitunnisteet
 projektitunnisteille
 pudota
@@ -253,6 +271,13 @@ pyydetty
 pyydetyn
 pyyntö
 rajan
+rajattu
+rajatun
+rajoitettua
+rajoitettuihin
+rajoittamattomat
+rajoittamattomia
+rajoittamattomien
 rajoitteiden
 resurssienkäyttö
 resurssit
@@ -274,8 +299,12 @@ salausratkaisun
 salli
 sallittu
 selaamiseen
+selailla
+selailu
 selain
 selainta
+selatessa
+siirto
 sisään
 sisäänkirjauksen
 sisällön
@@ -336,6 +365,7 @@ tilan
 tilankäyttö
 tilapäinen
 tilapäisesti
+toiseen
 toteuttaa
 tueta
 tuettu
@@ -358,8 +388,10 @@ uusi
 uusia
 vaatii
 vähintään
+vaihto
 väliaikaiseen
 valikossa
+valinnan
 valinnat
 valitse
 valittu
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a588edb..94ec91a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- (GH #468) add support for project isolation for projects marked as restricted
+    - discovery for restricted projects pending, implemented logic only for now
+    - foced restricted mode can be configured on
+
+
 ## [v2.0.0]
 
 ### Added
diff --git a/swift_browser_ui/ui/api.py b/swift_browser_ui/ui/api.py
index 333bb818..a5402619 100644
--- a/swift_browser_ui/ui/api.py
+++ b/swift_browser_ui/ui/api.py
@@ -37,7 +37,14 @@ async def os_list_projects(request: aiohttp.web.Request) -> aiohttp.web.Response
     )
     # Filter out the tokens contained in session token
     return aiohttp.web.json_response(
-        [{"name": v["name"], "id": v["id"]} for _, v in session["projects"].items()]
+        [
+            {
+                "name": v["name"],
+                "id": v["id"],
+                "tainted": v["tainted"],
+            }
+            for _, v in session["projects"].items()
+        ]
     )
 
 
diff --git a/swift_browser_ui/ui/front.py b/swift_browser_ui/ui/front.py
index 67342312..ce7ee52f 100644
--- a/swift_browser_ui/ui/front.py
+++ b/swift_browser_ui/ui/front.py
@@ -22,6 +22,18 @@ async def browse(_: aiohttp.web.Request) -> aiohttp.web.FileResponse:
     )
 
 
+async def select(_: aiohttp.web.Request) -> aiohttp.web.FileResponse:
+    """Serve a project selection for users with tainted projects."""
+    return aiohttp.web.FileResponse(
+        str(setd["static_directory"]) + "/select.html",
+        headers={
+            "Cache-Control": "no-cache, no-store, must-revalidate",
+            "Pragma": "no-cache",
+            "Expires": "0",
+        },
+    )
+
+
 async def index(
     request: typing.Optional[aiohttp.web.Request],
 ) -> typing.Union[aiohttp.web.Response, aiohttp.web.FileResponse]:
diff --git a/swift_browser_ui/ui/login.py b/swift_browser_ui/ui/login.py
index 2bea8d7d..58726639 100644
--- a/swift_browser_ui/ui/login.py
+++ b/swift_browser_ui/ui/login.py
@@ -186,6 +186,8 @@ async def login_with_token(
     session["referer"] = request.url.host
     uname = ""
 
+    taint = True if setd["force_restricted_mode"] else False
+
     # Check token availability
     avail = await get_availability_from_token(token, client)
     session["projects"] = {}
@@ -242,12 +244,23 @@ async def login_with_token(
                 "name": project["name"],
                 "endpoint": endpoint["url"],
                 "token": scoped,
+                "tainted": True if setd["force_restricted_mode"] else False,
             }
 
     session["token"] = token
     session["uname"] = uname
 
+    if taint:
+        session["taint"] = True
+    else:
+        session["taint"] = False
+
     session.changed()
+
+    if taint:
+        response.headers["Location"] = "/select"
+        return response
+
     # Redirect to the browse page
     if "NAV_TO" in request.cookies.keys():
         response.headers["Location"] = request.cookies["NAV_TO"]
@@ -258,6 +271,50 @@ async def login_with_token(
     return response
 
 
+async def handle_project_lock(request: aiohttp.web.Request) -> aiohttp.web.Response:
+    """Lock down to a specific project."""
+    log = request.app["Log"]
+    log.info("Call for locking down the project.")
+
+    session = await aiohttp_session.get_session(request)
+
+    project = request.match_info["project"]
+
+    # Ditch all projects that aren't the one specified if project is defined
+    if project in session["projects"]:
+        session["projects"] = {
+            k: v
+            for k, v in filter(
+                lambda val: val[0] == project,
+                session["projects"].items(),
+            )
+        }
+    # If the project doesn't exist, allow all untainted projects
+    else:
+        session["projects"] = {
+            k: v
+            for k, v in filter(
+                lambda val: not val[1]["tainted"], session["projects"].items()
+            )
+        }
+
+    if not session["projects"]:
+        session.invalidate()
+        raise aiohttp.web.HTTPForbidden(reason="No untainted projects available.")
+
+    # The session is no longer tainted if it's been locked
+    session["taint"] = False
+
+    session.changed()
+    return aiohttp.web.Response(
+        status=303,
+        body=None,
+        headers={
+            "Location": "/browse",
+        },
+    )
+
+
 async def handle_logout(request: aiohttp.web.Request) -> aiohttp.web.Response:
     """Properly kill the session for the user."""
     log = request.app["Log"]
diff --git a/swift_browser_ui/ui/middlewares.py b/swift_browser_ui/ui/middlewares.py
index dbb2ba56..8b17ac90 100644
--- a/swift_browser_ui/ui/middlewares.py
+++ b/swift_browser_ui/ui/middlewares.py
@@ -9,6 +9,7 @@ import aiohttp_session
 
 from swift_browser_ui.ui.settings import setd
 
+
 AiohttpHandler = typing.Callable[
     [web.Request], typing.Coroutine[typing.Awaitable, typing.Any, web.Response]
 ]
@@ -46,6 +47,30 @@ async def check_session_at(
     return await handler(request)
 
 
+@web.middleware
+async def check_session_taintness(
+    request: web.Request,
+    handler: AiohttpHandler,
+) -> web.StreamResponse:
+    """Override tainted sessions with project selection until scoped."""
+    session = await aiohttp_session.get_session(request)
+    if (
+        "taint" in session
+        and session["taint"]
+        and "select" not in request.path
+        and "projects" not in request.path
+        and "lock" not in request.path
+        and "static" not in request.path
+    ):
+        return web.Response(
+            status=303,
+            headers={
+                "Location": "/select",
+            },
+        )
+    return await handler(request)
+
+
 @web.middleware
 async def error_middleware(request: web.Request, handler: AiohttpHandler) -> web.Response:
     """Return the correct HTTP Error page."""
diff --git a/swift_browser_ui/ui/server.py b/swift_browser_ui/ui/server.py
index 6b549fa7..5e69e311 100644
--- a/swift_browser_ui/ui/server.py
+++ b/swift_browser_ui/ui/server.py
@@ -20,13 +20,14 @@ import aiohttp_session.redis_storage
 
 import aioredis
 
-from swift_browser_ui.ui.front import index, browse, loginpassword
+from swift_browser_ui.ui.front import index, browse, loginpassword, select
 from swift_browser_ui.ui.login import (
     handle_login,
     handle_logout,
     sso_query_begin,
     sso_query_end,
     credentials_login_end,
+    handle_project_lock,
 )
 from swift_browser_ui.ui.api import (
     swift_get_metadata_container,
@@ -82,6 +83,7 @@ async def servinit(
     middlewares = [
         swift_browser_ui.ui.middlewares.error_middleware,
         swift_browser_ui.ui.middlewares.check_session_at,
+        swift_browser_ui.ui.middlewares.check_session_taintness,
     ]
     if inject_middleware:
         middlewares = middlewares + inject_middleware
@@ -136,6 +138,14 @@ async def servinit(
             # Route all URLs prefixed by /browse to the browser page, as this is
             # an spa
             aiohttp.web.get("/browse/{tail:.*}", browse),
+            aiohttp.web.get("/select", select),
+        ]
+    )
+
+    # Add lock routes
+    app.add_routes(
+        [
+            aiohttp.web.get("/lock/{project}", handle_project_lock),
         ]
     )
 
diff --git a/swift_browser_ui/ui/settings.py b/swift_browser_ui/ui/settings.py
index 4596d234..57bbdc0e 100644
--- a/swift_browser_ui/ui/settings.py
+++ b/swift_browser_ui/ui/settings.py
@@ -70,6 +70,7 @@ setd: Dict[str, Union[str, int, None]] = {
     "has_trust": environ.get("BROWSER_START_HAS_TRUST", False),
     "set_origin_address": environ.get("BROWSER_START_SET_ORIGIN_ADDRESS", None),
     "os_user_domain": environ.get("OS_USER_DOMAIN_NAME", "Default"),
+    "force_restricted_mode": environ.get("SWIFT_UI_FORCE_RESTRICTED_MODE", False),
     "logfile": None,
     "port": 8080,
     "verbose": False,
diff --git a/swift_browser_ui_frontend/src/common/lang.js b/swift_browser_ui_frontend/src/common/lang.js
index e908adc8..bfc44ff8 100644
--- a/swift_browser_ui_frontend/src/common/lang.js
+++ b/swift_browser_ui_frontend/src/common/lang.js
@@ -300,6 +300,20 @@ let default_translations = {
         buildingIndex: "This project has a large number of objects. Please, " +
                        "wait while the search index is ready, and try again.",
       },
+      select: {
+        heading: "Select project for logging in",
+        description: "The user account used for logging in contains " +
+          "projects flagged as restricted. The interface scope is limited " +
+          "when a restricted project is opened, i.e. only the restricted " +
+          "project is visible during a restricted session. This means you " +
+          "cannot copy or move items across projects or view items in " +
+          "other projects available to you. Select a project you want to " +
+          "use from the following listing, after selection you will not be " +
+          "able to change the project without logging out. If you want to " +
+          "browse unrestricted projects, use the unrestricted projects " +
+          "button below.",
+        unrestricted: "All unrestricted projects",
+      },
     },
   },
   fi: {
@@ -594,6 +608,19 @@ let default_translations = {
         buildingIndex: "Tässä projektissa on suuri määrä kohteita. Odota, " +
                        "kunnes hakuindeksi on valmis, ja yritä uudelleen.",
       },
+      select: {
+        heading: "Valitse projekti kirjautuaksesi sisään",
+        description: "Käyttäjällä on pääsy rajoitettuihin projekteihin. " +
+          "Selatessa rajoitettua projektia käyttöliittymän pääsy on " +
+          "rajattu, eli vain rajatun projektin sisältö on näkyvissä. " +
+          "Tiedostojen kopiointi ja siirto projektista toiseen, ja " +
+          "muiden projektien sisällön selailu on estetty. Valitse " +
+          "projekti, jota haluat käyttää. Valinnan jälkeen projektin " +
+          "vaihto onnistuu vain kirjautumalla ulos. Mikäli haluat " + 
+          "selailla vain rajoittamattomia projekteja, paina " +
+          "rajoittamattomien projektien nappia alla.",
+        unrestricted: "Kaikki rajoittamattomat projektit",
+      },
     },
   },
 };
diff --git a/swift_browser_ui_frontend/src/entries/select.js b/swift_browser_ui_frontend/src/entries/select.js
new file mode 100644
index 00000000..4c1d7e6c
--- /dev/null
+++ b/swift_browser_ui_frontend/src/entries/select.js
@@ -0,0 +1,51 @@
+import Vue from "vue";
+import App from "@/pages/SelectPage.vue";
+import Buefy from "buefy";
+
+import getLangCookie from "@/common/conv";
+import translations from "@/common/lang";
+
+import { getProjects } from "@/common/api";
+
+// Import project css
+import "@/css/prod.scss";
+
+import VueI18n from "vue-i18n";
+
+Vue.use(VueI18n);
+Vue.use(Buefy);
+
+
+const i18n = new VueI18n({
+  locale: getLangCookie(),
+  messages: translations,
+});
+
+new Vue ({
+  i18n,
+  data: {
+    formname: "Token id:",
+    loginformname: "Openstack account:",
+    idb: true,
+    projects: [],
+    langs: [{ph: "In English", value: "en"}, {ph: "Suomeksi", value: "fi"}],
+  },
+  created() {
+    document.title = this.$t("message.program_name");
+    this.updateProjects();
+  },
+  methods: {
+    "updateProjects": function () {
+      getProjects().then(ret => this.projects = ret);
+    },
+    setCookieLang: function() {
+      const expiryDate = new Date();
+      expiryDate.setMonth(expiryDate.getMonth() + 1);
+      document.cookie = "OBJ_UI_LANG=" +
+                        i18n.locale +
+                        "; path=/; expires="
+                        + expiryDate.toUTCString();
+    },
+  },
+  ...App,
+}).$mount("#app");
diff --git a/swift_browser_ui_frontend/src/pages/SelectPage.vue b/swift_browser_ui_frontend/src/pages/SelectPage.vue
new file mode 100644
index 00000000..939bbbcf
--- /dev/null
+++ b/swift_browser_ui_frontend/src/pages/SelectPage.vue
@@ -0,0 +1,73 @@
+<template>
+  <div class="selectpage">
+    <div class="content has-text-centered">
+      <h2>{{ $t("message.select.heading" ) }}</h2>
+      <p class="maintext">
+        {{ $t("message.select.description") }}
+      </p>
+      <p
+        v-for="project in projects.filter(pro => pro.tainted)"
+        :key="project.id"
+      >
+        <b-button
+          tag="a"
+          expanded
+          :href="'/lock/'.concat(project.id)"
+          size="is-large"
+          type="is-primary is-outlined"
+        >
+          {{ project.name }}
+        </b-button>
+      </p>
+      <p>
+        <b-button
+          tag="a"
+          expanded
+          href="/lock/none"
+          size="is-large"
+          type="is-primary is-outlined"
+        >
+          {{ $t("message.select.unrestricted" ) }}
+        </b-button>
+      </p>
+      <p>
+        <b-field class="locale-changed block center">
+          <b-select
+            v-model="$i18n.locale"
+            placeholder="Language"
+            icon="earth"
+            expanded
+            @input="setCookieLang ()"
+          >
+            <option
+              v-for="lang in langs"
+              :key="lang.value"
+              :value="lang.value"
+            >
+              {{ lang.ph }}
+            </option>
+          </b-select>
+        </b-field>
+      </p>
+    </div>
+  </div>
+</template>
+
+<style>
+html, body {
+  height: 100%;
+}
+.selectpage {
+  width: 40%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  margin: auto;
+  align-content: center;
+  justify-content: center;;
+}
+.center {
+  width: 50%;
+  margin: auto;
+}
+</style>
diff --git a/swift_browser_ui_frontend/vue.config.js b/swift_browser_ui_frontend/vue.config.js
index 0ba8009b..0eaeed64 100644
--- a/swift_browser_ui_frontend/vue.config.js
+++ b/swift_browser_ui_frontend/vue.config.js
@@ -17,6 +17,13 @@ module.exports = {  // eslint-disable-line
       title: "Swift browser UI",
       chunks: ["chunk-vendors", "chunk-common", "index"],
     },
+    select: {
+      entry: "src/entries/select.js",
+      template: "public/select.html",
+      filename: "select.html",
+      title: "Select a project to isolate",
+      chunks: ["chunk-vendors", "chunk-common", "select"],
+    },
     badrequest: {
       entry: "src/entries/badrequest.js",
       template: "public/index.html",


Test patch:
diff --git a/tests/common/mockups.py b/tests/common/mockups.py
index f2f76f7b..e3177d45 100644
--- a/tests/common/mockups.py
+++ b/tests/common/mockups.py
@@ -32,12 +32,14 @@ class APITestBase(unittest.IsolatedAsyncioTestCase):
                 "name": "test-name-0",
                 "token": "test-token-0",
                 "endpoint": "https://test-endpoint-0/v1/AUTH_test-id-0",
+                "tainted": False,
             },
             "test-id-1": {
                 "id": "test-id-1",
                 "name": "test-name-1",
                 "token": "test-token-1",
                 "endpoint": "https://test-endpoint-1/v1/AUTH_test-id-1",
+                "tainted": False,
             },
         }
         self.aiohttp_session_get_session_mock = unittest.mock.AsyncMock()
diff --git a/tests/ui_unit/test_api.py b/tests/ui_unit/test_api.py
index 1b01ad52..8423e3bb 100644
--- a/tests/ui_unit/test_api.py
+++ b/tests/ui_unit/test_api.py
@@ -35,10 +35,12 @@ class APITestClass(tests.common.mockups.APITestBase):
                 {
                     "id": "test-id-0",
                     "name": "test-name-0",
+                    "tainted": False,
                 },
                 {
                     "id": "test-id-1",
                     "name": "test-name-1",
+                    "tainted": False,
                 },
             ]
         )
diff --git a/tests/ui_unit/test_login.py b/tests/ui_unit/test_login.py
index 3ffda3b2..d1f8fa1d 100644
--- a/tests/ui_unit/test_login.py
+++ b/tests/ui_unit/test_login.py
@@ -197,6 +197,7 @@ class LoginTestClass(tests.common.mockups.APITestBase):
         """
         self.setd_mock["session_lifetime"] = 28800
         self.setd_mock["history_lifetime"] = 2592000
+        self.setd_mock["force_restricted_mode"] = False
         self.setd_mock["swift_endpoint_url"] = ("http://obj.exampleosep.com:443/v1",)
         patch1 = unittest.mock.patch(
             "swift_browser_ui.ui.login.setd",