From 02d3fdcb22e494f086aeb5df56efc221a185d7e3 Mon Sep 17 00:00:00 2001
From: Xiao Gui <xgui3783@gmail.com>
Date: Mon, 21 Dec 2020 14:57:23 +0100
Subject: [PATCH] feat: added user permission to plugin csp policy

---
 common/util.js                                |  32 ++
 deploy/app.js                                 |  69 +--
 deploy/csp/index.js                           | 121 +++--
 deploy/csp/index.spec.js                      | 109 +++++
 deploy/lruStore/index.js                      |  14 +-
 deploy/package.json                           |   2 +-
 deploy/plugins/index.js                       |  29 ++
 deploy/user/index.js                          |  43 ++
 deploy/user/index.spec.js                     | 192 ++++++++
 docs/releases/v2.4.0.md                       |   9 +
 mkdocs.yml                                    |   1 +
 package.json                                  |   4 +-
 .../atlasViewer.pluginService.service.spec.ts | 425 +++++++++++++-----
 .../atlasViewer.pluginService.service.ts      | 320 ++++++++-----
 .../confirmDialog.component.spec.ts           |  54 +++
 .../confirmDialog/confirmDialog.component.ts  |   8 +-
 .../confirmDialog/confirmDialog.template.html |  13 +-
 src/services/dialogService.service.ts         |   1 +
 src/services/effect/pluginUseEffect.spec.ts   |   3 +-
 src/services/effect/pluginUseEffect.ts        |   3 +-
 src/services/state/pluginState.helper.ts      |   5 +
 src/services/state/pluginState.store.ts       |   6 +-
 .../state/userConfigState.helper.spec.ts      |  47 ++
 src/services/state/userConfigState.helper.ts  |  11 +
 .../state/userConfigState.store.spec.ts       |  81 ++++
 src/services/state/userConfigState.store.ts   | 160 ++++---
 src/services/state/viewerConfig.store.ts      |   3 +-
 src/services/state/viewerState/actions.ts     |   5 +
 src/services/stateStore.service.ts            |  45 +-
 src/ui/config/config.template.html            |   5 +
 .../config/pluginCsp/pluginCsp.component.ts   |  32 ++
 src/ui/config/pluginCsp/pluginCsp.style.css   |   0
 .../config/pluginCsp/pluginCsp.template.html  |  52 +++
 .../nehubaContainer.component.spec.ts         |   4 +-
 src/ui/ui.module.ts                           |   2 +
 src/util/fn.ts                                |  20 +
 src/util/pipes/objToArray.pipe.spec.ts        |  17 +
 src/util/pipes/objToArray.pipe.ts             |  22 +
 src/util/util.module.ts                       |   3 +
 39 files changed, 1550 insertions(+), 422 deletions(-)
 create mode 100644 deploy/csp/index.spec.js
 create mode 100644 deploy/user/index.spec.js
 create mode 100644 docs/releases/v2.4.0.md
 create mode 100644 src/components/confirmDialog/confirmDialog.component.spec.ts
 create mode 100644 src/services/state/pluginState.helper.ts
 create mode 100644 src/services/state/userConfigState.helper.spec.ts
 create mode 100644 src/services/state/userConfigState.helper.ts
 create mode 100644 src/services/state/userConfigState.store.spec.ts
 create mode 100644 src/ui/config/pluginCsp/pluginCsp.component.ts
 create mode 100644 src/ui/config/pluginCsp/pluginCsp.style.css
 create mode 100644 src/ui/config/pluginCsp/pluginCsp.template.html
 create mode 100644 src/util/pipes/objToArray.pipe.spec.ts
 create mode 100644 src/util/pipes/objToArray.pipe.ts

diff --git a/common/util.js b/common/util.js
index 118d3079a..4029c6744 100644
--- a/common/util.js
+++ b/common/util.js
@@ -172,4 +172,36 @@
       )
     )
   }
+
+  exports.serialiseParcellationRegion = ({ ngId, labelIndex }) => {
+    if (!ngId) {
+      throw new Error(`#serialiseParcellationRegion error: ngId must be defined`)
+    }
+
+    if (!labelIndex) {
+      throw new Error(`#serialiseParcellationRegion error labelIndex must be defined`)
+    }
+
+    return `${ngId}#${labelIndex}`
+  }
+
+  const deserialiseParcRegionId = labelIndexId => {
+    const _ = labelIndexId && labelIndexId.split && labelIndexId.split('#') || []
+    const ngId = _.length > 1
+      ? _[0]
+      : null
+    const labelIndex = _.length > 1
+      ? Number(_[1])
+      : _.length === 0
+        ? null
+        : Number(_[0])
+    return { labelIndex, ngId }
+  }
+
+  exports.deserialiseParcRegionId = deserialiseParcRegionId
+
+  exports.deserialiseParcellationRegion = ({ region, labelIndexId, inheritedNgId = 'root' }) => {
+    const { labelIndex, ngId } = deserialiseParcRegionId(labelIndexId)
+  }
+
 })(typeof exports === 'undefined' ? module.exports : exports)
diff --git a/deploy/app.js b/deploy/app.js
index d83356e4e..420720e79 100644
--- a/deploy/app.js
+++ b/deploy/app.js
@@ -7,6 +7,40 @@ const MemoryStore = require('memorystore')(session)
 const crypto = require('crypto')
 const cookieParser = require('cookie-parser')
 
+/**
+ * memorystore (or perhaps lru-cache itself) does not properly close when server shuts
+ * this causes problems during tests
+ * So when testing app.js, set USE_DEFAULT_MEMORY_STORE to true
+ * see app.spec.js
+ */
+const { USE_DEFAULT_MEMORY_STORE } = process.env
+const store = USE_DEFAULT_MEMORY_STORE
+  ? (console.warn(`USE_DEFAULT_MEMORY_STORE is set to true, memleak expected. Do NOT use in prod.`), null)
+  : new MemoryStore({
+      checkPeriod: 86400000
+    })
+
+const SESSIONSECRET = process.env.SESSIONSECRET || 'this is not really a random session secret'
+
+/**
+ * passport application of oidc requires session
+ */
+app.use(session({
+  secret: SESSIONSECRET,
+  resave: true,
+  saveUninitialized: true,
+  store,
+}))
+
+/**
+ * configure CSP
+ */
+if (process.env.DISABLE_CSP && process.env.DISABLE_CSP === 'true') {
+  console.warn(`DISABLE_CSP is set to true, csp will not be enabled`)
+} else {
+  require('./csp')(app)
+}
+
 const { router: regionalFeaturesRouter, regionalFeatureIsReady } = require('./regionalFeatures')
 const { router: datasetRouter, ready: datasetRouteIsReady } = require('./datasets')
 
