openwhisk-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From rab...@apache.org
Subject [incubator-openwhisk] branch master updated: Scala based admin tooling wskadmin-next (#3722)
Date Tue, 26 Jun 2018 12:32:37 GMT
This is an automated email from the ASF dual-hosted git repository.

rabbah pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-openwhisk.git


The following commit(s) were added to refs/heads/master by this push:
     new daa14c6  Scala based admin tooling wskadmin-next (#3722)
daa14c6 is described below

commit daa14c6cd515fd20482951218c292474738cc0ed
Author: Chetan Mehrotra <chetanm@apache.org>
AuthorDate: Tue Jun 26 18:02:33 2018 +0530

    Scala based admin tooling wskadmin-next (#3722)
    
    Introduced wskadmin-next, an implementation of wskadmin in Scala (packaged as a fat executable) to support datastores other than CouchDB (which is the limitation of wskadmin) by sharing the implementation of the ArtifactStore.
    
    See the documentation for how to use the new CLI. Briefly
    
    > wskadmin-cli -c application-cli.conf user get guest
    
    where wskadmin-cli is the executable fat jar (Gradle build would copy this to ${OPENWHISK_HOME}/bin folder) and application-cli.conf contains configuration details.
    
    include classpath("application.conf")
    
    whisk {
      couchdb {
        protocol = "http"
        host     = "172.17.0.1"
        port     = "5984"
        username = "whisk_admin"
        password = "some_passw0rd"
        provider = "CouchDB"
        databases {
          WhiskAuth       = "whisk_local_subjects"
          WhiskEntity     = "whisk_local_whisks"
          WhiskActivation = "whisk_local_activations"
        }
      }
    }
---
 settings.gradle                                    |   2 +
 tests/build.gradle                                 |   7 +-
 .../whisk/core/database/LimitsCommandTests.scala   | 106 ++++++
 .../whisk/core/database/UserCommandTests.scala     | 285 +++++++++++++++++
 .../core/database/WhiskAdminCliTestBase.scala      |  75 +++++
 tools/admin/README-NEXT.md                         | 126 ++++++++
 settings.gradle => tools/admin/build.gradle        |  51 +--
 .../scala/whisk/core/cli/CommandMessages.scala     |  45 +++
 .../admin/src/main/scala/whisk/core/cli/Main.scala | 195 ++++++++++++
 .../scala/whisk/core/database/LimitsCommand.scala  | 185 +++++++++++
 .../scala/whisk/core/database/UserCommand.scala    | 354 +++++++++++++++++++++
 11 files changed, 1408 insertions(+), 23 deletions(-)

diff --git a/settings.gradle b/settings.gradle
index 283120b..9e37f87 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -25,6 +25,8 @@ include 'tests:performance:gatling_tests'
 
 include 'tools:actionProxy'
 
+include 'tools:admin'
+
 rootProject.name = 'openwhisk'
 
 gradle.ext.scala = [
diff --git a/tests/build.gradle b/tests/build.gradle
index 50847fc..32fff68 100644
--- a/tests/build.gradle
+++ b/tests/build.gradle
@@ -147,6 +147,7 @@ dependencies {
     compile project(':common:scala')
     compile project(':core:controller')
     compile project(':core:invoker')
+    compile project(':tools:admin')
 
     scoverage gradle.scoverage.deps
 }
@@ -197,7 +198,8 @@ task reportCoverage(type: ScoverageReport) {
     dependsOn([
         ':common:scala:reportScoverage',
         ':core:controller:reportScoverage',
-        ':core:invoker:reportScoverage'
+        ':core:invoker:reportScoverage',
+        ':tools:admin:reportScoverage'
     ])
 }
 
@@ -228,7 +230,8 @@ def getScoverageClasspath(Project project) {
     def projectNames = [
         ':common:scala',
         ':core:controller',
-        ':core:invoker'
+        ':core:invoker',
+        ':tools:admin'
     ]
     def combinedClasspath = projectNames.inject(project.files([])) { result, name ->
         result + project.project(name).sourceSets.scoverage.runtimeClasspath
diff --git a/tests/src/test/scala/whisk/core/database/LimitsCommandTests.scala b/tests/src/test/scala/whisk/core/database/LimitsCommandTests.scala
new file mode 100644
index 0000000..e1b1660
--- /dev/null
+++ b/tests/src/test/scala/whisk/core/database/LimitsCommandTests.scala
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.database
+
+import org.junit.runner.RunWith
+import org.scalatest.FlatSpec
+import org.scalatest.junit.JUnitRunner
+import whisk.common.TransactionId
+import whisk.core.cli.CommandMessages
+import whisk.core.database.LimitsCommand.LimitEntity
+import whisk.core.entity.{DocInfo, EntityName, UserLimits}
+
+import scala.collection.mutable.ListBuffer
+import scala.concurrent.duration.Duration
+import scala.util.Try
+
+@RunWith(classOf[JUnitRunner])
+class LimitsCommandTests extends FlatSpec with WhiskAdminCliTestBase {
+  private val limitsToDelete = ListBuffer[String]()
+
+  protected val limitsStore = LimitsCommand.createDataStore()
+
+  behavior of "limits"
+
+  it should "set limits for non existing namespace" in {
+    implicit val tid = transid()
+    val ns = newNamespace()
+    resultOk(
+      "limits",
+      "set",
+      "--invocationsPerMinute",
+      "3",
+      "--firesPerMinute",
+      "7",
+      "--concurrentInvocations",
+      "11",
+      ns) shouldBe CommandMessages.limitsSuccessfullySet(ns)
+
+    val limits = limitsStore.get[LimitEntity](DocInfo(LimitsCommand.limitIdOf(EntityName(ns)))).futureValue
+    limits.limits shouldBe UserLimits(Some(3), Some(7), Some(11))
+
+    resultOk("limits", "set", "--invocationsPerMinute", "13", ns) shouldBe CommandMessages.limitsSuccessfullyUpdated(ns)
+
+    val limits2 = limitsStore.get[LimitEntity](DocInfo(LimitsCommand.limitIdOf(EntityName(ns)))).futureValue
+    limits2.limits shouldBe UserLimits(Some(13), None, None)
+  }
+
+  it should "set and get limits" in {
+    val ns = newNamespace()
+    resultOk("limits", "set", "--invocationsPerMinute", "13", ns)
+    resultOk("limits", "get", ns) shouldBe "invocationsPerMinute = 13"
+  }
+
+  it should "respond with default system limits apply for non existing namespace" in {
+    resultOk("limits", "get", "non-existing-ns") shouldBe CommandMessages.defaultLimits
+  }
+
+  it should "delete an existing limit" in {
+    val ns = newNamespace()
+    resultOk("limits", "set", "--invocationsPerMinute", "13", ns)
+    resultOk("limits", "get", ns) shouldBe "invocationsPerMinute = 13"
+
+    //Delete
+    resultOk("limits", "delete", ns) shouldBe CommandMessages.limitsDeleted
+
+    //Read after delete should result in default message
+    resultOk("limits", "get", ns) shouldBe CommandMessages.defaultLimits
+
+    //Delete of deleted namespace should result in error
+    resultNotOk("limits", "delete", ns) shouldBe CommandMessages.limitsNotFound(ns)
+  }
+
+  override def cleanup()(implicit timeout: Duration): Unit = {
+    implicit val tid = TransactionId.testing
+    limitsToDelete.map { u =>
+      Try {
+        val limit = limitsStore.get[LimitEntity](DocInfo(LimitsCommand.limitIdOf(EntityName(u)))).futureValue
+        delete(limitsStore, limit.docinfo)
+      }
+    }
+    limitsToDelete.clear()
+    super.cleanup()
+  }
+
+  private def newNamespace(): String = {
+    val ns = randomString()
+    limitsToDelete += ns
+    ns
+  }
+
+}
diff --git a/tests/src/test/scala/whisk/core/database/UserCommandTests.scala b/tests/src/test/scala/whisk/core/database/UserCommandTests.scala
new file mode 100644
index 0000000..9e69b0d
--- /dev/null
+++ b/tests/src/test/scala/whisk/core/database/UserCommandTests.scala
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.database
+
+import org.junit.runner.RunWith
+import org.scalatest.FlatSpec
+import org.scalatest.junit.JUnitRunner
+import whisk.common.TransactionId
+import whisk.core.cli.{CommandMessages, Conf, WhiskAdmin}
+import whisk.core.entity.{AuthKey, DocId, DocInfo, EntityName, Identity, Namespace, Subject, WhiskAuth, WhiskNamespace}
+
+import scala.collection.mutable.ListBuffer
+import scala.concurrent.duration.Duration
+import scala.util.Try
+import whisk.core.database.UserCommand.ExtendedAuth
+
+@RunWith(classOf[JUnitRunner])
+class UserCommandTests extends FlatSpec with WhiskAdminCliTestBase {
+  private val usersToDelete = ListBuffer[String]()
+
+  behavior of "create user"
+
+  it should "fail for subject less than length 5" in {
+    the[Exception] thrownBy {
+      new Conf(Seq("user", "create", "foo"))
+    } should have message CommandMessages.shortName
+  }
+
+  it should "fail for short key" in {
+    the[Exception] thrownBy {
+      new Conf(Seq("user", "create", "--auth", "uid:shortKey", "foobar"))
+    } should have message CommandMessages.shortKey
+  }
+
+  it should "fail for invalid uuid" in {
+    val key = "x" * 64
+    the[Exception] thrownBy {
+      new Conf(Seq("user", "create", "--auth", s"uid:$key", "foobar"))
+    } should have message CommandMessages.invalidUUID
+  }
+
+  it should "create a user" in {
+    val subject = newSubject()
+    val key = AuthKey()
+    val conf = new Conf(Seq("user", "create", "--auth", key.compact, subject))
+    val admin = WhiskAdmin(conf)
+    admin.executeCommand().futureValue.right.get shouldBe key.compact
+  }
+
+  it should "add namespace to existing user" in {
+    val subject = newSubject()
+    val key = AuthKey()
+
+    //Create user
+    WhiskAdmin(new Conf(Seq("user", "create", "--auth", key.compact, subject))).executeCommand().futureValue
+
+    //Add new namespace
+    val key2 = AuthKey()
+    resultOk("user", "create", "--auth", key2.compact, "--namespace", "foo", subject) shouldBe key2.compact
+
+    //Adding same namespace should fail
+    resultNotOk("user", "create", "--auth", key2.compact, "--namespace", "foo", subject) shouldBe CommandMessages.namespaceExists
+
+    //It should be possible to lookup by new namespace
+    implicit val tid = transid()
+    val i = Identity.get(authStore, EntityName("foo")).futureValue
+    i.subject.asString shouldBe subject
+  }
+
+  it should "not add namespace to a blocked user" in {
+    val subject = newSubject()
+    val ns = randomString()
+    val blockedAuth = new ExtendedAuth(Subject(subject), Set(newNS(EntityName(ns), AuthKey())), Some(true))
+    val authStore2 = UserCommand.createDataStore()
+
+    implicit val tid = transid()
+    authStore2.put(blockedAuth).futureValue
+
+    resultNotOk("user", "create", "--namespace", "foo", subject) shouldBe CommandMessages.subjectBlocked
+
+    authStore2.shutdown()
+  }
+
+  behavior of "delete user"
+
+  it should "fail deleting non existing user" in {
+    resultNotOk("user", "delete", "non-existing-user") shouldBe CommandMessages.subjectMissing
+  }
+
+  it should "delete existing user" in {
+    val subject = newSubject()
+    val key = AuthKey()
+
+    //Create user
+    WhiskAdmin(new Conf(Seq("user", "create", "--auth", key.compact, subject))).executeCommand().futureValue
+
+    resultOk("user", "delete", subject) shouldBe CommandMessages.subjectDeleted
+  }
+
+  it should "remove namespace from existing user" in {
+    implicit val tid = transid()
+    val subject = newSubject()
+
+    val ns1 = newNS()
+    val ns2 = newNS()
+
+    val auth = WhiskAuth(Subject(subject), Set(ns1, ns2))
+
+    put(authStore, auth)
+
+    resultOk("user", "delete", "--namespace", ns1.namespace.name.asString, subject) shouldBe CommandMessages.namespaceDeleted
+
+    val authFromDB = authStore.get[WhiskAuth](DocInfo(DocId(subject))).futureValue
+    authFromDB.namespaces shouldBe Set(ns2)
+  }
+
+  it should "not remove missing namespace" in {
+    implicit val tid = transid()
+    val subject = newSubject()
+    val auth = WhiskAuth(Subject(subject), Set(newNS(), newNS()))
+
+    put(authStore, auth)
+    resultNotOk("user", "delete", "--namespace", "non-existing-ns", subject) shouldBe
+      CommandMessages.namespaceMissing("non-existing-ns", subject)
+  }
+
+  behavior of "get key"
+
+  it should "not get key for missing subject" in {
+    resultNotOk("user", "get", "non-existing-user") shouldBe CommandMessages.subjectMissing
+  }
+
+  it should "get key for existing user" in {
+    implicit val tid = transid()
+    val subject = newSubject()
+
+    val ns1 = newNS()
+    val ns2 = newNS()
+    val ns3 = newNS(EntityName(subject), AuthKey())
+
+    val auth = WhiskAuth(Subject(subject), Set(ns1, ns2, ns3))
+    put(authStore, auth)
+
+    resultOk("user", "get", "--namespace", ns1.namespace.name.asString, subject) shouldBe ns1.authkey.compact
+
+    val all = resultOk("user", "get", "--all", subject)
+
+    all should include(ns1.authkey.compact)
+    all should include(ns2.authkey.compact)
+    all should include(ns3.authkey.compact)
+
+    //Is --namespace is not there look by subject
+    resultOk("user", "get", subject) shouldBe ns3.authkey.compact
+
+    //Look for namespace which does not exist
+    resultNotOk("user", "get", "--namespace", "non-existing-ns", subject) shouldBe
+      CommandMessages.namespaceMissing("non-existing-ns", subject)
+  }
+
+  behavior of "whois"
+
+  it should "not get subject for missing subject" in {
+    resultNotOk("user", "whois", AuthKey().compact) shouldBe CommandMessages.subjectMissing
+  }
+
+  it should "get key for existing user" in {
+    implicit val tid = transid()
+    val subject = newSubject()
+
+    val ns1 = newNS()
+    val ns3 = newNS(EntityName(subject), AuthKey())
+
+    val auth = WhiskAuth(Subject(subject), Set(ns1, ns3))
+    put(authStore, auth)
+
+    val result = resultOk("user", "whois", ns1.authkey.compact)
+
+    result should include(subject)
+    result should include(ns1.namespace.name.asString)
+  }
+
+  behavior of "list"
+
+  it should "list keys associated with given namespace" in {
+    implicit val tid = transid()
+    def newWhiskAuth(ns: String*) =
+      WhiskAuth(Subject(newSubject()), ns.map(n => newNS(EntityName(n), AuthKey())).toSet)
+
+    def key(a: WhiskAuth, ns: String) = a.namespaces.find(_.namespace.name.asString == ns).map(_.authkey).get
+
+    val ns1 = randomString()
+    val ns2 = randomString()
+    val ns3 = randomString()
+
+    val a1 = newWhiskAuth(ns1)
+    val a2 = newWhiskAuth(ns1, ns2)
+    val a3 = newWhiskAuth(ns1, ns2, ns3)
+
+    Seq(a1, a2, a3).foreach(put(authStore, _))
+
+    Seq(a1, a2, a3).foreach(a => waitOnView(authStore, key(a, ns1), 1))
+
+    //Check negative case
+    resultNotOk("user", "list", "non-existing-ns") shouldBe CommandMessages.namespaceMissing("non-existing-ns")
+
+    //Check all results
+    val r1 = resultOk("user", "list", ns1)
+    r1.split("\n").length shouldBe 3
+    r1 should include(a1.subject.asString)
+    r1 should include(key(a2, ns1).compact)
+
+    //Check limit
+    val r2 = resultOk("user", "list", "-p", "2", ns1)
+    r2.split("\n").length shouldBe 2
+
+    //Check key only
+    val r3 = resultOk("user", "list", "-k", ns1)
+    r3 should include(key(a2, ns1).compact)
+    r3 should not include a2.subject.asString
+  }
+
+  behavior of "block"
+
+  it should "block subjects" in {
+    implicit val tid = transid()
+    val a1 = WhiskAuth(Subject(newSubject()), Set(newNS()))
+    val a2 = WhiskAuth(Subject(newSubject()), Set(newNS()))
+    val a3 = WhiskAuth(Subject(newSubject()), Set(newNS()))
+
+    Seq(a1, a2, a3).foreach(put(authStore, _))
+
+    val r1 = resultOk("user", "block", a1.subject.asString, a2.subject.asString)
+
+    val authStore2 = UserCommand.createDataStore()
+
+    authStore2.get[ExtendedAuth](a1.docinfo).futureValue.isBlocked shouldBe true
+    authStore2.get[ExtendedAuth](a2.docinfo).futureValue.isBlocked shouldBe true
+    authStore2.get[ExtendedAuth](a3.docinfo).futureValue.isBlocked shouldBe false
+
+    val r2 = resultOk("user", "unblock", a2.subject.asString, a3.subject.asString)
+
+    authStore2.get[ExtendedAuth](a1.docinfo).futureValue.isBlocked shouldBe true
+    authStore2.get[ExtendedAuth](a2.docinfo).futureValue.isBlocked shouldBe false
+    authStore2.get[ExtendedAuth](a3.docinfo).futureValue.isBlocked shouldBe false
+
+    authStore2.shutdown()
+  }
+
+  override def cleanup()(implicit timeout: Duration): Unit = {
+    implicit val tid = TransactionId.testing
+    usersToDelete.map { u =>
+      Try {
+        val auth = authStore.get[WhiskAuth](DocInfo(u)).futureValue
+        delete(authStore, auth.docinfo)
+      }
+    }
+    usersToDelete.clear()
+    super.cleanup()
+  }
+
+  private def newNS(): WhiskNamespace = newNS(EntityName(randomString()), AuthKey())
+
+  private def newNS(name: EntityName, authKey: AuthKey) = WhiskNamespace(Namespace(name, authKey.uuid), authKey)
+
+  private def newSubject(): String = {
+    val subject = randomString()
+    usersToDelete += subject
+    subject
+  }
+}
diff --git a/tests/src/test/scala/whisk/core/database/WhiskAdminCliTestBase.scala b/tests/src/test/scala/whisk/core/database/WhiskAdminCliTestBase.scala
new file mode 100644
index 0000000..0bbba02
--- /dev/null
+++ b/tests/src/test/scala/whisk/core/database/WhiskAdminCliTestBase.scala
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.database
+
+import akka.stream.ActorMaterializer
+import common.{StreamLogging, WskActorSystem}
+import org.rogach.scallop.throwError
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, FlatSpec, Matchers}
+import whisk.core.cli.{Conf, WhiskAdmin}
+import whisk.core.database.test.DbUtils
+import whisk.core.entity.WhiskAuthStore
+
+import scala.util.Random
+
+trait WhiskAdminCliTestBase
+    extends FlatSpec
+    with WskActorSystem
+    with DbUtils
+    with StreamLogging
+    with BeforeAndAfterEach
+    with BeforeAndAfterAll
+    with ScalaFutures
+    with Matchers {
+
+  implicit val materializer = ActorMaterializer()
+  //Bring in sync the timeout used by ScalaFutures and DBUtils
+  implicit override val patienceConfig: PatienceConfig = PatienceConfig(timeout = dbOpTimeout)
+  protected val authStore = WhiskAuthStore.datastore()
+
+  //Ensure scalaop does not exit upon validation failure
+  throwError.value = true
+
+  override def afterEach(): Unit = {
+    cleanup()
+  }
+
+  override def afterAll(): Unit = {
+    println("Shutting down store connections")
+    authStore.shutdown()
+    super.afterAll()
+  }
+
+  protected def randomString(len: Int = 5): String = Random.alphanumeric.take(len).mkString
+
+  protected def resultOk(args: String*): String =
+    WhiskAdmin(new Conf(args.toSeq))
+      .executeCommand()
+      .futureValue
+      .right
+      .get
+
+  protected def resultNotOk(args: String*): String =
+    WhiskAdmin(new Conf(args.toSeq))
+      .executeCommand()
+      .futureValue
+      .left
+      .get
+      .message
+}
diff --git a/tools/admin/README-NEXT.md b/tools/admin/README-NEXT.md
new file mode 100644
index 0000000..6d74e52
--- /dev/null
+++ b/tools/admin/README-NEXT.md
@@ -0,0 +1,126 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+## Administrative Operations
+
+The `wskadmin-next` utility is handy for performing various administrative operations against an OpenWhisk deployment.
+It allows you to create a new subject, manage their namespaces, to block a subject or delete their record entirely.
+
+This is a scala based implementation of `wskadmin` utility and is meant to be DB agnostic.
+
+### Build
+
+To build the tool run
+
+    $./gradlew :tools:admin:build
+
+This creates a jar at `tools/admin/build/libs/openwhisk-admin-tools-1.0.0-SNAPSHOT-cli.jar` and install it as an executable script at
+`bin/wskadmin-next`.
+
+### Setup
+
+Build task creates an executable at `bin/wskadmin-next`. This script requires config related to `ArtifactStore`
+for accessing database. For example to access user details from default CouchDB setup create a file `application-cli.conf`.
+
+    include classpath("application.conf")
+
+    whisk {
+      couchdb {
+        protocol = "http"
+        host     = "172.17.0.1"
+        port     = "5984"
+        username = "whisk_admin"
+        password = "some_passw0rd"
+        provider = "CouchDB"
+        databases {
+          WhiskAuth       = "whisk_local_subjects"
+          WhiskEntity     = "whisk_local_whisks"
+          WhiskActivation = "whisk_local_activations"
+        }
+      }
+    }
+
+And pass that to command via `-c` option.
+
+    $./wskadmin-next -c application-cli.conf user get guest
+
+
+### Managing Users (subjects)
+
+The `wskadmin-next user -h` command prints the help message for working with subject records. You can create and delete a
+new user, list all their namespaces or keys for a specific namespace, identify a user by their key, block/unblock a subject,
+and list all keys that have access to a particular namespace.
+
+Some examples:
+```bash
+# create a new user
+$ wskadmin-next -c application-cli.conf user create userA
+<prints key>
+
+# add user to a specific namespace
+$ wskadmin-next -c application-cli.conf user create userA -ns space1
+<prints new key specific to userA and space1>
+
+# add second user to same space
+$ wskadmin-next -c application-cli.conf user create userB -ns space1
+<prints new key specific to userB and space1>
+
+# list all users sharing a space
+$ wskadmin-next -c application-cli.conf user list space1 -a
+<key for userA>   userA
+<key for userB>   userB
+
+# remove user access to a namespace
+$ wskadmin-next -c application-cli.conf user delete userB -ns space1
+Namespace deleted
+
+# get key for userA default namespaces
+$ wskadmin-next -c application-cli.conf user get userA
+<prints key specific to userA default namespace>
+
+# block a user
+$ wskadmin-next -c application-cli.conf user block userA
+"userA" blocked successfully
+
+# unblock a user
+$ wskadmin-next -c application-cli.conf user unblock userA
+"userA" unblocked successfully
+
+# delete user
+$ wskadmin-next -c application-cli.conf user delete userB
+Subject deleted
+```
+
+The `wskadmin-next limits` commands allow you set action and trigger throttles per namespace.
+
+```bash
+# see if custom limits are set for a namespace
+$ wskadmin-next -c application-cli.conf limits get space1
+No limits found, default system limits apply
+
+# set limits
+$ wskadmin-next -c application-cli.conf limits set space1 --invocationsPerMinute 1
+Limits successfully set for "space1"
+```
+
+Note that limits apply to a namespace and will survive even if all users that share a namespace are deleted. You must manually delete them.
+```bash
+$ wskadmin-next -c application-cli.conf limits delete space1
+Limits deleted
+```
diff --git a/settings.gradle b/tools/admin/build.gradle
similarity index 53%
copy from settings.gradle
copy to tools/admin/build.gradle
index 283120b..ab8aff2 100644
--- a/settings.gradle
+++ b/tools/admin/build.gradle
@@ -15,31 +15,40 @@
  * limitations under the License.
  */
 
-include 'common:scala'
+plugins {
+    id 'org.springframework.boot' version '2.0.2.RELEASE'
+    id 'scala'
+}
 
-include 'core:controller'
-include 'core:invoker'
+apply plugin: 'org.scoverage'
 
-include 'tests'
-include 'tests:performance:gatling_tests'
+project.archivesBaseName = "openwhisk-admin-tools"
 
-include 'tools:actionProxy'
+repositories {
+    mavenCentral()
+}
 
-rootProject.name = 'openwhisk'
+jar {
+    enabled = true
+}
 
-gradle.ext.scala = [
-    version: '2.11.11',
-    compileFlags: ['-feature', '-unchecked', '-deprecation', '-Xfatal-warnings', '-Ywarn-unused-import']
-]
+task copyBootJarToBin(type:Copy){
+    from ("${buildDir}/libs")
+    into file("${project.rootProject.projectDir}/bin")
+    rename("${project.archivesBaseName}-$version-cli.jar", "wskadmin-next")
+}
+
+bootJar {
+    classifier = 'cli'
+    mainClassName = 'whisk.core.cli.Main'
+    launchScript()
+    finalizedBy copyBootJarToBin
+}
+
+dependencies {
+    compile project(':common:scala')
+    compile 'org.rogach:scallop_2.11:3.1.2'
+    scoverage gradle.scoverage.deps
+}
 
-gradle.ext.scalafmt = [
-    version: '1.5.0',
-    config: new File(rootProject.projectDir, '.scalafmt.conf')
-]
 
-gradle.ext.scoverage = [
-    deps: [
-        'org.scoverage:scalac-scoverage-plugin_2.11:1.3.1',
-        'org.scoverage:scalac-scoverage-runtime_2.11:1.3.1'
-    ]
-]
diff --git a/tools/admin/src/main/scala/whisk/core/cli/CommandMessages.scala b/tools/admin/src/main/scala/whisk/core/cli/CommandMessages.scala
new file mode 100644
index 0000000..5728950
--- /dev/null
+++ b/tools/admin/src/main/scala/whisk/core/cli/CommandMessages.scala
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.cli
+
+object CommandMessages {
+  val subjectBlocked = "The subject you want to edit is blocked"
+  val namespaceExists = "Namespace already exists"
+  val shortName = "Subject name must be at least 5 characters"
+  val invalidUUID = "authorization id is not a valid UUID"
+  val shortKey = "authorization key must be at least 64 characters long"
+
+  val subjectMissing = "Subject to delete not found"
+
+  def namespaceMissing(ns: String, u: String) = s"Namespace '$ns' does not exist for '$u'"
+  val namespaceDeleted = "Namespace deleted"
+  val subjectDeleted = "Subject deleted"
+
+  def namespaceMissing(ns: String) = s"no identities found for namespace  '$ns'"
+  def blocked(subject: String) = s"'$subject' blocked successfully"
+  def unblocked(subject: String) = s"'$subject' unblocked successfully"
+  def subjectMissing(subject: String) = s"'$subject missing"
+
+  def limitsSuccessfullyUpdated(namespace: String) = s"Limits successfully updated for '$namespace'"
+  def limitsSuccessfullySet(namespace: String) = s"Limits successfully set for '$namespace'"
+  val defaultLimits = "No limits found, default system limits apply"
+
+  def limitsNotFound(namespace: String) = s"Limits not found for '$namespace'"
+  val limitsDeleted = s"Limits deleted"
+
+}
diff --git a/tools/admin/src/main/scala/whisk/core/cli/Main.scala b/tools/admin/src/main/scala/whisk/core/cli/Main.scala
new file mode 100644
index 0000000..58d6e4d
--- /dev/null
+++ b/tools/admin/src/main/scala/whisk/core/cli/Main.scala
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.cli
+
+import java.io.File
+
+import akka.actor.ActorSystem
+import akka.http.scaladsl.Http
+import akka.stream.ActorMaterializer
+import ch.qos.logback.classic.{Level, LoggerContext}
+import org.rogach.scallop._
+import org.slf4j.LoggerFactory
+import pureconfig.error.ConfigReaderException
+import whisk.common.{AkkaLogging, Logging, TransactionId}
+import whisk.core.database.{LimitsCommand, UserCommand}
+
+import scala.concurrent.duration.{Duration, DurationInt}
+import scala.concurrent.{Await, Future}
+import scala.util.{Failure, Success, Try}
+
+class Conf(arguments: Seq[String]) extends ScallopConf(arguments) {
+  banner("OpenWhisk admin command line tool")
+  val durationConverter = singleArgConverter[Duration](Duration(_))
+
+  //Spring boot launch script changes the working directory to one where jar file is present
+  //So invocation like ./bin/wskadmin-next -c config.conf would fail to resolve file as it would
+  //be looked in directory where jar is present. This convertor makes use of `OLDPWD` to also
+  //do a fallback check in that directory
+  val fileConverter = singleArgConverter[File] { f =>
+    val f1 = new File(f)
+    val oldpwd = System.getenv("OLDPWD")
+    if (f1.exists())
+      f1
+    else if (oldpwd != null) {
+      val f2 = new File(oldpwd, f)
+      if (f2.exists()) f2 else f1
+    } else {
+      f1
+    }
+  }
+  val verbose = tally()
+  val configFile = opt[File](descr = "application.conf path")(fileConverter)
+  val timeout =
+    opt[Duration](descr = "time to wait for asynchronous task to finish", default = Some(30.seconds))(durationConverter)
+  printedName = Main.printedName
+
+  def verboseEnabled: Boolean = verbose() > 0
+
+  addSubcommand(new UserCommand)
+  addSubcommand(new LimitsCommand)
+  shortSubcommandsHelp()
+
+  requireSubcommand()
+  validateFileExists(configFile)
+  verify()
+}
+
+object Main {
+  val printedName = "wskadmin"
+
+  def main(args: Array[String]) {
+    //Parse conf before instantiating actorSystem to ensure fast pre check of config
+    val conf = new Conf(args)
+    initLogging(conf)
+    initConfig(conf)
+
+    conf.subcommands match {
+      case List(c: WhiskCommand) => c.failNoSubCommand()
+      case _                     =>
+    }
+    val exitCode = execute(conf)
+    System.exit(exitCode)
+  }
+
+  private def execute(conf: Conf): Int = {
+    implicit val actorSystem = ActorSystem("admin-cli")
+    try {
+      executeWithSystem(conf)
+    } finally {
+      Await.result(Http().shutdownAllConnectionPools(), 60.seconds)
+      actorSystem.terminate()
+      Await.result(actorSystem.whenTerminated, 60.seconds)
+    }
+  }
+
+  private def executeWithSystem(conf: Conf)(implicit actorSystem: ActorSystem): Int = {
+    implicit val logger = new AkkaLogging(akka.event.Logging.getLogger(actorSystem, this))
+    implicit val materializer = ActorMaterializer.create(actorSystem)
+
+    val admin = new WhiskAdmin(conf)
+    val result = Try {
+      val f = admin.executeCommand()
+      Await.result(f, admin.timeout)
+    }
+    result match {
+      case Success(r) =>
+        r match {
+          case Right(msg) =>
+            println(msg)
+            0
+          case Left(e) =>
+            printErr(e.message)
+            e.code
+        }
+      case Failure(e) =>
+        e match {
+          case _: ConfigReaderException[_] =>
+            printErr("Incomplete config. Provide application.conf via '-c' option")
+            if (conf.verboseEnabled) {
+              e.printStackTrace()
+            }
+          case _ =>
+            e.printStackTrace()
+        }
+        3
+    }
+  }
+
+  private def initConfig(conf: Conf): Unit = {
+    conf.configFile.foreach(f => System.setProperty("config.file", f.getAbsolutePath))
+  }
+
+  private def initLogging(conf: Conf): Unit = {
+    val ctx = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext]
+    ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).setLevel(toLevel(conf.verbose()))
+  }
+
+  private def toLevel(v: Int) = {
+    v match {
+      case 0 => Level.WARN
+      case 1 => Level.INFO
+      case 2 => Level.DEBUG
+      case _ => Level.ALL
+    }
+  }
+
+  private def printErr(message: String): Unit = {
+    //Taken from ScallopConf
+    if (overrideColorOutput.value.getOrElse(System.console() != null)) {
+      Console.err.println("[\u001b[31m%s\u001b[0m] Error: %s" format (printedName, message))
+    } else {
+      // no colors on output
+      Console.err.println("[%s] Error: %s" format (printedName, message))
+    }
+  }
+}
+
+class CommandError(val message: String, val code: Int)
+case class IllegalState(override val message: String) extends CommandError(message, 1)
+case class IllegalArg(override val message: String) extends CommandError(message, 2)
+
+trait WhiskCommand {
+  this: ScallopConfBase =>
+
+  shortSubcommandsHelp()
+
+  def failNoSubCommand(): Unit = {
+    val s = parentConfig.builder.findSubbuilder(commandNameAndAliases.head).get
+    println(s.help)
+    sys.exit(0)
+  }
+}
+
+case class WhiskAdmin(conf: Conf)(implicit val actorSystem: ActorSystem,
+                                  implicit val materializer: ActorMaterializer,
+                                  implicit val logging: Logging) {
+  implicit val tid = TransactionId(TransactionId.systemPrefix + "cli")
+  def executeCommand(): Future[Either[CommandError, String]] = {
+    conf.subcommands match {
+      case List(cmd: UserCommand, x)   => cmd.exec(x)
+      case List(cmd: LimitsCommand, x) => cmd.exec(x)
+    }
+  }
+
+  def timeout: Duration = {
+    conf.subcommands match {
+      case _ => conf.timeout()
+    }
+  }
+}
diff --git a/tools/admin/src/main/scala/whisk/core/database/LimitsCommand.scala b/tools/admin/src/main/scala/whisk/core/database/LimitsCommand.scala
new file mode 100644
index 0000000..6de66e1
--- /dev/null
+++ b/tools/admin/src/main/scala/whisk/core/database/LimitsCommand.scala
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.database
+
+import akka.actor.ActorSystem
+import akka.stream.ActorMaterializer
+import org.rogach.scallop.{ScallopConfBase, Subcommand}
+import spray.json.{JsObject, JsString, JsValue, RootJsonFormat}
+import whisk.common.{Logging, TransactionId}
+import whisk.core.cli.{CommandError, CommandMessages, IllegalState, WhiskCommand}
+import whisk.core.database.LimitsCommand.LimitEntity
+import whisk.core.entity.types.AuthStore
+import whisk.core.entity.{DocId, DocInfo, DocRevision, EntityName, Subject, UserLimits, WhiskAuth, WhiskDocumentReader}
+import whisk.http.Messages
+import whisk.spi.SpiLoader
+
+import scala.concurrent.{ExecutionContext, Future}
+import scala.reflect.classTag
+import scala.util.{Properties, Try}
+
+class LimitsCommand extends Subcommand("limits") with WhiskCommand {
+  descr("manage namespace-specific limits")
+
+  val set = new Subcommand("set") {
+    descr("set limits for a given namespace")
+
+    val namespace = trailArg[String](descr = "the namespace to set limits for")
+
+    //name is explicitly mentioned for backward compatability
+    //otherwise scallop would convert it to - separated names
+    val invocationsPerMinute =
+      opt[Int](
+        descr = "invocations per minute allowed",
+        argName = "INVOCATIONSPERMINUTE",
+        validate = _ >= 0,
+        name = "invocationsPerMinute",
+        noshort = true)
+    val firesPerMinute =
+      opt[Int](
+        descr = "trigger fires per minute allowed",
+        argName = "FIRESPERMINUTE",
+        validate = _ >= 0,
+        name = "firesPerMinute",
+        noshort = true)
+    val concurrentInvocations =
+      opt[Int](
+        descr = "concurrent invocations allowed for this namespace",
+        argName = "CONCURRENTINVOCATIONS",
+        validate = _ >= 0,
+        name = "concurrentInvocations",
+        noshort = true)
+
+    lazy val limits: LimitEntity =
+      new LimitEntity(
+        EntityName(namespace()),
+        UserLimits(invocationsPerMinute.toOption, firesPerMinute.toOption, concurrentInvocations.toOption))
+  }
+  addSubcommand(set)
+
+  val get = new Subcommand("get") {
+    descr("get limits for a given namespace (if none exist, system defaults apply)")
+    val namespace = trailArg[String](descr = "the namespace to get limits for`")
+  }
+  addSubcommand(get)
+
+  val delete = new Subcommand("delete") {
+    descr("delete limits for a given namespace (system defaults apply)")
+    val namespace = trailArg[String](descr = "the namespace to delete limits for")
+
+  }
+  addSubcommand(delete)
+
+  def exec(cmd: ScallopConfBase)(implicit system: ActorSystem,
+                                 logging: Logging,
+                                 materializer: ActorMaterializer,
+                                 transid: TransactionId): Future[Either[CommandError, String]] = {
+    implicit val executionContext = system.dispatcher
+    val authStore = LimitsCommand.createDataStore()
+    val result = cmd match {
+      case `set`    => setLimits(authStore)
+      case `get`    => getLimits(authStore)
+      case `delete` => delLimits(authStore)
+    }
+    result.onComplete { _ =>
+      authStore.shutdown()
+    }
+    result
+  }
+
+  def setLimits(authStore: AuthStore)(implicit transid: TransactionId,
+                                      ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    authStore
+      .get[LimitEntity](set.limits.docinfo)
+      .flatMap { limits =>
+        val newLimits = set.limits.revision[LimitEntity](limits.rev)
+        authStore.put(newLimits).map(_ => Right(CommandMessages.limitsSuccessfullyUpdated(limits.name.asString)))
+      }
+      .recoverWith {
+        case _: NoDocumentException =>
+          authStore.put(set.limits).map(_ => Right(CommandMessages.limitsSuccessfullySet(set.limits.name.asString)))
+      }
+  }
+
+  def getLimits(authStore: AuthStore)(implicit transid: TransactionId,
+                                      ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    val info = DocInfo(LimitsCommand.limitIdOf(EntityName(get.namespace())))
+    authStore
+      .get[LimitEntity](info)
+      .map { le =>
+        val l = le.limits
+        val msg = Seq(
+          l.concurrentInvocations.map(ci => s"concurrentInvocations =  $ci"),
+          l.invocationsPerMinute.map(i => s"invocationsPerMinute = $i"),
+          l.firesPerMinute.map(i => s"firesPerMinute = $i")).flatten.mkString(Properties.lineSeparator)
+        Right(msg)
+      }
+      .recover {
+        case _: NoDocumentException =>
+          Right(CommandMessages.defaultLimits)
+      }
+  }
+
+  def delLimits(authStore: AuthStore)(implicit transid: TransactionId,
+                                      ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    val info = DocInfo(LimitsCommand.limitIdOf(EntityName(delete.namespace())))
+    authStore
+      .get[LimitEntity](info)
+      .flatMap { l =>
+        authStore.del(l.docinfo).map(_ => Right(CommandMessages.limitsDeleted))
+      }
+      .recover {
+        case _: NoDocumentException =>
+          Left(IllegalState(CommandMessages.limitsNotFound(delete.namespace())))
+      }
+  }
+}
+
+object LimitsCommand {
+  def limitIdOf(name: EntityName) = DocId(s"${name.name}/limits")
+
+  def createDataStore()(implicit system: ActorSystem,
+                        logging: Logging,
+                        materializer: ActorMaterializer): ArtifactStore[WhiskAuth] =
+    SpiLoader
+      .get[ArtifactStoreProvider]
+      .makeStore[WhiskAuth]()(classTag[WhiskAuth], LimitsFormat, WhiskDocumentReader, system, logging, materializer)
+
+  class LimitEntity(val name: EntityName, val limits: UserLimits) extends WhiskAuth(Subject(), Set.empty) {
+    override def docid: DocId = limitIdOf(name)
+
+    //There is no api to write limits. So piggy back on WhiskAuth but replace auth json
+    //with limits!
+    override def toJson: JsObject = UserLimits.serdes.write(limits).asJsObject
+  }
+
+  private object LimitsFormat extends RootJsonFormat[WhiskAuth] {
+    override def read(json: JsValue): WhiskAuth = {
+      val r = Try[LimitEntity] {
+        val limits = UserLimits.serdes.read(json)
+        val JsString(id) = json.asJsObject.fields("_id")
+        val JsString(rev) = json.asJsObject.fields("_rev")
+        val Array(name, _) = id.split('/')
+        new LimitEntity(EntityName(name), limits).revision[LimitEntity](DocRevision(rev))
+      }
+      if (r.isSuccess) r.get else throw DocumentUnreadable(Messages.corruptedEntity)
+    }
+
+    override def write(obj: WhiskAuth): JsValue = obj.toDocumentRecord
+  }
+}
diff --git a/tools/admin/src/main/scala/whisk/core/database/UserCommand.scala b/tools/admin/src/main/scala/whisk/core/database/UserCommand.scala
new file mode 100644
index 0000000..e32f39b
--- /dev/null
+++ b/tools/admin/src/main/scala/whisk/core/database/UserCommand.scala
@@ -0,0 +1,354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package whisk.core.database
+
+import java.util.UUID
+
+import akka.actor.ActorSystem
+import akka.stream.ActorMaterializer
+import akka.stream.scaladsl.{Sink, Source}
+import org.rogach.scallop.{ScallopConfBase, Subcommand}
+import spray.json.{JsBoolean, JsObject, JsString, JsValue, RootJsonFormat}
+import whisk.common.{Logging, TransactionId}
+import whisk.core.cli.{CommandError, CommandMessages, IllegalState, WhiskCommand}
+import whisk.core.database.UserCommand.ExtendedAuth
+import whisk.core.entity.types._
+import whisk.core.entity.{
+  AuthKey,
+  DocInfo,
+  EntityName,
+  Identity,
+  Namespace,
+  Subject,
+  WhiskAuth,
+  WhiskDocumentReader,
+  WhiskNamespace
+}
+import whisk.http.Messages
+import whisk.spi.SpiLoader
+
+import scala.concurrent.{ExecutionContext, Future}
+import scala.reflect.classTag
+import scala.util.{Properties, Try}
+
+class UserCommand extends Subcommand("user") with WhiskCommand {
+  descr("manage users")
+
+  class CreateUserCmd extends Subcommand("create") {
+    descr("create a user and show authorization key")
+    val auth =
+      opt[String](
+        descr = "the uuid:key to initialize the subject authorization key with",
+        argName = "AUTH",
+        short = 'u')
+    val namespace =
+      opt[String](descr = "create key for given namespace instead (defaults to subject id)", argName = "NAMESPACE")
+    val subject = trailArg[String](descr = "the subject to create")
+
+    validate(subject) { s =>
+      if (s.length < 5) {
+        Left(CommandMessages.shortName)
+      } else {
+        Right(Unit)
+      }
+    }
+
+    validate(auth) { a =>
+      a.split(":") match {
+        case Array(uuid, key) =>
+          if (key.length < 64) {
+            Left(CommandMessages.shortKey)
+          } else if (!isUUID(uuid)) {
+            Left(CommandMessages.invalidUUID)
+          } else {
+            Right(Unit)
+          }
+        case _ => Left(s"failed to determine authorization id and key: $a")
+      }
+
+    }
+
+    def isUUID(u: String) = Try(UUID.fromString(u)).isSuccess
+
+    def desiredNamespace = Namespace(EntityName(namespace.getOrElse(subject()).trim), authKey.uuid)
+
+    def authKey: AuthKey = auth.map(AuthKey(_)).getOrElse(AuthKey())
+  }
+
+  val create = new CreateUserCmd
+
+  addSubcommand(create)
+
+  val delete = new Subcommand("delete") {
+    descr("delete a user")
+    val subject = trailArg[String](descr = "the subject to delete")
+    val namespace =
+      opt[String](descr = "delete key for given namespace only", argName = "NAMESPACE")
+  }
+  addSubcommand(delete)
+
+  val get = new Subcommand("get") {
+    descr("get authorization key for user")
+
+    val subject = trailArg[String](descr = "the subject to get key for")
+    val namespace =
+      opt[String](descr = "the namespace to get the key for, defaults to subject id", argName = "NAMESPACE")
+
+    val all = opt[Boolean](descr = "list all namespaces and their keys")
+  }
+  addSubcommand(get)
+
+  val whois = new Subcommand("whois") {
+    descr("identify user from an authorization key")
+    val authkey = trailArg[String](descr = "the credentials to look up 'uuid:key'")
+  }
+  addSubcommand(whois)
+
+  val list = new Subcommand("list") {
+    descr("list authorization keys associated with a namespace")
+    val namespace = trailArg[String](descr = "the namespace to lookup")
+
+    val pick = opt[Int](descr = "show no more than N identities", argName = "N", validate = _ > 0)
+    val key = opt[Boolean](descr = "show only the keys")
+    val all = opt[Boolean](descr = "show all identities")
+
+    def limit: Int = {
+      if (all.isSupplied) 0
+      else pick.getOrElse(0)
+    }
+
+    def showOnlyKeys = key.isSupplied
+  }
+  addSubcommand(list)
+
+  val block = new Subcommand("block") {
+    descr("block one or more users")
+    val subjects = trailArg[List[String]](descr = "one or more users to block")
+  }
+  addSubcommand(block)
+
+  val unblock = new Subcommand("unblock") {
+    descr("unblock one or more users")
+    val subjects = trailArg[List[String]](descr = "one or more users to unblock")
+  }
+  addSubcommand(unblock)
+
+  def exec(cmd: ScallopConfBase)(implicit system: ActorSystem,
+                                 logging: Logging,
+                                 materializer: ActorMaterializer,
+                                 transid: TransactionId): Future[Either[CommandError, String]] = {
+    implicit val executionContext = system.dispatcher
+    val authStore = UserCommand.createDataStore()
+    val result = cmd match {
+      case `create`  => createUser(authStore)
+      case `delete`  => deleteUser(authStore)
+      case `get`     => getKey(authStore)
+      case `whois`   => whoIs(authStore)
+      case `list`    => list(authStore)
+      case `block`   => changeUserState(authStore, block.subjects(), blocked = true)
+      case `unblock` => changeUserState(authStore, unblock.subjects(), blocked = false)
+    }
+    result.onComplete { _ =>
+      authStore.shutdown()
+    }
+    result
+  }
+
+  def createUser(authStore: AuthStore)(implicit transid: TransactionId,
+                                       ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    authStore.get[ExtendedAuth](DocInfo(create.subject())).flatMap { auth =>
+      if (auth.isBlocked) {
+        Future.successful(Left(IllegalState(CommandMessages.subjectBlocked)))
+      } else if (auth.namespaces.exists(_.namespace.name == create.desiredNamespace.name)) {
+        Future.successful(Left(IllegalState(CommandMessages.namespaceExists)))
+      } else {
+        val newNS = auth.namespaces + WhiskNamespace(create.desiredNamespace, create.authKey)
+        val newAuth = WhiskAuth(auth.subject, newNS).revision[WhiskAuth](auth.rev)
+        authStore.put(newAuth).map(_ => Right(create.authKey.compact))
+      }
+    }
+  }.recoverWith {
+    case _: NoDocumentException =>
+      val auth =
+        WhiskAuth(Subject(create.subject()), Set(WhiskNamespace(create.desiredNamespace, create.authKey)))
+      authStore.put(auth).map(_ => Right(create.authKey.compact))
+  }
+
+  def deleteUser(authStore: AuthStore)(implicit transid: TransactionId,
+                                       ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    authStore
+      .get[ExtendedAuth](DocInfo(delete.subject()))
+      .flatMap { auth =>
+        delete.namespace
+          .map { namespaceToDelete =>
+            val newNS = auth.namespaces.filter(_.namespace.name.asString != namespaceToDelete)
+            if (newNS == auth.namespaces) {
+              Future.successful(
+                Left(IllegalState(CommandMessages.namespaceMissing(namespaceToDelete, delete.subject()))))
+            } else {
+              val newAuth = WhiskAuth(auth.subject, newNS).revision[WhiskAuth](auth.rev)
+              authStore.put(newAuth).map(_ => Right(CommandMessages.namespaceDeleted))
+            }
+          }
+          .getOrElse {
+            authStore.del(auth.docinfo).map(_ => Right(CommandMessages.subjectDeleted))
+          }
+      }
+      .recover {
+        case _: NoDocumentException =>
+          Left(IllegalState(CommandMessages.subjectMissing))
+      }
+  }
+
+  def getKey(authStore: AuthStore)(implicit transid: TransactionId,
+                                   ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    authStore
+      .get[ExtendedAuth](DocInfo(get.subject()))
+      .map { auth =>
+        if (get.all.isSupplied) {
+          val msg =
+            auth.namespaces.map(ns => s"${ns.namespace.name}\t${ns.authkey.compact}").mkString(Properties.lineSeparator)
+          Right(msg)
+        } else {
+          val ns = get.namespace.getOrElse(get.subject())
+          auth.namespaces
+            .find(_.namespace.name.asString == ns)
+            .map(n => Right(n.authkey.compact))
+            .getOrElse(Left(IllegalState(CommandMessages.namespaceMissing(ns, get.subject()))))
+        }
+      } recover {
+      case _: NoDocumentException =>
+        Left(IllegalState(CommandMessages.subjectMissing))
+    }
+  }
+
+  def whoIs(authStore: AuthStore)(implicit transid: TransactionId,
+                                  ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    Identity
+      .get(authStore, AuthKey(whois.authkey()))
+      .map { i =>
+        val msg = Seq(s"subject: ${i.subject}", s"namespace: ${i.namespace}").mkString(Properties.lineSeparator)
+        Right(msg)
+      }
+      .recover {
+        case _: NoDocumentException =>
+          Left(IllegalState(CommandMessages.subjectMissing))
+      }
+  }
+
+  def list(authStore: AuthStore)(implicit transid: TransactionId,
+                                 ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    Identity
+      .list(authStore, List(list.namespace()), limit = list.limit)
+      .map { rows =>
+        if (rows.isEmpty) Left(IllegalState(CommandMessages.namespaceMissing(list.namespace())))
+        else {
+          val msg = rows
+            .map { row =>
+              row.getFields("id", "value") match {
+                case Seq(JsString(subject), JsObject(value)) =>
+                  val JsString(uuid) = value("uuid")
+                  val JsString(secret) = value("key")
+                  s"$uuid:$secret${if (list.showOnlyKeys) "" else s"\t$subject"}"
+                case _ => throw new IllegalStateException("identities view malformed")
+              }
+            }
+            .mkString(Properties.lineSeparator)
+          Right(msg)
+        }
+      }
+  }
+
+  def changeUserState(authStore: AuthStore, subjects: List[String], blocked: Boolean)(
+    implicit transid: TransactionId,
+    materializer: ActorMaterializer,
+    ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    Source(subjects)
+      .mapAsync(1)(changeUserState(authStore, _, blocked))
+      .runWith(Sink.seq[Either[CommandError, String]])
+      .map { rows =>
+        val lefts = rows.count(_.isLeft)
+        val msg = rows
+          .map {
+            case Left(x)  => x.message
+            case Right(x) => x
+          }
+          .mkString(Properties.lineSeparator)
+
+        if (lefts > 0) Left(new CommandError(msg, lefts)) else Right(msg)
+      }
+  }
+
+  private def changeUserState(authStore: AuthStore, subject: String, blocked: Boolean)(
+    implicit transid: TransactionId,
+    ec: ExecutionContext): Future[Either[CommandError, String]] = {
+    authStore
+      .get[ExtendedAuth](DocInfo(subject))
+      .flatMap { auth =>
+        val newAuth = new ExtendedAuth(auth.subject, auth.namespaces, Some(blocked))
+        newAuth.revision[ExtendedAuth](auth.rev)
+        val msg = if (blocked) CommandMessages.blocked(subject) else CommandMessages.unblocked(subject)
+        authStore.put(newAuth).map(_ => Right(msg))
+      }
+      .recover {
+        case _: NoDocumentException =>
+          Left(IllegalState(CommandMessages.subjectMissing(subject)))
+      }
+  }
+}
+
+object UserCommand {
+  def createDataStore()(implicit system: ActorSystem,
+                        logging: Logging,
+                        materializer: ActorMaterializer): ArtifactStore[WhiskAuth] =
+    SpiLoader
+      .get[ArtifactStoreProvider]
+      .makeStore[WhiskAuth]()(
+        classTag[WhiskAuth],
+        ExtendedAuthFormat,
+        WhiskDocumentReader,
+        system,
+        logging,
+        materializer)
+
+  class ExtendedAuth(subject: Subject, namespaces: Set[WhiskNamespace], blocked: Option[Boolean])
+      extends WhiskAuth(subject, namespaces) {
+    override def toJson: JsObject =
+      blocked.map(b => JsObject(super.toJson.fields + ("blocked" -> JsBoolean(b)))).getOrElse(super.toJson)
+
+    def isBlocked: Boolean = blocked.getOrElse(false)
+  }
+
+  private object ExtendedAuthFormat extends RootJsonFormat[WhiskAuth] {
+    override def write(obj: WhiskAuth): JsValue = {
+      obj.toDocumentRecord
+    }
+
+    override def read(json: JsValue): WhiskAuth = {
+      val r = Try[ExtendedAuth] {
+        val auth = WhiskAuth.serdes.read(json)
+        val blocked = json.asJsObject.fields.get("blocked") match {
+          case Some(b: JsBoolean) => Some(b.value)
+          case _                  => None
+        }
+        new ExtendedAuth(auth.subject, auth.namespaces, blocked).revision[ExtendedAuth](auth.rev)
+      }
+      if (r.isSuccess) r.get else throw DocumentUnreadable(Messages.corruptedEntity)
+    }
+  }
+}


Mime
View raw message