@@ -46,46 +80,13 @@ app.use((req, _, next) => {
 
 const { configureAuth, ready: authReady } = require('./auth')
 
-/**
- * memorystore (or perhaps lru-cache itself) does not properly close when server shuts
- * this causes problems during tests
- * So when testing app.js, set USE_DEFAULT_MEMORY_STORE to true
- * see app.spec.js
- */
-const { USE_DEFAULT_MEMORY_STORE } = process.env
-const store = USE_DEFAULT_MEMORY_STORE
-  ? (console.warn(`USE_DEFAULT_MEMORY_STORE is set to true, memleak expected. Do NOT use in prod.`), null)
-  : new MemoryStore({
-      checkPeriod: 86400000
-    })
-
-const SESSIONSECRET = process.env.SESSIONSECRET || 'this is not really a random session secret'
-
-/**
- * passport application of oidc requires session
- */
-app.use(session({
-  secret: SESSIONSECRET,
-  resave: true,
-  saveUninitialized: false,
-  store
-}))
-
-/**
- * configure CSP
- */
-if (process.env.DISABLE_CSP && process.env.DISABLE_CSP === 'true') {
-  console.warn(`DISABLE_CSP is set to true, csp will not be enabled`)
-} else {
-  require('./csp')(app)
-}
 
 /**
  * configure Auth
  * async function, but can start server without
  */
 
-(async () => {
+const _ = (async () => {
   await configureAuth(app)
   app.use('/user', require('./user'))
 })()
diff --git a/deploy/csp/index.js b/deploy/csp/index.js
index 1b702ad03..8f664fae9 100644
--- a/deploy/csp/index.js
+++ b/deploy/csp/index.js
@@ -55,52 +55,81 @@ module.exports = (app) => {
     next()
   })
 
-  app.use(csp({
-    directives: {
-      defaultSrc: [
-        ...defaultAllowedSites,
-        ...WHITE_LIST_SRC
-      ],
-      styleSrc: [
-        ...defaultAllowedSites,
-        'stackpath.bootstrapcdn.com/bootstrap/4.3.1/',
-        'use.fontawesome.com/releases/v5.8.1/',
-        "'unsafe-inline'", // required for angular [style.xxx] bindings
-        ...WHITE_LIST_SRC
-      ],
-      fontSrc: [
-        "'self'",
-        'use.fontawesome.com/releases/v5.8.1/',
-        ...WHITE_LIST_SRC
-      ],
-      connectSrc: [
-        ...defaultAllowedSites,
-        ...connectSrc,
-        ...WHITE_LIST_SRC
-      ],
-      imgSrc: [
-        "'self'",
-        "hbp-kg-dataset-previewer.apps.hbp.eu/v2/"
-      ],
-      scriptSrc:[
-        "'self'",
-        'code.jquery.com', // plugin load external library -> jquery v2 and v3
-        'cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/', // plugin load external library -> web components
-        'cdnjs.cloudflare.com/ajax/libs/d3/', // plugin load external lib -> d3
-        'cdn.jsdelivr.net/npm/vue@2.5.16/', // plugin load external lib -> vue 2
-        'cdn.jsdelivr.net/npm/preact@8.4.2/', // plugin load external lib -> preact
-        'unpkg.com/react@16/umd/', // plugin load external lib -> react
-        'unpkg.com/kg-dataset-previewer@1.1.5/', // preview component
-        'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax
-        (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null,
-        ...SCRIPT_SRC,
-        ...WHITE_LIST_SRC,
-        ...defaultAllowedSites
-      ],
-      reportUri: CSP_REPORT_URI || '/report-violation'
-    },
-    reportOnly
-  }))
+  app.use((req, res, next) => {
+    const permittedCsp = (req.session && req.session.permittedCsp) || {}
+    const userConnectSrc = []
+    const userScriptSrc = []
+    for (const key in permittedCsp) {
+      userConnectSrc.push(
+        ...(permittedCsp[key]['connect-src'] || []),
+        ...(permittedCsp[key]['connectSrc'] || [])
+      )
+      userScriptSrc.push(
+        ...(permittedCsp[key]['script-src'] || []),
+        ...(permittedCsp[key]['scriptSrc'] || [])
+      )
+    }
+    res.locals.userCsp = {
+      userConnectSrc,
+      userScriptSrc,
+    }
+    next()
+  })
+
+  app.use((req, res, next) => {
+    const {
+      userConnectSrc = [],
+      userScriptSrc = [],
+    } =  res.locals.userCsp || {}
+    csp({
+      directives: {
+        defaultSrc: [
+          ...defaultAllowedSites,
+          ...WHITE_LIST_SRC
+        ],
+        styleSrc: [
+          ...defaultAllowedSites,
+          'stackpath.bootstrapcdn.com/bootstrap/4.3.1/',
+          'use.fontawesome.com/releases/v5.8.1/',
+          "'unsafe-inline'", // required for angular [style.xxx] bindings
+          ...WHITE_LIST_SRC
+        ],
+        fontSrc: [
+          "'self'",
+          'use.fontawesome.com/releases/v5.8.1/',
+          ...WHITE_LIST_SRC
+        ],
+        connectSrc: [
+          ...userConnectSrc,
+          ...defaultAllowedSites,
+          ...connectSrc,
+          ...WHITE_LIST_SRC
+        ],
+        imgSrc: [
+          "'self'",
+          "hbp-kg-dataset-previewer.apps.hbp.eu/v2/"
+        ],
+        scriptSrc:[
+          "'self'",
+          ...userScriptSrc,
+          'code.jquery.com', // plugin load external library -> jquery v2 and v3
+          'cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/', // plugin load external library -> web components
+          'cdnjs.cloudflare.com/ajax/libs/d3/', // plugin load external lib -> d3
+          'cdn.jsdelivr.net/npm/vue@2.5.16/', // plugin load external lib -> vue 2
+          'cdn.jsdelivr.net/npm/preact@8.4.2/', // plugin load external lib -> preact
+          'unpkg.com/react@16/umd/', // plugin load external lib -> react
+          'unpkg.com/kg-dataset-previewer@1.1.5/', // preview component
+          'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax
+          (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null,
+          ...SCRIPT_SRC,
+          ...WHITE_LIST_SRC,
+          ...defaultAllowedSites
+        ],
+        reportUri: CSP_REPORT_URI || '/report-violation'
+      },
+      reportOnly
+    })(req, res, next)
+  })
 
   if (!CSP_REPORT_URI) {
     app.post('/report-violation', bodyParser.json({
diff --git a/deploy/csp/index.spec.js b/deploy/csp/index.spec.js
new file mode 100644
index 000000000..70b4e1442
--- /dev/null
+++ b/deploy/csp/index.spec.js
@@ -0,0 +1,109 @@
+const express = require('express')
+const app = express()
+const csp = require('./index')
+const got = require('got')
+const { expect, assert } = require('chai')
+
+const checkBaseFn = async (rules = []) => {
+
+  const resp = await got(`http://localhost:1234/`)
+  const stringifiedHeader = JSON.stringify(resp.headers)
+
+  /**
+   * expect stats.humanbrainproject.eu and neuroglancer.humanbrainproject.eu to be present
+   */
+  assert(
+    /stats\.humanbrainproject\.eu/.test(stringifiedHeader),
+    'stats.humanbrainproject.eu present in header'
+  )
+
+  assert(
+    /neuroglancer\.humanbrainproject\.eu/.test(stringifiedHeader),
+    'neuroglancer.humanbrainproject.eu present in header'
+  )
+
+  assert(
+    /content-security-policy/.test(stringifiedHeader),
+    'content-security-policy present in header'
+  )
+
+  for (const rule of rules) {
+    assert(
+      rule.test(stringifiedHeader),
+      `${rule.toString()} present in header`
+    )
+  }
+}
+
+describe('> csp/index.js', () => {
+  let server, permittedCsp
+  const middleware = (req, res, next) => {
+    if (!!permittedCsp) {
+      req.session = { permittedCsp }
+    }
+    next()
+  }
+  before(done => {
+    app.use(middleware)
+    csp(app)
+    app.get('/', (req, res) => {
+      res.status(200).send('OK')
+    })
+    server = app.listen(1234, () => console.log(`app listening`))
+    setTimeout(() => {
+      done()
+    }, 1000);
+  })
+
+  it('> should work when session is unset', async () => {
+    await checkBaseFn()
+  })
+
+  describe('> if session and permittedCsp are both set', () => {
+    describe('> if permittedCsp is malformed', () => {
+      describe('> if permittedCsp is set to string', () => {
+        before(() => {
+          permittedCsp = 'hello world'
+        })
+        it('> base csp should work', async () => {
+          await checkBaseFn()
+        })
+      })
+
+      describe('> if permittedCsp is number', () => {
+        before(() => {
+          permittedCsp = 420
+        })
+        it('> base csp should work', async () => {
+          await checkBaseFn()
+        })
+      })
+    })
+  
+    describe('> if premittedCsp defines', () => {
+
+      before(() => {
+        permittedCsp = {
+          'foo-bar': {
+            'connect-src': [
+              'connect.int.dev'
+            ],
+            'script-src': [
+              'script.int.dev'
+            ]
+          }
+        }
+      })
+      
+      it('> csp should include permittedCsp should work', async () => {
+        await checkBaseFn([
+          /connect\.int\.dev/,
+          /script\.int\.dev/,
+        ])
+      })
+    })
+  })
+  after(done => {
+    server.close(done)
+  })
+})
\ No newline at end of file
diff --git a/deploy/lruStore/index.js b/deploy/lruStore/index.js
index e63e2e139..564b89566 100644
--- a/deploy/lruStore/index.js
+++ b/deploy/lruStore/index.js
@@ -58,11 +58,14 @@ if (redisURL) {
 
   const keys = []
 
+  /**
+   * maxage in milli seconds
+   */
   exports.store = {
-    set: async (key, val) => {
+    set: async (key, val, { maxAge } = {}) => {
       ensureString(key)
       ensureString(val)
-      asyncSet(key, val)
+      asyncSet(key, val, ...( maxAge ? [ 'PX', maxAge ] : [] ))
       keys.push(key)
     },
     get: async (key) => {
@@ -90,10 +93,13 @@ if (redisURL) {
   })
 
   exports.store = {
-    set: async (key, val) => {
+    /**
+     * maxage in milli seconds
+     */
+    set: async (key, val, { maxAge } = {}) => {
       ensureString(key)
       ensureString(val)
-      store.set(key, val)
+      store.set(key, val, ...( maxAge ? [ maxAge ] : [] ))
     },
     get: async (key) => {
       ensureString(key)
diff --git a/deploy/package.json b/deploy/package.json
index 26715ffd6..39df6e6fc 100644
--- a/deploy/package.json
+++ b/deploy/package.json
@@ -18,6 +18,7 @@
     "express": "^4.16.4",
     "express-rate-limit": "^5.1.1",
     "express-session": "^1.15.6",
+    "got": "^10.5.5",
     "hbp-seafile": "0.0.6",
     "helmet-csp": "^2.8.0",
     "jwt-decode": "^2.2.0",
@@ -39,7 +40,6 @@
     "cors": "^2.8.5",
     "dotenv": "^6.2.0",
     "google-spreadsheet": "^3.0.13",
-    "got": "^10.5.5",
     "mocha": "^6.1.4",
     "nock": "^12.0.3",
     "sinon": "^8.0.2"
diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js
index b9e0e92d5..cd829c98d 100644
--- a/deploy/plugins/index.js
+++ b/deploy/plugins/index.js
@@ -4,6 +4,8 @@
  */
 
 const express = require('express')
+const { store } = require('../lruStore')
+const got = require('got')
 const router = express.Router()
 const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) || []
 const STAGING_PLUGIN_URLS = (process.env.STAGING_PLUGIN_URLS && process.env.STAGING_PLUGIN_URLS.split(';')) || []
@@ -15,4 +17,31 @@ router.get('', (_req, res) => {
   ])
 })
 
+const getKey = url => `plugin:manifest-cache:${url}}`
+
+router.get('/manifests', async (_req, res) => {
+
+  const allManifests = await Promise.all([
+    ...PLUGIN_URLS,
+    ...STAGING_PLUGIN_URLS
+  ].map(async url => {
+    const key = getKey(url)
+    try {
+      const storedManifest = await store.get(key)
+      if (storedManifest) return JSON.parse(storedManifest)
+      else throw `not found`
+    } catch (e) {
+      const resp = await got(url)
+      const json = JSON.parse(resp.body)
+      
+      await store.set(key, JSON.stringify(json), { maxAge: 1000 * 60 * 60 })
+      return json
+    }
+  }))
+
+  res.status(200).json(
+    allManifests.filter(v => !!v)
+  )
+})
+
 module.exports = router
\ No newline at end of file
diff --git a/deploy/user/index.js b/deploy/user/index.js
index 9d1809c96..1d6a364aa 100644
--- a/deploy/user/index.js
+++ b/deploy/user/index.js
@@ -23,6 +23,49 @@ router.get('/config', loggedInOnlyMiddleware, async (req, res) => {
   }
 })
 
+router.get('/pluginPermissions', async (req, res) => {
+  const { user } = req
+  /**
+   * only using session to store user csp for now
+   * in future, if use is logged in, look for **signed** config file, and verify the signature
+   */
+  const permittedCsp = req.session.permittedCsp || {}
+  res.status(200).json(permittedCsp)
+})
+
+router.post('/pluginPermissions', bodyParser.json(), async (req, res) => {
+  const { user, body } = req
+  /**
+   * only using session to store user csp for now
+   * in future, if use is logged in, **signed** config file, and store in user space
+   */
+  
+  const newPermittedCsp = req.session.permittedCsp || {}
+  for (const key in body) {
+    newPermittedCsp[key] = body[key]
+  }
+  req.session.permittedCsp = newPermittedCsp
+  res.status(200).json({ ok: true })
+})
+
+router.delete('/pluginPermissions/:pluginKey', async (req, res) => {
+  const { user, params } = req
+  const { pluginKey } = params
+  /**
+    * only using session to store user csp for now
+    * in future, if use is logged in, **signed** config file, and store in user space
+    */
+  const newPermission = {}
+  const permittedCsp = req.session.permittedCsp || {}
+  for (const key in permittedCsp) {
+    if (!pluginKey !== key) {
+      newPermission[key] = permittedCsp[key]
+    }
+  }
+  req.session.permittedCsp = newPermission
+  res.status(200).json({ ok: true })
+})
+
 router.post('/config', loggedInOnlyMiddleware, bodyParser.json(), async (req, res) => {
   const { user, body } = req
   try {
diff --git a/deploy/user/index.spec.js b/deploy/user/index.spec.js
new file mode 100644
index 000000000..abae29293
--- /dev/null
+++ b/deploy/user/index.spec.js
@@ -0,0 +1,192 @@
+const router = require('./index')
+const app = require('express')()
+const sinon = require('sinon')
+const { stub, spy } = require('sinon')
+const { default: got } = require('got/dist/source')
+const { expect } = require('chai')
+const { assert } = require('console')
+
+
+
+const sessionObj = {
+  permittedCspVal: {},
+  get permittedCsp(){
+    return this.permittedCspVal
+  },
+  set permittedCsp(val) {
+
+  }
+}
+
+const permittedCspSpy = spy(sessionObj, 'permittedCsp', ['get', 'set'])
+
+const middleware = (req, res, next) => {
+  req.session = sessionObj
+  next()
+}
+
+describe('> user/index.js', () => {
+  let server
+  
+  before(done => {
+    app.use(middleware)
+    app.use(router)
+    server = app.listen(1234)
+    setTimeout(() => {
+      done()
+    }, 1000);
+  })
+
+  afterEach(() => {
+    permittedCspSpy.get.resetHistory()
+    permittedCspSpy.set.resetHistory()
+    sessionObj.permittedCspVal = {}
+  })
+
+  after(done => server.close(done))
+
+  describe('> GET /pluginPermissions', () => {
+    it('> getter called, setter not called', async () => {
+      await got.get('http://localhost:1234/pluginPermissions')
+
+      assert(
+        permittedCspSpy.get.calledOnce,
+        `permittedCsp getter accessed once`
+      )
+
+      assert(
+        permittedCspSpy.set.notCalled,
+        `permittedCsp setter not called`
+      )
+    })
+    it('> if no value present, returns {}', async () => {
+      sessionObj.permittedCspVal = null
+      const { body } = await got.get('http://localhost:1234/pluginPermissions')
+      expect(JSON.parse(body)).to.deep.equal({})
+    })
+
+    it('> if value present, return value', async () => {
+      const val = {
+        'hot-dog': {
+          'weatherman': 'tolerable'
+        }
+      }
+      sessionObj.permittedCspVal = val
+
+      const { body } = await got.get('http://localhost:1234/pluginPermissions')
+      expect(JSON.parse(body)).to.deep.equal(val)
+    })
+  })
+
+  describe('> POST /pluginPermissions', () => {
+    it('> getter called once, then setter called once', async () => {
+      const jsonPayload = {
+        'hotdog-world': 420
+      }
+      await got.post('http://localhost:1234/pluginPermissions', {
+        json: jsonPayload
+      })
+      assert(
+        permittedCspSpy.get.calledOnce,
+        `permittedCsp getter called once`
+      )
+      assert(
+        permittedCspSpy.set.calledOnce,
+        `permittedCsp setter called once`
+      )
+
+      assert(
+        permittedCspSpy.get.calledBefore(permittedCspSpy.set),
+        `getter called before setter`
+      )
+
+      assert(
+        permittedCspSpy.set.calledWith(jsonPayload),
+        `setter called with payload`
+      )
+    })
+
+    it('> if sessio obj exists, will set with merged obj', async () => {
+      const prevVal = {
+        'foo-bar': [
+          123,
+          'fuzz-buzz'
+        ],
+        'hot-dog-world': 'baz'
+      }
+      sessionObj.permittedCspVal = prevVal
+
+      const jsonPayload = {
+        'hot-dog-world': [
+          'fussball'
+        ]
+      }
+
+      await got.post('http://localhost:1234/pluginPermissions', {
+        json: jsonPayload
+      })
+      assert(
+        permittedCspSpy.set.calledWith({
+          ...prevVal,
+          ...jsonPayload,
+        }),
+        'setter called with merged payload'
+      )
+    })
+  })
+
+  describe('> DELETE /pluginPermissions/:pluginId', () => {
+    const prevVal = {
+      'foo': 'bar',
+      'buzz': 'lightyear'
+    }
+    before(() => {
+      sessionObj.permittedCspVal = prevVal
+    })
+
+    it('> getter and setter gets called once and in correct order', async () => {
+
+      await got.delete(`http://localhost:1234/pluginPermissions/foolish`)
+
+      assert(
+        permittedCspSpy.get.calledOnce,
+        'getter called once'
+      )
+
+      assert(
+        permittedCspSpy.set.calledOnce,
+        'setter called once'
+      )
+
+      assert(
+        permittedCspSpy.get.calledBefore(permittedCspSpy.set),
+        'getter called before setter'
+      )
+    })
+
+    it('> if attempts at delete non existent key, still returns ok', async () => {
+
+      const { body } = await got.delete(`http://localhost:1234/pluginPermissions/foolish`)
+      const json = JSON.parse(body)
+      expect(json).to.deep.equal({ ok: true })
+
+      assert(
+        permittedCspSpy.set.calledWith(prevVal),
+        'permittedCsp setter called with the prev value (nothing changed)'
+      )
+    })
+
+    it('> if attempts at delete exisiting key, returns ok, and value is set', async () => {
+
+      const { body } = await got.delete(`http://localhost:1234/pluginPermissions/foo`)
+      const json = JSON.parse(body)
+      expect(json).to.deep.equal({ ok: true })
+
+      const { foo, ...rest } = prevVal
+      assert(
+        permittedCspSpy.set.calledWith(rest),
+        'permittedCsp setter called with the prev value, less the deleted key'
+      )
+    })
+  })
+})
\ No newline at end of file
diff --git a/docs/releases/v2.4.0.md b/docs/releases/v2.4.0.md
new file mode 100644
index 000000000..9e047ec46
--- /dev/null
+++ b/docs/releases/v2.4.0.md
@@ -0,0 +1,9 @@
+# v2.4.0
+
+## New features
+
+- plugins will now follow a permission model, if they would like to access external resources
+
+## Under the hood stuff
+
+- refactored code, added additional test coverage
diff --git a/mkdocs.yml b/mkdocs.yml
index 5d484b503..02d1d4267 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -40,6 +40,7 @@ pages:
     - Fetching datasets: 'advanced/datasets.md'
     - Display non-atlas volumes: 'advanced/otherVolumes.md'
   - Release notes:
+    - v2.4.0: 'releases/v2.4.0.md'
     - v2.3.0: 'releases/v2.3.0.md'
     - v2.2.7: 'releases/v2.2.7.md'
     - v2.2.6: 'releases/v2.2.6.md'
diff --git a/package.json b/package.json
index aa5a9d92d..cf99e4aca 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "interactive-viewer",
-  "version": "2.3.0",
-  "description": "HBP interactive atlas viewer. Integrating KG query, dataset previews & more. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular.io",
+  "version": "2.4.0",
+  "description": "HBP interactive atlas viewer. Integrating KG query, dataset previews & more. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular",
   "scripts": {
     "dev-server-export": "webpack-dev-server --config webpack.export.js",
     "build-export": "webpack --config webpack.export.js",
diff --git a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts
index acb0d9425..9020d72d2 100644
--- a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts
+++ b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.spec.ts
@@ -1,110 +1,315 @@
-// import { PluginServices } from "./atlasViewer.pluginService.service";
-// import { TestBed, inject } from "@angular/core/testing";
-// import { MainModule } from "src/main.module";
-// import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing'
-
-// const MOCK_PLUGIN_MANIFEST = {
-//   name: 'fzj.xg.MOCK_PLUGIN_MANIFEST',
-//   templateURL: 'http://localhost:10001/template.html',
-//   scriptURL: 'http://localhost:10001/script.js'
-// }
-
-// describe('PluginServices', () => {
-//   let pluginService: PluginServices
-
-//   beforeEach(async () => {
-//     await TestBed.configureTestingModule({
-//       imports: [
-//         HttpClientTestingModule,
-//         MainModule
-//       ]
-//     }).compileComponents()
-
-//     pluginService = TestBed.get(PluginServices)
-//   })
-
-//   it(
-//     'is instantiated in test suite OK',
-//     () => expect(TestBed.get(PluginServices)).toBeTruthy()
-//   )
-
-//   it(
-//     'expectOne is working as expected',
-//     inject([HttpTestingController], (httpMock: HttpTestingController) => {
-//       expect(httpMock.match('test').length).toBe(0)
-//       pluginService.fetch('test')
-//       expect(httpMock.match('test').length).toBe(1)
-//       pluginService.fetch('test')
-//       pluginService.fetch('test')
-//       expect(httpMock.match('test').length).toBe(2)
-//     })
-//   )
-
-//   describe('#launchPlugin', () => {
-
-//     describe('basic fetching functionality', () => {
-//       it(
-//         'fetches templateURL and scriptURL properly',
-//         inject([HttpTestingController], (httpMock: HttpTestingController) => {
-
-//           pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST)
-
-//           const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL)
-//           const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL)
-
-//           expect(mockTemplate).toBeTruthy()
-//           expect(mockScript).toBeTruthy()
-//         })
-//       )
-//       it(
-//         'template overrides templateURL',
-//         inject([HttpTestingController], (httpMock: HttpTestingController) => {
-//           pluginService.launchPlugin({
-//             ...MOCK_PLUGIN_MANIFEST,
-//             template: ''
-//           })
-
-//           httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL)
-//           const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL)
-
-//           expect(mockScript).toBeTruthy()
-//         })
-//       )
-
-//       it(
-//         'script overrides scriptURL',
-
-//         inject([HttpTestingController], (httpMock: HttpTestingController) => {
-//           pluginService.launchPlugin({
-//             ...MOCK_PLUGIN_MANIFEST,
-//             script: ''
-//           })
-
-//           const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL)
-//           httpMock.expectNone(MOCK_PLUGIN_MANIFEST.scriptURL)
-
-//           expect(mockTemplate).toBeTruthy()
-//         })
-//       )
-//     })
-
-//     describe('racing slow cconnection when launching plugin', () => {
-//       it(
-//         'when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching',
-//         inject([HttpTestingController], (httpMock:HttpTestingController) => {
-
-//           expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy()
-//           pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST)
-//           pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST)
-//           expect(httpMock.match(MOCK_PLUGIN_MANIFEST.scriptURL).length).toBe(1)
-//           expect(httpMock.match(MOCK_PLUGIN_MANIFEST.templateURL).length).toBe(1)
-
-//           expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeTruthy()
-//         })
-//       )
-//     })
-//   })
-// })
-
-// TODO currently crashes test somehow
-// TODO figure out why
+import { CommonModule } from "@angular/common"
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"
+import { NgModule } from "@angular/core"
+import { async, fakeAsync, flushMicrotasks, TestBed, tick } from "@angular/core/testing"
+import { MockStore, provideMockStore } from "@ngrx/store/testing"
+import { ComponentsModule } from "src/components"
+import { DialogService } from "src/services/dialogService.service"
+import { selectorPluginCspPermission } from "src/services/state/userConfigState.helper"
+import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"
+import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants"
+import { WidgetModule, WidgetServices } from "src/widget"
+import { PluginServices } from "./atlasViewer.pluginService.service"
+import { PluginUnit } from "./pluginUnit.component"
+
+const MOCK_PLUGIN_MANIFEST = {
+  name: 'fzj.xg.MOCK_PLUGIN_MANIFEST',
+  templateURL: 'http://localhost:10001/template.html',
+  scriptURL: 'http://localhost:10001/script.js'
+}
+
+@NgModule({
+  declarations: [
+    PluginUnit,
+  ],
+  entryComponents: [
+    PluginUnit
+  ],
+  exports: [
+    PluginUnit
+  ]
+})
+
+class PluginUnitModule{}
+
+const spyfn = {
+  appendSrc: jasmine.createSpy('appendSrc')
+}
+
+
+
+describe('> atlasViewer.pluginService.service.ts', () => {
+  describe('> PluginServices', () => {
+    
+    let pluginService: PluginServices
+    let httpMock: HttpTestingController
+    let mockStore: MockStore
+    
+    beforeEach(async(() => {
+      TestBed.configureTestingModule({
+        imports: [
+          AngularMaterialModule,
+          CommonModule,
+          WidgetModule,
+          PluginUnitModule,
+          HttpClientTestingModule,
+          ComponentsModule,
+        ],
+        providers: [
+          provideMockStore(),
+          PluginServices,
+          {
+            provide: APPEND_SCRIPT_TOKEN,
+            useValue: spyfn.appendSrc
+          },
+          {
+            provide: REMOVE_SCRIPT_TOKEN,
+            useValue: () => Promise.resolve()
+          },
+          {
+            provide: DialogService,
+            useValue: {
+              getUserConfirm: () => Promise.resolve()
+            }
+          }
+        ]
+      }).compileComponents().then(() => {
+        
+        httpMock = TestBed.inject(HttpTestingController)
+        pluginService = TestBed.inject(PluginServices)
+        mockStore = TestBed.inject(MockStore)
+        pluginService.pluginViewContainerRef = {
+          createComponent: () => {
+            return {
+              onDestroy: () => {},
+              instance: {
+                elementRef: {
+                  nativeElement: {
+                    append: () => {}
+                  }
+                }
+              }
+            }
+          }
+        } as any
+
+        httpMock.expectOne('http://localhost:3000/plugins/manifests').flush('[]')
+
+        const widgetService = TestBed.inject(WidgetServices)
+        /**
+         * widget service floatingcontainer not inst in this circumstance
+         * TODO fix widget service tests importing widget service are not as flaky
+         */
+        widgetService.addNewWidget = () => {
+          return {} as any
+        }
+      })
+    }))
+
+    afterEach(() => {
+      spyfn.appendSrc.calls.reset()
+      const ctrl = TestBed.inject(HttpTestingController)
+      ctrl.verify()
+    })
+
+    it('> service can be inst', () => {
+      expect(pluginService).toBeTruthy()
+    })
+
+    it('expectOne is working as expected', done => {
+      
+      pluginService.fetch('test')
+        .then(text => {
+          expect(text).toEqual('bla')
+          done()
+        })
+      httpMock.expectOne('test').flush('bla')
+        
+    })
+
+    /**
+     * need to consider user confirmation on csp etc
+     */
+    describe('#launchPlugin', () => {
+
+      beforeEach(() => {
+        mockStore.overrideSelector(selectorPluginCspPermission, { value: false })
+      })
+
+      describe('> basic fetching functionality', () => {
+        it('> fetches templateURL and scriptURL properly', fakeAsync(() => {
+          
+          pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST})
+
+          tick(100)
+          
+          const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL)
+          mockTemplate.flush('hello world')
+          
+          tick(100)
+          
+          expect(spyfn.appendSrc).toHaveBeenCalledTimes(1)
+          expect(spyfn.appendSrc).toHaveBeenCalledWith(MOCK_PLUGIN_MANIFEST.scriptURL)
+          
+        }))
+
+        it('> template overrides templateURL', fakeAsync(() => {
+          pluginService.launchPlugin({
+            ...MOCK_PLUGIN_MANIFEST,
+            template: ''
+          })
+
+          tick(20)
+          httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL)
+        }))
+
+        it('> script with scriptURL throws', done => {
+          pluginService.launchPlugin({
+            ...MOCK_PLUGIN_MANIFEST,
+            script: '',
+            scriptURL: null
+          })
+            .then(() => {
+              /**
+               * should not pass
+               */
+              expect(true).toEqual(false)
+            })
+            .catch(e => {
+              done()
+            })
+          
+          /**
+           * http call will not be made, as rejection happens by Promise.reject, while fetch call probably happens at the next event cycle
+           */
+          httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL)
+        })
+      
+        describe('> user permission', () => {
+          let userConfirmSpy: jasmine.Spy
+          let readyPluginSpy: jasmine.Spy
+          let cspManifest = {
+            ...MOCK_PLUGIN_MANIFEST,
+            csp: {
+              'connect-src': [`'unsafe-eval'`]
+            }
+          }
+          afterEach(() => {
+            userConfirmSpy.calls.reset()
+            readyPluginSpy.calls.reset()
+          })
+          beforeEach(() => {
+            readyPluginSpy = spyOn(pluginService, 'readyPlugin').and.callFake(() => Promise.reject())
+            const dialogService = TestBed.inject(DialogService)
+            userConfirmSpy = spyOn(dialogService, 'getUserConfirm')
+          })
+
+          describe('> if user permission has been given', () => {
+            beforeEach(fakeAsync(() => {
+              mockStore.overrideSelector(selectorPluginCspPermission, { value: true })
+              userConfirmSpy.and.callFake(() => Promise.reject())
+              pluginService.launchPlugin({
+                ...cspManifest
+              }).catch(() => {
+                /**
+                 * expecting to throw because call fake returning promise.reject in beforeEach
+                 */
+              })
+              tick(20)
+            }))
+            it('> will not ask for permission', () => {
+              expect(userConfirmSpy).not.toHaveBeenCalled()
+            })
+
+            it('> will call ready plugin', () => {
+              expect(readyPluginSpy).toHaveBeenCalled()
+            })
+          })
+
+          describe('> if user permission has not yet been given', () => {
+            beforeEach(() => {
+              mockStore.overrideSelector(selectorPluginCspPermission, { value: false })
+            })
+            describe('> user permission', () => {
+              beforeEach(fakeAsync(() => {
+                pluginService.launchPlugin({
+                  ...cspManifest
+                }).catch(() => {
+                  /**
+                   * expecting to throw because call fake returning promise.reject in beforeEach
+                   */
+                })
+                tick(40)
+              }))
+              it('> will be asked for', () => {
+                expect(userConfirmSpy).toHaveBeenCalled()
+              })
+            })
+
+            describe('> if user accepts', () => {
+              beforeEach(fakeAsync(() => {
+                userConfirmSpy.and.callFake(() => Promise.resolve())
+
+                pluginService.launchPlugin({
+                  ...cspManifest
+                }).catch(() => {
+                  /**
+                   * expecting to throw because call fake returning promise.reject in beforeEach
+                   */
+                })
+              }))
+              it('> calls /POST user/pluginPermissions', () => {
+                httpMock.expectOne({
+                  method: 'POST',
+                  url: 'http://localhost:3000/user/pluginPermissions'
+                })
+              })
+            })
+
+            describe('> if user declines', () => {
+
+              beforeEach(fakeAsync(() => {
+                userConfirmSpy.and.callFake(() => Promise.reject())
+
+                pluginService.launchPlugin({
+                  ...cspManifest
+                }).catch(() => {
+                  /**
+                   * expecting to throw because call fake returning promise.reject in beforeEach
+                   */
+                })
+              }))
+              it('> calls /POST user/pluginPermissions', () => {
+                httpMock.expectNone({
+                  method: 'POST',
+                  url: 'http://localhost:3000/user/pluginPermissions'
+                })
+              })
+            })
+          })
+        })
+      })
+
+      describe('> racing slow connection when launching plugin', () => {
+        it('> when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', fakeAsync(() => {
+
+          expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy()
+          expect(pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy()
+          pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST})
+          pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST})
+          tick(20)
+          const req = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL)
+          req.flush('baba')
+          tick(20)
+          expect(spyfn.appendSrc).toHaveBeenCalledTimes(1)
+
+          expect(
+            pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name) ||
+            pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name)
+          ).toBeTruthy()
+        }))
+      })
+    
+    })
+  })
+})
diff --git a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts
index dff123181..bf6104a32 100644
--- a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts
+++ b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts
@@ -1,16 +1,21 @@
 import { HttpClient } from '@angular/common/http'
-import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject, InjectionToken } from "@angular/core";
-import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.store";
-import { IavRootStoreInterface, isDefined } from 'src/services/stateStore.service'
+import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject, SecurityContext } from "@angular/core";
+import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.helper";
 import { PluginUnit } from "./pluginUnit.component";
 import { select, Store } from "@ngrx/store";
-import { BehaviorSubject, merge, Observable, of, Subject, zip } from "rxjs";
-import { filter, map, shareReplay, switchMap, catchError } from "rxjs/operators";
+import { BehaviorSubject, from, merge, Observable, of } from "rxjs";
+import { catchError, filter, map, mapTo, shareReplay, switchMap, switchMapTo, take, tap } from "rxjs/operators";
 import { LoggingService } from 'src/logging';
 import { PluginHandler } from 'src/util/pluginHandler';
 import { WidgetUnit, WidgetServices } from "src/widget";
 import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, BACKENDURL, getHttpHeader } from 'src/util/constants';
 import { PluginFactoryDirective } from './pluginFactory.directive';
+import { selectorPluginCspPermission } from 'src/services/state/userConfigState.helper';
+import { DialogService } from 'src/services/dialogService.service';
+import { DomSanitizer } from '@angular/platform-browser';
+import { MatSnackBar } from '@angular/material/snack-bar';
+
+const requiresReloadMd = `\n\n***\n\n**warning**: interactive atlas viewer needs to be reloaded for the change to take effect.`
 
 export const registerPluginFactoryDirectiveFactory = (pSer: PluginServices) => {
   return (pFactoryDirective: PluginFactoryDirective) => {
@@ -45,9 +50,12 @@ export class PluginServices {
   constructor(
     private widgetService: WidgetServices,
     private cfr: ComponentFactoryResolver,
-    private store: Store<IavRootStoreInterface>,
+    private store: Store<any>,
+    private dialogService: DialogService,
+    private snackbar: MatSnackBar,
     private http: HttpClient,
     private log: LoggingService,
+    private sanitizer: DomSanitizer,
     @Inject(APPEND_SCRIPT_TOKEN) private appendSrc: (src: string) => Promise<HTMLScriptElement>,
     @Inject(REMOVE_SCRIPT_TOKEN) private removeSrc: (src: HTMLScriptElement) => void,
   ) {
@@ -65,34 +73,14 @@ export class PluginServices {
      * TODO convert to rxjs streams, instead of Promise.all
      */
 
-    const pluginUrl = `${BACKENDURL.replace(/\/$/,'')}/plugins`
-    const streamFetchedManifests$ = this.http.get(pluginUrl,{
+    const pluginManifestsUrl = `${BACKENDURL.replace(/\/$/,'/')}plugins/manifests`
+
+    this.http.get<IPluginManifest[]>(pluginManifestsUrl, {
       responseType: 'json',
       headers: getHttpHeader(),
-    }).pipe(
-      switchMap((arr: string[]) => {
-        return zip(
-          ...arr.map(url => this.http.get(url, {
-            responseType: 'json',
-            headers: getHttpHeader()
-          }).pipe(
-            catchError((err, caught) => of(null))
-          ))
-        ).pipe(
-          map(arr => arr.filter(v => !!v))
-        )
-      })
-    )
-
-    streamFetchedManifests$.subscribe(
-      arr => {
-        this.fetchedPluginManifests = arr
-        this.log.log(this.fetchedPluginManifests)
-      },
+    }).subscribe(
+      arr => this.fetchedPluginManifests = arr,
       this.log.error,
-      () => {
-        this.log.log(`fetching end`)
-      }
     )
 
     this.minimisedPlugins$ = merge(
@@ -123,14 +111,20 @@ export class PluginServices {
     })
 
   public readyPlugin(plugin: IPluginManifest): Promise<any> {
-    return Promise.all([
-      isDefined(plugin.template)
-        ? Promise.resolve()
-        : isDefined(plugin.templateURL)
-          ? this.fetch(plugin.templateURL, {responseType: 'text'}).then(template => plugin.template = template)
-          : Promise.reject('both template and templateURL are not defined') ,
-      isDefined(plugin.scriptURL) ? Promise.resolve() : Promise.reject(`inline script has been deprecated. use scriptURL instead`),
-    ])
+    const isDefined = input => typeof input !== 'undefined' && input !== null
+    if (!isDefined(plugin.scriptURL)) {
+      return Promise.reject(`inline script has been deprecated. use scriptURL instead`)
+    }
+    if (isDefined(plugin.template)) {
+      return Promise.resolve()
+    }
+    if (plugin.templateURL) {
+      return this.fetch(plugin.templateURL, {responseType: 'text'})
+        .then(template => {
+          plugin.template = template
+        })
+    }
+    return Promise.reject('both template and templateURL are not defined')
   }
 
   private launchedPlugins: Set<string> = new Set()
@@ -165,7 +159,36 @@ export class PluginServices {
 
   private launchingPlugins: Set<string> = new Set()
   public orphanPlugins: Set<IPluginManifest> = new Set()
-  public launchPlugin(plugin: IPluginManifest) {
+
+  public async revokePluginPermission(pluginKey: string) {
+    const createRevokeMd = (pluginKey: string) => `You are about to revoke the permission given to ${pluginKey}.${requiresReloadMd}`
+
+    try {
+      await this.dialogService.getUserConfirm({
+        markdown: createRevokeMd(pluginKey)
+      })
+
+      this.http.delete(
+        `${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions/${encodeURIComponent(pluginKey)}`, 
+        {
+          headers: getHttpHeader()
+        }
+      ).subscribe(
+        () => {
+          window.location.reload()
+        },
+        err => {
+          this.snackbar.open(`Error revoking plugin permission ${err.toString()}`, 'Dismiss')
+        }
+      )
+    } catch (_e) {
+      /**
+       * user cancelled workflow
+       */
+    }
+  }
+
+  public async launchPlugin(plugin: IPluginManifest): Promise<PluginHandler> {
     if (this.pluginIsLaunching(plugin.name)) {
       // plugin launching please be patient
       // TODO add visual feedback
@@ -188,107 +211,169 @@ export class PluginServices {
 
     this.addPluginToIsLaunchingSet(plugin.name)
 
-    return this.readyPlugin(plugin)
-      .then(async () => {
-        const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory )
-        /* TODO in v0.2, I used:
+    const { csp, displayName, name = '', version = 'latest' } = plugin
+    const pluginKey = `${name}::${version}`
+    const createPermissionMd = ({ csp, name, version }) => {
+      const sanitize = val =>  this.sanitizer.sanitize(SecurityContext.HTML, val)
+      const getCspRow = ({ key }) => {
+        return `| ${sanitize(key)} | ${csp[key].map(v => '`' + sanitize(v) + '`').join(',')} |`
+      }
+      return `**${sanitize(displayName || name)}** version **${sanitize(version)}** requires additional permission from you to run:\n\n| permission | detail |\n| --- | --- |\n${Object.keys(csp).map(key => getCspRow({ key })).join('\n')}${requiresReloadMd}`
+    } 
+
+    await new Promise((rs, rj) => {
+      this.store.pipe(
+        select(selectorPluginCspPermission, { key: pluginKey }),
+        take(1),
+        switchMap(userAgreed => {
+          if (userAgreed.value) return of(true)
+
+          /**
+           * check if csp exists
+           */
+          if (!csp || Object.keys(csp).length === 0) {
+            return of(true)
+          }
+          /**
+           * TODO: check do not ask status
+           */
+          return from(
+            this.dialogService.getUserConfirm({
+              markdown: createPermissionMd({ csp, name, version })
+            })
+          ).pipe(
+            mapTo(true),
+            catchError(() => of(false)),
+            filter(v => !!v),
+            switchMapTo(
+              this.http.post(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, 
+                { [pluginKey]: csp },
+                {
+                  responseType: 'json',
+                  headers: getHttpHeader()
+                })
+            ),
+            tap(() => {
+              window.location.reload()
+            }),
+            mapTo(false)
+          )
+        }),
+        take(1),
+      ).subscribe(
+        val => val ? rs() : rj(),
+        err => rj(err)
+      )
+    })
 
-        const template = document.createElement('div')
-        template.insertAdjacentHTML('afterbegin',template)
+    await this.readyPlugin(plugin)
+
+    /**
+     * catch when pluginViewContainerRef as not been overwritten?
+     */
+    if (!this.pluginViewContainerRef) {
+      throw new Error(`pluginViewContainerRef not populated`)
+    }
+    const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory )
+    /* TODO in v0.2, I used:
 
-        // reason was:
-        // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification
+    const template = document.createElement('div')
+    template.insertAdjacentHTML('afterbegin',template)
 
-        */
+    // reason was:
+    // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification
 
-        const handler = new PluginHandler()
-        this.pluginHandlersMap.set(plugin.name, handler)
+    */
 
-        /**
-         * define the handler properties prior to appending plugin script
-         * so that plugin script can access properties w/o timeout
-         */
-        handler.initState = plugin.initState
-          ? plugin.initState
-          : null
+    const handler = new PluginHandler()
+    this.pluginHandlersMap.set(plugin.name, handler)
 
-        handler.initStateUrl = plugin.initStateUrl
-          ? plugin.initStateUrl
-          : null
+    /**
+     * define the handler properties prior to appending plugin script
+     * so that plugin script can access properties w/o timeout
+     */
+    handler.initState = plugin.initState
+      ? plugin.initState
+      : null
+
+    handler.initStateUrl = plugin.initStateUrl
+      ? plugin.initStateUrl
+      : null
+
+    handler.setInitManifestUrl = (url) => this.store.dispatch({
+      type : PLUGINSTORE_ACTION_TYPES.SET_INIT_PLUGIN,
+      manifest : {
+        name : plugin.name,
+        initManifestUrl : url,
+      },
+    })
 
-        handler.setInitManifestUrl = (url) => this.store.dispatch({
-          type : PLUGINSTORE_ACTION_TYPES.SET_INIT_PLUGIN,
-          manifest : {
-            name : plugin.name,
-            initManifestUrl : url,
-          },
-        })
+    const shutdownCB = [
+      () => {
+        this.removePluginFromLaunchedSet(plugin.name)
+      },
+    ]
 
-        const shutdownCB = [
-          () => {
-            this.removePluginFromLaunchedSet(plugin.name)
-          },
-        ]
+    handler.onShutdown = (cb) => {
+      if (typeof cb !== 'function') {
+        this.log.warn('onShutdown requires the argument to be a function')
+        return
+      }
+      shutdownCB.push(cb)
+    }
 
-        handler.onShutdown = (cb) => {
-          if (typeof cb !== 'function') {
-            this.log.warn('onShutdown requires the argument to be a function')
-            return
-          }
-          shutdownCB.push(cb)
-        }
+    const scriptEl = await this.appendSrc(plugin.scriptURL)
 
-        const scriptEl = await this.appendSrc(plugin.scriptURL)
-        handler.onShutdown(() => this.removeSrc(scriptEl))
+    handler.onShutdown(() => this.removeSrc(scriptEl))
 
-        const template = document.createElement('div')
-        template.insertAdjacentHTML('afterbegin', plugin.template)
-        pluginUnit.instance.elementRef.nativeElement.append( template )
+    const template = document.createElement('div')
+    template.insertAdjacentHTML('afterbegin', plugin.template)
+    pluginUnit.instance.elementRef.nativeElement.append( template )
 
-        const widgetCompRef = this.widgetService.addNewWidget(pluginUnit, {
-          state : 'floating',
-          exitable : true,
-          persistency: plugin.persistency,
-          title : plugin.displayName || plugin.name,
-        })
+    const widgetCompRef = this.widgetService.addNewWidget(pluginUnit, {
+      state : 'floating',
+      exitable : true,
+      persistency: plugin.persistency,
+      title : plugin.displayName || plugin.name,
+    })
 
-        this.addPluginToLaunchedSet(plugin.name)
-        this.removePluginFromIsLaunchingSet(plugin.name)
+    this.addPluginToLaunchedSet(plugin.name)
+    this.removePluginFromIsLaunchingSet(plugin.name)
 
-        this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance)
+    this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance)
 
-        const unsubscribeOnPluginDestroy = []
+    const unsubscribeOnPluginDestroy = []
 
-        // TODO deprecate sec
-        handler.blink = (_sec?: number) => {
-          widgetCompRef.instance.blinkOn = true
-        }
+    // TODO deprecate sec
+    handler.blink = (_sec?: number) => {
+      widgetCompRef.instance.blinkOn = true
+    }
 
-        handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val
+    handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val
 
-        handler.shutdown = () => {
-          widgetCompRef.instance.exit()
-        }
+    handler.shutdown = () => {
+      widgetCompRef.instance.exit()
+    }
 
-        handler.onShutdown(() => {
-          unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe())
-          this.pluginHandlersMap.delete(plugin.name)
-          this.mapPluginNameToWidgetUnit.delete(plugin.name)
-        })
+    handler.onShutdown(() => {
+      unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe())
+      this.pluginHandlersMap.delete(plugin.name)
+      this.mapPluginNameToWidgetUnit.delete(plugin.name)
+    })
 
-        pluginUnit.onDestroy(() => {
-          while (shutdownCB.length > 0) {
-            shutdownCB.pop()()
-          }
-        })
+    pluginUnit.onDestroy(() => {
+      while (shutdownCB.length > 0) {
+        shutdownCB.pop()()
+      }
+    })
 
-        return handler
-      })
+    return handler
   }
 }
 
 export interface IPluginManifest {
   name?: string
+  version?: string
   displayName?: string
   templateURL?: string
   template?: string
@@ -303,4 +388,9 @@ export interface IPluginManifest {
 
   homepage?: string
   authors?: string
+
+  csp?: {
+    'connect-src'?: string[]
+    'script-src'?: string[]
+  }
 }
diff --git a/src/components/confirmDialog/confirmDialog.component.spec.ts b/src/components/confirmDialog/confirmDialog.component.spec.ts
new file mode 100644
index 000000000..535e6f9fb
--- /dev/null
+++ b/src/components/confirmDialog/confirmDialog.component.spec.ts
@@ -0,0 +1,54 @@
+import { CommonModule } from "@angular/common"
+import { TestBed, async } from "@angular/core/testing"
+import { MAT_DIALOG_DATA } from "@angular/material/dialog"
+import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"
+import { ComponentsModule } from "../components.module"
+import { ConfirmDialogComponent } from "./confirmDialog.component"
+
+describe('> confirmDialog.component.spec.ts', () => {
+
+  describe('> ConfirmDialogComponent', () => {
+    let matDialogData = {}
+    beforeEach(async(() => {
+      TestBed.configureTestingModule({
+        imports: [
+          AngularMaterialModule,
+          CommonModule,
+          ComponentsModule,
+        ],
+        providers: [{
+          provide: MAT_DIALOG_DATA,
+          useFactory: () => {
+            return matDialogData as any
+          }
+        }]
+      }).compileComponents()
+    }))
+
+    it('> can be created', () => {
+      const fixutre = TestBed.createComponent(ConfirmDialogComponent)
+      expect(fixutre).toBeTruthy()
+    })
+
+    describe('> if both markdown and message are truthy', () => {
+      beforeEach(() => {
+        matDialogData = {
+          markdown: `hello world`,
+          message: `foo bar`,
+        }
+      })
+      it('> should show markdown in preference', () => {
+        const fixture = TestBed.createComponent(ConfirmDialogComponent)
+        fixture.detectChanges()
+        const text = fixture.debugElement.nativeElement.textContent
+        expect(
+          /hello\sworld/.test(text)
+        ).toBeTruthy()
+        expect(
+          /foo\sbar/.test(text)
+        ).toBeFalsy()
+      })
+    })
+  })
+
+})
\ No newline at end of file
diff --git a/src/components/confirmDialog/confirmDialog.component.ts b/src/components/confirmDialog/confirmDialog.component.ts
index b17827614..5c3d9eb02 100644
--- a/src/components/confirmDialog/confirmDialog.component.ts
+++ b/src/components/confirmDialog/confirmDialog.component.ts
@@ -1,5 +1,5 @@
 import { Component, Inject, Input } from "@angular/core";
-import {MAT_DIALOG_DATA} from "@angular/material/dialog";
+import { MAT_DIALOG_DATA } from "@angular/material/dialog";
 
 @Component({
   selector: 'confirm-dialog-component',
@@ -22,10 +22,14 @@ export class ConfirmDialogComponent {
   @Input()
   public cancelBtnText: string = `Cancel`
 
+  @Input()
+  public markdown: string
+
   constructor(@Inject(MAT_DIALOG_DATA) data: any) {
-    const { title = null, message  = null, okBtnText, cancelBtnText} = data || {}
+    const { title = null, message  = null, markdown, okBtnText, cancelBtnText} = data || {}
     if (title) this.title = title
     if (message) this.message = message
+    if (markdown) this.markdown = markdown
     if (okBtnText) this.okBtnText = okBtnText
     if (cancelBtnText) this.cancelBtnText = cancelBtnText
   }
diff --git a/src/components/confirmDialog/confirmDialog.template.html b/src/components/confirmDialog/confirmDialog.template.html
index df52270e9..401261f1a 100644
--- a/src/components/confirmDialog/confirmDialog.template.html
+++ b/src/components/confirmDialog/confirmDialog.template.html
@@ -3,9 +3,16 @@
 </h1>
 
 <mat-dialog-content>
-  <p>
-    {{ message }}
-  </p>
+  <ng-template [ngIf]="markdown" [ngIfElse]="stringMessageTmpl">
+    <markdown-dom [markdown]="markdown">
+    </markdown-dom>
+  </ng-template>
+
+  <ng-template #stringMessageTmpl>
+    <p>
+      {{ message }}
+    </p>
+  </ng-template>
 </mat-dialog-content>
 
 <mat-divider></mat-divider>
diff --git a/src/services/dialogService.service.ts b/src/services/dialogService.service.ts
index 05911fdc7..c202eda58 100644
--- a/src/services/dialogService.service.ts
+++ b/src/services/dialogService.service.ts
@@ -63,5 +63,6 @@ export interface DialogConfig {
   placeholder: string
   defaultValue: string
   message: string
+  markdown?: string
   iconClass: string
 }
diff --git a/src/services/effect/pluginUseEffect.spec.ts b/src/services/effect/pluginUseEffect.spec.ts
index 2bd9efda5..cda6dccb7 100644
--- a/src/services/effect/pluginUseEffect.spec.ts
+++ b/src/services/effect/pluginUseEffect.spec.ts
@@ -6,7 +6,8 @@ import { Action } from "@ngrx/store";
 import { provideMockActions } from "@ngrx/effects/testing";
 import { provideMockStore } from "@ngrx/store/testing";
 import { defaultRootState } from "../stateStore.service";
-import { PLUGINSTORE_CONSTANTS, PLUGINSTORE_ACTION_TYPES } from '../state/pluginState.store'
+import { PLUGINSTORE_CONSTANTS } from '../state/pluginState.store'
+import { PLUGINSTORE_ACTION_TYPES } from '../state/pluginState.helper'
 import { Injectable } from "@angular/core";
 import { getRandomHex } from 'common/util'
 import { PluginServices } from "src/atlasViewer/pluginUnit";
diff --git a/src/services/effect/pluginUseEffect.ts b/src/services/effect/pluginUseEffect.ts
index 1c60a3d5a..c6d0048d7 100644
--- a/src/services/effect/pluginUseEffect.ts
+++ b/src/services/effect/pluginUseEffect.ts
@@ -5,7 +5,8 @@ import { Observable, forkJoin } from "rxjs"
 import { filter, map, startWith, switchMap } from "rxjs/operators"
 import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"
 import { PluginServices } from "src/atlasViewer/pluginUnit"
-import { PLUGINSTORE_ACTION_TYPES, PLUGINSTORE_CONSTANTS } from 'src/services/state/pluginState.store'
+import { PLUGINSTORE_CONSTANTS } from 'src/services/state/pluginState.store'
+import { PLUGINSTORE_ACTION_TYPES } from 'src/services/state/pluginState.helper'
 import { IavRootStoreInterface } from "../stateStore.service"
 import { HttpClient } from "@angular/common/http"
 
diff --git a/src/services/state/pluginState.helper.ts b/src/services/state/pluginState.helper.ts
new file mode 100644
index 000000000..2c6494747
--- /dev/null
+++ b/src/services/state/pluginState.helper.ts
@@ -0,0 +1,5 @@
+
+export const PLUGINSTORE_ACTION_TYPES = {
+  SET_INIT_PLUGIN: `SET_INIT_PLUGIN`,
+  CLEAR_INIT_PLUGIN: 'CLEAR_INIT_PLUGIN',
+}
\ No newline at end of file
diff --git a/src/services/state/pluginState.store.ts b/src/services/state/pluginState.store.ts
index de5b76754..ac8ee4862 100644
--- a/src/services/state/pluginState.store.ts
+++ b/src/services/state/pluginState.store.ts
@@ -1,6 +1,6 @@
 import { Action } from '@ngrx/store'
 import { GENERAL_ACTION_TYPES } from '../stateStore.service'
-
+import { PLUGINSTORE_ACTION_TYPES } from './pluginState.helper'
 export const defaultState: StateInterface = {
   initManifests: []
 }
@@ -16,10 +16,6 @@ export interface ActionInterface extends Action {
   }
 }
 
-export const PLUGINSTORE_ACTION_TYPES = {
-  SET_INIT_PLUGIN: `SET_INIT_PLUGIN`,
-  CLEAR_INIT_PLUGIN: 'CLEAR_INIT_PLUGIN',
-}
 
 export const PLUGINSTORE_CONSTANTS = {
   INIT_MANIFEST_SRC: 'INIT_MANIFEST_SRC',
diff --git a/src/services/state/userConfigState.helper.spec.ts b/src/services/state/userConfigState.helper.spec.ts
new file mode 100644
index 000000000..e007700e8
--- /dev/null
+++ b/src/services/state/userConfigState.helper.spec.ts
@@ -0,0 +1,47 @@
+import { selectorPluginCspPermission } from "./userConfigState.helper"
+
+describe('> userConfigState.helper.ts', () => {
+  describe('> selectorPluginCspPermission', () => {
+    const expectedTrue = {
+      value: true
+    }
+    const expectedFalse = {
+      value: false
+    }
+    describe('> malformed init value', () => {
+      describe('> undefined userconfigstate', () => {
+        it('> return expected false val', () => {
+          const returnVal = selectorPluginCspPermission.projector(null, { key: 'foo-bar' })
+          expect(returnVal).toEqual(expectedFalse)
+        })
+      })
+      describe('> undefined pluginCsp property', () => {
+        it('> return expected false val', () => {
+          const returnVal = selectorPluginCspPermission.projector({}, { key: 'foo-bar' })
+          expect(returnVal).toEqual(expectedFalse)
+        })
+      })
+    })
+
+    describe('> well fored init valu', () => {
+
+      describe('> undefined key', () => {
+        it('> return expected false val', () => {
+          const returnVal = selectorPluginCspPermission.projector({
+            pluginCsp: {'yes-man': true}
+          }, { key: 'foo-bar' })
+          expect(returnVal).toEqual(expectedFalse)
+        })
+      })
+
+      describe('> truthly defined key', () => {
+        it('> return expected true val', () => {
+          const returnVal = selectorPluginCspPermission.projector({ pluginCsp:
+            { 'foo-bar': true }
+          }, { key: 'foo-bar' })
+          expect(returnVal).toEqual(expectedTrue)
+        })
+      })
+    })
+  })
+})
\ No newline at end of file
diff --git a/src/services/state/userConfigState.helper.ts b/src/services/state/userConfigState.helper.ts
new file mode 100644
index 000000000..e71be024c
--- /dev/null
+++ b/src/services/state/userConfigState.helper.ts
@@ -0,0 +1,11 @@
+import { createSelector } from "@ngrx/store"
+
+export const selectorPluginCspPermission = createSelector(
+  (state: any) => state.userConfigState,
+  (userConfigState: any, props: any = {}) => {
+    const { key } = props as { key: string }
+    return {
+      value: !!userConfigState?.pluginCsp?.[key]
+    } 
+  }
+)
diff --git a/src/services/state/userConfigState.store.spec.ts b/src/services/state/userConfigState.store.spec.ts
new file mode 100644
index 000000000..00ef5fb33
--- /dev/null
+++ b/src/services/state/userConfigState.store.spec.ts
@@ -0,0 +1,81 @@
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"
+import { fakeAsync, TestBed, tick } from "@angular/core/testing"
+import { provideMockActions } from "@ngrx/effects/testing"
+import { Action } from "@ngrx/store"
+import { MockStore, provideMockStore } from "@ngrx/store/testing"
+import { from, Observable } from "rxjs"
+import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"
+import { DialogService } from "../dialogService.service"
+import { actionUpdatePluginCsp, UserConfigStateUseEffect } from "./userConfigState.store"
+import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors"
+
+describe('> userConfigState.store.spec.ts', () => {
+  describe('> UserConfigStateUseEffect', () => {
+    let action$: Observable<Action>
+    beforeEach(() => {
+      TestBed.configureTestingModule({
+        imports: [
+          HttpClientTestingModule,
+          AngularMaterialModule,
+        ],
+        providers: [
+          provideMockActions(() => action$),
+          provideMockStore({
+            initialState: {
+              viewerConfigState: {
+                gpuLimit: 1e9,
+                animation: true
+              }
+            }
+          }),
+          DialogService
+        ]
+      })
+
+      const mockStore = TestBed.inject(MockStore)
+      mockStore.overrideSelector(viewerStateSelectedTemplateSelector, null)
+      mockStore.overrideSelector(viewerStateSelectedParcellationSelector, null)
+      mockStore.overrideSelector(viewerStateSelectedRegionsSelector, [])
+    })
+
+    it('> can be init', () => {
+      const useEffect = TestBed.inject(UserConfigStateUseEffect)
+      expect(useEffect).toBeTruthy()
+    })
+    describe('> setInitPluginPermission$', () => {
+      let mockHttp: HttpTestingController
+      let useEffect: UserConfigStateUseEffect
+      const mockpluginPer = {
+        'foo-bar': {
+          'script-src': [
+            '1',
+            '2',
+          ]
+        }
+      }
+      beforeEach(() => {
+        mockHttp = TestBed.inject(HttpTestingController)
+        useEffect = TestBed.inject(UserConfigStateUseEffect)
+      })
+      afterEach(() => {
+        mockHttp.verify()
+      })
+      it('> calls /GET user/pluginPermissions', fakeAsync(() => {
+        let val
+        useEffect.setInitPluginPermission$.subscribe(v => val = v)
+        tick(20)
+        const req = mockHttp.expectOne(`http://localhost:3000/user/pluginPermissions`)
+        req.flush(mockpluginPer)
+        expect(val).toEqual(actionUpdatePluginCsp({ payload: mockpluginPer }))
+      }))
+
+      it('> if get fn fails', fakeAsync(() => {
+        let val
+        useEffect.setInitPluginPermission$.subscribe(v => val = v)
+        const req = mockHttp.expectOne(`http://localhost:3000/user/pluginPermissions`)
+        req.error(null, { status: 500, statusText: 'Internal Error' })
+        expect(val).toEqual(actionUpdatePluginCsp({ payload: {} }))
+      }))
+    })
+  })
+})
\ No newline at end of file
diff --git a/src/services/state/userConfigState.store.ts b/src/services/state/userConfigState.store.ts
index db913779d..de8d74829 100644
--- a/src/services/state/userConfigState.store.ts
+++ b/src/services/state/userConfigState.store.ts
@@ -1,21 +1,35 @@
 import { Injectable, OnDestroy } from "@angular/core";
 import { Actions, Effect, ofType } from "@ngrx/effects";
-import { Action, select, Store } from "@ngrx/store";
+import { Action, createAction, createReducer, props, select, Store, on, createSelector } from "@ngrx/store";
 import { combineLatest, from, Observable, of, Subscription } from "rxjs";
-import { catchError, distinctUntilChanged, filter, map, share, shareReplay, switchMap, take, withLatestFrom } from "rxjs/operators";
-import { LOCAL_STORAGE_CONST } from "src/util//constants";
+import { catchError, distinctUntilChanged, filter, map, mapTo, share, shareReplay, switchMap, take, withLatestFrom } from "rxjs/operators";
+import { BACKENDURL, LOCAL_STORAGE_CONST } from "src/util//constants";
 import { DialogService } from "../dialogService.service";
-import { generateLabelIndexId, IavRootStoreInterface, recursiveFindRegionWithLabelIndexId } from "../stateStore.service";
-import { NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS } from "./viewerState.store";
-
+import { recursiveFindRegionWithLabelIndexId } from "src/util/fn";
+import { serialiseParcellationRegion } from 'common/util'
 // Get around the problem of importing duplicated string (ACTION_TYPES), even using ES6 alias seems to trip up the compiler
 // TODO file bug and reverse
-import * as viewerConfigStore from './viewerConfig.store'
+import { HttpClient } from "@angular/common/http";
+import { actionSetMobileUi, viewerStateNewViewer, viewerStateSelectParcellation, viewerStateSetSelectedRegions } from "./viewerState/actions";
+import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors";
 
-const SET_MOBILE_UI = viewerConfigStore.VIEWER_CONFIG_ACTION_TYPES.SET_MOBILE_UI
+interface ICsp{
+  'connect-src'?: string[]
+  'script-src'?: string[]
+}
 
 export interface StateInterface {
   savedRegionsSelection: RegionSelection[]
+  /**
+   * plugin csp - currently store in localStorage
+   * if user log in, store in user profile
+   */
+  pluginCsp: {
+    /**
+     * key === plugin version id 
+     */
+    [key: string]: ICsp
+  }
 }
 
 export interface RegionSelection {
@@ -43,11 +57,31 @@ interface UserConfigAction extends Action {
 }
 
 export const defaultState: StateInterface = {
-  savedRegionsSelection: []
+  savedRegionsSelection: [],
+  pluginCsp: {}
 }
 
+export const actionUpdateRegionSelections = createAction(
+  `[userConfig] updateRegionSelections`,
+  props<{ config: { savedRegionsSelection: RegionSelection[]} }>()
+)
+
+export const selectorAllPluginsCspPermission = createSelector(
+  (state: any) => state.userConfigState,
+  userConfigState => userConfigState.pluginCsp
+)
+
+export const actionUpdatePluginCsp = createAction(
+  `[userConfig] updatePluginCspPermission`,
+  props<{
+    payload: {
+      [key: string]: ICsp
+    }
+  }>()
+)
+
 export const ACTION_TYPES = {
-  UPDATE_REGIONS_SELECTIONS: `UPDATE_REGIONS_SELECTIONS`,
+  UPDATE_REGIONS_SELECTIONS: actionUpdateRegionSelections.type,
   UPDATE_REGIONS_SELECTION: 'UPDATE_REGIONS_SELECTION',
   SAVE_REGIONS_SELECTION: `SAVE_REGIONS_SELECTIONN`,
   DELETE_REGIONS_SELECTION: 'DELETE_REGIONS_SELECTION',
@@ -55,32 +89,23 @@ export const ACTION_TYPES = {
   LOAD_REGIONS_SELECTION: 'LOAD_REGIONS_SELECTION',
 }
 
-export const getStateStore = ({ state = defaultState } = {}) => (prevState: StateInterface = state, action: UserConfigAction) => {
-  switch (action.type) {
-  case ACTION_TYPES.UPDATE_REGIONS_SELECTIONS: {
-    const { config = {} } = action
+
+export const userConfigReducer = createReducer(
+  defaultState,
+  on(actionUpdateRegionSelections, (state, { config }) => {
     const { savedRegionsSelection } = config
     return {
-      ...prevState,
-      savedRegionsSelection,
+      ...state,
+      savedRegionsSelection
     }
-  }
-  default: return prevState
-  }
-}
-
-// must export a named function for aot compilation
-// see https://github.com/angular/angular/issues/15587
-// https://github.com/amcdnl/ngrx-actions/issues/23
-// or just google for:
-//
-// angular function expressions are not supported in decorators
-
-const defaultStateStore = getStateStore()
-
-export function stateStore(state, action) {
-  return defaultStateStore(state, action)
-}
+  }),
+  on(actionUpdatePluginCsp, (state, { payload }) => {
+    return {
+      ...state,
+      pluginCsp: payload
+    }
+  })
+)
 
 @Injectable({
   providedIn: 'root',
@@ -91,28 +116,28 @@ export class UserConfigStateUseEffect implements OnDestroy {
 
   constructor(
     private actions$: Actions,
-    private store$: Store<IavRootStoreInterface>,
+    private store$: Store<any>,
     private dialogService: DialogService,
+    private http: HttpClient,
   ) {
     const viewerState$ = this.store$.pipe(
       select('viewerState'),
       shareReplay(1),
     )
 
-    this.parcellationSelected$ = viewerState$.pipe(
-      select('parcellationSelected'),
+    this.parcellationSelected$ = this.store$.pipe(
+      select(viewerStateSelectedParcellationSelector),
       distinctUntilChanged(),
-      share(),
     )
 
     this.tprSelected$ = combineLatest(
-      viewerState$.pipe(
-        select('templateSelected'),
+      this.store$.pipe(
+        select(viewerStateSelectedTemplateSelector),
         distinctUntilChanged(),
       ),
       this.parcellationSelected$,
-      viewerState$.pipe(
-        select('regionsSelected'),
+      this.store$.pipe(
+        select(viewerStateSelectedRegionsSelector)
         /**
          * TODO
          * distinct selectedRegions
@@ -124,7 +149,6 @@ export class UserConfigStateUseEffect implements OnDestroy {
           templateSelected, parcellationSelected, regionsSelected,
         }
       }),
-      shareReplay(1),
     )
 
     this.savedRegionsSelections$ = this.store$.pipe(
@@ -224,11 +248,12 @@ export class UserConfigStateUseEffect implements OnDestroy {
             /**
              * template different, dispatch NEWVIEWER
              */
-            this.store$.dispatch({
-              type: NEWVIEWER,
-              selectParcellation: savedRegionsSelection.parcellationSelected,
-              selectTemplate: savedRegionsSelection.templateSelected,
-            })
+            this.store$.dispatch(
+              viewerStateNewViewer({
+                selectParcellation: savedRegionsSelection.parcellationSelected,
+                selectTemplate: savedRegionsSelection.templateSelected,
+              })
+            )
             return this.parcellationSelected$.pipe(
               filter(p => p.updated),
               take(1),
@@ -244,11 +269,11 @@ export class UserConfigStateUseEffect implements OnDestroy {
             /**
              * parcellation different, dispatch SELECT_PARCELLATION
              */
-
-            this.store$.dispatch({
-              type: SELECT_PARCELLATION,
-              selectParcellation: savedRegionsSelection.parcellationSelected,
-            })
+            this.store$.dispatch(
+              viewerStateSelectParcellation({
+                selectParcellation: savedRegionsSelection.parcellationSelected,
+              })
+            )
             return this.parcellationSelected$.pipe(
               filter(p => p.updated),
               take(1),
@@ -265,10 +290,11 @@ export class UserConfigStateUseEffect implements OnDestroy {
           })
         }),
       ).subscribe(({ regionsSelected }) => {
-        this.store$.dispatch({
-          type: SELECT_REGIONS,
-          selectRegions: regionsSelected,
-        })
+        this.store$.dispatch(
+          viewerStateSetSelectedRegions({
+            selectRegions: regionsSelected,
+          })
+        )
       }),
     )
 
@@ -288,8 +314,7 @@ export class UserConfigStateUseEffect implements OnDestroy {
 
     this.subscriptions.push(
       this.actions$.pipe(
-
-        ofType(SET_MOBILE_UI),
+        ofType(actionSetMobileUi.type),
         map((action: any) => {
           const { payload } = action
           const { useMobileUI } = payload
@@ -313,7 +338,7 @@ export class UserConfigStateUseEffect implements OnDestroy {
             name,
             tName: templateSelected.name,
             pName: parcellationSelected.name,
-            rSelected: regionsSelected.map(({ ngId, labelIndex }) => generateLabelIndexId({ ngId, labelIndex })),
+            rSelected: regionsSelected.map(({ ngId, labelIndex }) => serialiseParcellationRegion({ ngId, labelIndex })),
           } as SimpleRegionSelection
         })
 
@@ -334,7 +359,11 @@ export class UserConfigStateUseEffect implements OnDestroy {
       map(fetchedTemplates => savedSRSs.map(({ id, name, tName, pName, rSelected }) => {
         const templateSelected = fetchedTemplates.find(t => t.name === tName)
         const parcellationSelected = templateSelected && templateSelected.parcellations.find(p => p.name === pName)
-        const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({ regions: parcellationSelected.regions, labelIndexId, inheritedNgId: parcellationSelected.ngId }))
+        const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({
+          regions: parcellationSelected.regions,
+          labelIndexId,
+          inheritedNgId: parcellationSelected.ngId
+        }))
         return {
           templateSelected,
           parcellationSelected,
@@ -378,4 +407,15 @@ export class UserConfigStateUseEffect implements OnDestroy {
 
   @Effect()
   public restoreSRSsFromStorage$: Observable<any>
+
+  @Effect()
+  public setInitPluginPermission$ = this.http.get(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, {
+    responseType: 'json'
+  }).pipe(
+    /**
+     * TODO show warning?
+     */
+    catchError(() => of({})),
+    map((json: any) => actionUpdatePluginCsp({ payload: json }))
+  )
 }
diff --git a/src/services/state/viewerConfig.store.ts b/src/services/state/viewerConfig.store.ts
index 74d2a6572..b3a80aeb9 100644
--- a/src/services/state/viewerConfig.store.ts
+++ b/src/services/state/viewerConfig.store.ts
@@ -2,6 +2,7 @@ import { Action } from "@ngrx/store";
 import { LOCAL_STORAGE_CONST } from "src/util/constants";
 
 import { IViewerConfigState as StateInterface } from './viewerConfig.store.helper'
+import { actionSetMobileUi } from "./viewerState/actions";
 export { StateInterface }
 
 interface ViewerConfigurationAction extends Action {
@@ -23,7 +24,7 @@ export const VIEWER_CONFIG_ACTION_TYPES = {
   SET_ANIMATION: `SET_ANIMATION`,
   UPDATE_CONFIG: `UPDATE_CONFIG`,
   CHANGE_GPU_LIMIT: `CHANGE_GPU_LIMIT`,
-  SET_MOBILE_UI: 'SET_MOBILE_UI',
+  SET_MOBILE_UI: actionSetMobileUi.type,
 }
 
 // get gpu limit
diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts
index 1b2efd6f7..1a066605f 100644
--- a/src/services/state/viewerState/actions.ts
+++ b/src/services/state/viewerState/actions.ts
@@ -109,3 +109,8 @@ export const viewerStateChangeNavigation = createAction(
   `[viewerState] changeNavigation`,
   props<{ navigation: any }>()
 )
+
+export const actionSetMobileUi = createAction(
+  `[viewerState] setMobileUi`,
+  props<{ payload: { useMobileUI: boolean } }>()
+)
\ No newline at end of file
diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts
index 1dcde5194..209c3a2a9 100644
--- a/src/services/stateStore.service.ts
+++ b/src/services/stateStore.service.ts
@@ -1,4 +1,12 @@
 import { filter } from 'rxjs/operators';
+export {
+  serialiseParcellationRegion as generateLabelIndexId,
+  deserialiseParcRegionId as getNgIdLabelIndexFromId,
+
+} from 'common/util'
+export {
+  recursiveFindRegionWithLabelIndexId
+} from 'src/util/fn'
 
 export { getNgIds } from 'src/util/fn'
 
@@ -23,7 +31,7 @@ import {
   ACTION_TYPES as USER_CONFIG_ACTION_TYPES,
   defaultState as userConfigDefaultState,
   StateInterface as UserConfigStateInterface,
-  stateStore as userConfigState,
+  userConfigReducer as userConfigState,
 } from './state/userConfigState.store'
 import {
   defaultState as viewerConfigDefaultState,
@@ -149,41 +157,6 @@ export function isDefined(obj) {
   return typeof obj !== 'undefined' && obj !== null
 }
 
-export function generateLabelIndexId({ ngId, labelIndex }) {
-  return `${ngId}#${labelIndex}`
-}
-
-export function getNgIdLabelIndexFromId({ labelIndexId } = {labelIndexId: ''}) {
-  const _ = labelIndexId && labelIndexId.split && labelIndexId.split('#') || []
-  const ngId = _.length > 1
-    ? _[0]
-    : null
-  const labelIndex = _.length > 1
-    ? Number(_[1])
-    : _.length === 0
-      ? null
-      : Number(_[0])
-  return { ngId, labelIndex }
-}
-
-const recursiveFlatten = (region, {ngId}) => {
-  return [{
-    ngId,
-    ...region,
-  }].concat(
-    ...((region.children && region.children.map && region.children.map(c => recursiveFlatten(c, { ngId : region.ngId || ngId })) ) || []),
-  )
-}
-
-export function recursiveFindRegionWithLabelIndexId({ regions, labelIndexId, inheritedNgId = 'root' }: {regions: any[], labelIndexId: string, inheritedNgId: string}) {
-  const { ngId, labelIndex } = getNgIdLabelIndexFromId({ labelIndexId })
-  const fr1 = regions.map(r => recursiveFlatten(r, { ngId: inheritedNgId }))
-  const fr2 = fr1.reduce((acc, curr) => acc.concat(...curr), [])
-  const found = fr2.find(r => r.ngId === ngId && Number(r.labelIndex) === Number(labelIndex))
-  if (found) { return found }
-  return null
-}
-
 export interface IavRootStoreInterface {
   pluginState: PluginStateInterface
   viewerConfigState: ViewerConfigStateInterface
diff --git a/src/ui/config/config.template.html b/src/ui/config/config.template.html
index adfe40f93..deb625baa 100644
--- a/src/ui/config/config.template.html
+++ b/src/ui/config/config.template.html
@@ -201,5 +201,10 @@
       </div>
     </div>
   </mat-tab>
+
+  <!-- plugin csp -->
+  <mat-tab label="Plugin Permission">
+    <plugin-csp-controller></plugin-csp-controller>
+  </mat-tab>
 </mat-tab-group>
 
diff --git a/src/ui/config/pluginCsp/pluginCsp.component.ts b/src/ui/config/pluginCsp/pluginCsp.component.ts
new file mode 100644
index 000000000..73a50c6b4
--- /dev/null
+++ b/src/ui/config/pluginCsp/pluginCsp.component.ts
@@ -0,0 +1,32 @@
+import { Component } from "@angular/core";
+import { select, Store } from "@ngrx/store";
+import { map, tap } from "rxjs/operators";
+import { PluginServices } from "src/atlasViewer/pluginUnit";
+import { selectorAllPluginsCspPermission } from "src/services/state/userConfigState.store";
+
+@Component({
+  selector: 'plugin-csp-controller',
+  templateUrl: './pluginCsp.template.html',
+  styleUrls: [
+    './pluginCsp.style.css'
+  ]
+})
+
+export class PluginCspCtrlCmp{
+
+  public pluginCsp$ = this.store$.pipe(
+    select(selectorAllPluginsCspPermission),
+    map(pluginCsp => Object.keys(pluginCsp).map(key => ({ pluginKey: key, pluginCsp: pluginCsp[key] }))),
+  )
+
+  constructor(
+    private store$: Store<any>,
+    private pluginService: PluginServices,
+  ){
+
+  }
+
+  revoke(pluginKey: string){
+    this.pluginService.revokePluginPermission(pluginKey)
+  }
+}
\ No newline at end of file
diff --git a/src/ui/config/pluginCsp/pluginCsp.style.css b/src/ui/config/pluginCsp/pluginCsp.style.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/ui/config/pluginCsp/pluginCsp.template.html b/src/ui/config/pluginCsp/pluginCsp.template.html
new file mode 100644
index 000000000..477d41a9c
--- /dev/null
+++ b/src/ui/config/pluginCsp/pluginCsp.template.html
@@ -0,0 +1,52 @@
+
+<ng-container *ngIf="pluginCsp$ | async as pluginsCsp; else fallbackTmpl">
+  
+  <ng-template #pluginsCspContainerTmpl>
+    <ng-container *ngTemplateOutlet="pluginCpTmpl; context: { pluginsCsp: pluginsCsp }">
+    </ng-container>  
+  </ng-template>
+  
+  <ng-container *ngIf="pluginsCsp.length === 0; else pluginsCspContainerTmpl">
+    <ng-container *ngTemplateOutlet="fallbackTmpl">
+    </ng-container>
+  </ng-container>
+</ng-container>
+
+<ng-template #fallbackTmpl>
+  You have not granted permission to any plugins.
+</ng-template>
+
+<ng-template #pluginCpTmpl let-pluginsCsp="pluginsCsp">
+  <p>
+    You have granted permission to the following plugins
+  </p>
+  
+  <mat-accordion>
+    <mat-expansion-panel *ngFor="let pluginCsp of pluginCsp$ | async">
+      <mat-expansion-panel-header>
+        <mat-panel-title>
+          {{ pluginCsp['pluginKey'] }}
+        </mat-panel-title>
+      </mat-expansion-panel-header>
+  
+      <button mat-raised-button
+        color="warn"
+        (click)="revoke(pluginCsp['pluginKey'])">
+        Revoke
+      </button>
+  
+      <mat-list>
+        <ng-container *ngFor="let csp of pluginCsp['pluginCsp'] | objToArray">
+          <span mat-subheader>
+            {{ csp['key'] }}
+          </span>
+          <mat-list-item *ngFor="let item of csp['value']">
+            {{ item }}
+          </mat-list-item>
+        </ng-container>
+      </mat-list>
+  
+    </mat-expansion-panel>
+  </mat-accordion>
+  
+</ng-template>
diff --git a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts
index 6feb8bf61..796c6ce28 100644
--- a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts
+++ b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts
@@ -295,7 +295,9 @@ describe('> nehubaContainer.component.ts', () => {
               templateSelected: bigbrainJson,
               parcellationSelected: bigbrainJson.parcellations[0],
               regionsSelected: [{
-                name: "foobar"
+                name: "foobar",
+                ngId: 'untitled',
+                labelIndex: 15
               }]
             },
             [viewerStateHelperStoreName]: {
diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts
index 0c116696a..b45831e8f 100644
--- a/src/ui/ui.module.ts
+++ b/src/ui/ui.module.ts
@@ -85,6 +85,7 @@ import { RegionAccordionTooltipTextPipe } from './util'
 import { HelpOnePager } from "./helpOnePager/helpOnePager.component";
 import { RegionalFeaturesModule } from "./regionalFeatures";
 import { Landmark2DModule } from "./nehubaContainer/2dLandmarks/module";
+import { PluginCspCtrlCmp } from "./config/pluginCsp/pluginCsp.component";
 
 @NgModule({
   imports : [
@@ -123,6 +124,7 @@ import { Landmark2DModule } from "./nehubaContainer/2dLandmarks/module";
     AtlasDropdownSelector,
     AtlasLayerSelector,
     AtlasDropdownSelector,
+    PluginCspCtrlCmp,
 
     StatusCardComponent,
     CookieAgreement,
diff --git a/src/util/fn.ts b/src/util/fn.ts
index c589873f2..9307ae093 100644
--- a/src/util/fn.ts
+++ b/src/util/fn.ts
@@ -1,3 +1,5 @@
+import { deserialiseParcRegionId } from 'common/util'
+
 export function isSame(o, n) {
   if (!o) { return !n }
   return o === n || (o && n && o.name === n.name)
@@ -31,3 +33,21 @@ export function getNgIds(regions: any[]): string[] {
       .filter(ngId => !!ngId)
     : []
 }
+
+const recursiveFlatten = (region, {ngId}) => {
+  return [{
+    ngId,
+    ...region,
+  }].concat(
+    ...((region.children && region.children.map && region.children.map(c => recursiveFlatten(c, { ngId : region.ngId || ngId })) ) || []),
+  )
+}
+
+export function recursiveFindRegionWithLabelIndexId({ regions, labelIndexId, inheritedNgId = 'root' }: {regions: any[], labelIndexId: string, inheritedNgId: string}) {
+  const { ngId, labelIndex } = deserialiseParcRegionId( labelIndexId )
+  const fr1 = regions.map(r => recursiveFlatten(r, { ngId: inheritedNgId }))
+  const fr2 = fr1.reduce((acc, curr) => acc.concat(...curr), [])
+  const found = fr2.find(r => r.ngId === ngId && Number(r.labelIndex) === Number(labelIndex))
+  if (found) { return found }
+  return null
+}
diff --git a/src/util/pipes/objToArray.pipe.spec.ts b/src/util/pipes/objToArray.pipe.spec.ts
new file mode 100644
index 000000000..d1a54e082
--- /dev/null
+++ b/src/util/pipes/objToArray.pipe.spec.ts
@@ -0,0 +1,17 @@
+import { ObjectToArrayPipe } from "./objToArray.pipe"
+
+describe('> objToArray.pipe.ts', () => {
+  describe('> ObjectToArrayPipe', () => {
+    const pipe = new ObjectToArrayPipe()
+    it('> transforms obj to array', () => {
+      const result = pipe.transform({'a': '1', 'b': '2'})
+      expect(result).toEqual([{
+        key: 'a',
+        value: '1'
+      }, {
+        key: 'b',
+        value: '2'
+      }])
+    })
+  })
+})
\ No newline at end of file
diff --git a/src/util/pipes/objToArray.pipe.ts b/src/util/pipes/objToArray.pipe.ts
new file mode 100644
index 000000000..9320b3631
--- /dev/null
+++ b/src/util/pipes/objToArray.pipe.ts
@@ -0,0 +1,22 @@
+import { Pipe, PipeTransform } from "@angular/core";
+
+interface ITransformedObj{
+  key: string
+  value: string
+}
+
+@Pipe({
+  name: 'objToArray',
+  pure: true
+})
+
+export class ObjectToArrayPipe implements PipeTransform{
+  public transform(input: { [key: string]: any }): ITransformedObj[]{
+    return Object.keys(input).map(key => {
+      return {
+        key,
+        value: input[key]
+      }
+    })
+  }
+}
\ No newline at end of file
diff --git a/src/util/util.module.ts b/src/util/util.module.ts
index 8d45bb6c9..ad736e0d1 100644
--- a/src/util/util.module.ts
+++ b/src/util/util.module.ts
@@ -25,6 +25,7 @@ import { FilterByPropertyPipe } from "./pipes/filterByProperty.pipe";
 import { ArrayContainsPipe } from "./pipes/arrayContains.pipe";
 import { DoiParserPipe } from "./pipes/doiPipe.pipe";
 import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe";
+import { ObjectToArrayPipe } from "./pipes/objToArray.pipe";
 
 @NgModule({
   imports:[
@@ -55,6 +56,7 @@ import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe";
     ArrayContainsPipe,
     DoiParserPipe,
     TmpParcNamePipe,
+    ObjectToArrayPipe,
   ],
   exports: [
     FilterNullPipe,
@@ -81,6 +83,7 @@ import { TmpParcNamePipe } from "./pipes/_tmpParcName.pipe";
     ArrayContainsPipe,
     DoiParserPipe,
     TmpParcNamePipe,
+    ObjectToArrayPipe,
   ]
 })
 
-- 
GitLab