Repository: nats-io/nsc
Pull request #416 (2021-07-14 20:30:17)

[adding] support to create user from scoped signing key
also changed describe for such user

Signed-off-by: Matthias Hanel <mh@synadia.com>

Also moved tags (common field) around

```
nsc describe user --account SYS --name test
+---------------------------------------------------------------------------+
|                                   User                                    |
+----------------+----------------------------------------------------------+
| Name           | test                                                     |
| User ID        | UCH5FKMAM75PCERKSOPADAWV4HNNQ43UWSBO5Y3J43WS2RXCMKA6BWXH |
| Issuer ID      | AACYICOAQMQ72EHT35R7LV6VFWMIVWFKWFE5P2JJ2TT674EO7DJTUHMM |
| Issuer Account | ADECCNBUEBWZ727OMBFSN7OMK2FPYRM52TJS25TFQWYS76NPOJBN3KU4 |
| Issued         | 2021-07-14 19:33:14 UTC                                  |
| Expires        |                                                          |
| Issuer Scoped  | Yes                                                      |
+----------------+----------------------------------------------------------+

+---------------------------------------------------------------------------------+
|                          Scoped Signing Key - Details                           |
+----------------------+----------------------------------------------------------+
| Key                  | AACYICOAQMQ72EHT35R7LV6VFWMIVWFKWFE5P2JJ2TT674EO7DJTUHMM |
| role                 | blub                                                     |
+----------------------+----------------------------------------------------------+
| Sub Allow            | foo                                                      |
| Response Permissions | Not Set                                                  |
| Max Msg Payload      | Unlimited                                                |
| Max Data             | Unlimited                                                |
| Max Subs             | 5                                                        |
| Network Src          | Any                                                      |
| Time                 | Any                                                      |
| Bearer Token         | No                                                       |
+----------------------+----------------------------------------------------------+
```


Patch:
diff --git a/cmd/adduser.go b/cmd/adduser.go
index 8d7e3c4..2e41518 100644
--- a/cmd/adduser.go
+++ b/cmd/adduser.go
@@ -253,13 +253,62 @@ func (p *AddUserParams) Validate(ctx ActionCtx) error {
 	return nil
 }
 
+func signerKeyIsScoped(ctx ActionCtx, accountName string, signerKP nkeys.KeyPair) bool {
+	// get the account JWT - must have since we resolved the user based on it
+	ac, err := ctx.StoreCtx().Store.ReadAccountClaim(accountName)
+	if err != nil {
+		return false
+	}
+	// extract the signer public key
+	pk, err := signerKP.PublicKey()
+	if err != nil {
+		return false
+	}
+	if s, ok := ac.SigningKeys.GetScope(pk); ok && s != nil {
+		return true
+	}
+	return false
+}
+
+func checkUserForScope(ctx ActionCtx, accountName string, signerKP nkeys.KeyPair, uc *jwt.UserClaims) error {
+	if ctx == nil || accountName == "" || signerKP == nil || uc == nil {
+		return errors.New("invalid arguments")
+	}
+	// get the account JWT - must have since we resolved the user based on it
+	ac, err := ctx.StoreCtx().Store.ReadAccountClaim(accountName)
+	if err != nil {
+		return err
+	}
+	// extract the signer public key
+	pk, err := signerKP.PublicKey()
+	if err != nil {
+		return err
+	}
+	if s, ok := ac.SigningKeys.GetScope(pk); ok && s != nil {
+		if uc.Issuer == "" {
+			// set issuer as this is commonly set during encoding but is required next
+			uc.Issuer = pk
+		}
+		if err := s.ValidateScopedSigner(uc); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (p *AddUserParams) Run(ctx ActionCtx) (store.Status, error) {
 	r := store.NewDetailedReport(false)
-	uc, err := p.generateUserClaim(ctx, r)
+	uc, err := p.generateUserClaim(ctx, r, signerKeyIsScoped(ctx, p.AccountContextParams.Name, p.signerKP))
 	if err != nil {
 		return nil, err
 	}
 
+	if err := checkUserForScope(ctx, p.AccountContextParams.Name, p.signerKP, uc); err != nil {
+		r.AddFromError(err)
+		r.AddWarning("user was NOT edited as the edits conflict with signing key scope")
+		return r, err
+	}
+
 	token, err := uc.Encode(p.signerKP)
 	if err != nil {
 		return nil, err
@@ -311,13 +360,14 @@ func (p *AddUserParams) Run(ctx ActionCtx) (store.Status, error) {
 	return r, nil
 }
 
-func (p *AddUserParams) generateUserClaim(ctx ActionCtx, r *store.Report) (*jwt.UserClaims, error) {
+func (p *AddUserParams) generateUserClaim(ctx ActionCtx, r *store.Report, scoped bool) (*jwt.UserClaims, error) {
 	pub, err := p.kp.PublicKey()
 	if err != nil {
 		return nil, err
 	}
 	uc := jwt.NewUserClaims(pub)
 	uc.Name = p.userName
+	uc.SetScoped(scoped)
 
 	spk, err := p.signerKP.PublicKey()
 	if err != nil {
diff --git a/cmd/describer.go b/cmd/describer.go
index e5db3f6..beed6b0 100644
--- a/cmd/describer.go
+++ b/cmd/describer.go
@@ -140,11 +140,6 @@ func (a *AccountDescriber) Describe() string {
 		table.AddRow("Revocations", fmt.Sprintf("%d", len(a.Revocations)))
 	}
 
-	if len(a.Tags) > 0 {
-		table.AddSeparator()
-		AddListValues(table, "Tags", a.Tags)
-	}
-
 	buf.WriteString(table.Render())
 
 	if len(a.Exports) > 0 {
@@ -364,24 +359,29 @@ func (i *ImportDescriber) LoadActivation() (*jwt.ActivationClaims, error) {
 func AddStandardClaimInfo(table *tablewriter.Table, claims jwt.Claims) {
 	label := "Account ID"
 	issuer := ""
+	var tags jwt.TagList
 	if ac, ok := claims.(*jwt.ActivationClaims); ok {
 		if ac.IssuerAccount != "" {
 			issuer = ac.IssuerAccount
 		}
+		tags = ac.Tags
 	}
 	if acc, ok := claims.(*jwt.ActivationClaims); ok {
 		if acc.IssuerAccount != "" {
 			issuer = acc.IssuerAccount
 		}
+		tags = acc.Tags
 	}
 	if uc, ok := claims.(*jwt.UserClaims); ok {
 		label = "User ID"
 		if uc.IssuerAccount != "" {
 			issuer = uc.IssuerAccount
 		}
+		tags = uc.Tags
 	}
-	if _, ok := claims.(*jwt.OperatorClaims); ok {
+	if oc, ok := claims.(*jwt.OperatorClaims); ok {
 		label = "Operator ID"
+		tags = oc.Tags
 	}
 
 	cd := claims.Claims()
@@ -395,6 +395,9 @@ func AddStandardClaimInfo(table *tablewriter.Table, claims jwt.Claims) {
 	}
 	table.AddRow("Issued", RenderDate(cd.IssuedAt))
 	table.AddRow("Expires", RenderDate(cd.Expires))
+	if len(tags) > 0 {
+		AddListValues(table, "Tags", tags)
+	}
 }
 
 type ActivationDescriber struct {
@@ -503,23 +506,19 @@ func (u *UserDescriber) Describe() string {
 	table := tablewriter.CreateTable()
 	table.AddTitle("User")
 	AddStandardClaimInfo(table, &u.UserClaims)
-	table.AddRow("Bearer Token", toYesNo(u.BearerToken))
-
-	AddPermissions(table, u.Permissions)
-
-	table.AddSeparator()
-	AddLimits(table, u.Limits)
-
-	if len(u.Tags) > 0 {
+	if u.HasEmptyPermissions() {
+		table.AddRow("Issuer Scoped", "Yes")
+	} else {
+		table.AddRow("Bearer Token", toYesNo(u.BearerToken))
+		AddPermissions(table, u.Permissions)
 		table.AddSeparator()
-		AddListValues(table, "Tags", u.Tags)
-	}
+		AddLimits(table, u.Limits)
 
-	if len(u.AllowedConnectionTypes) > 0 {
-		table.AddSeparator()
-		AddListValues(table, "Allowed Connection Types", u.AllowedConnectionTypes)
+		if len(u.AllowedConnectionTypes) > 0 {
+			table.AddSeparator()
+			AddListValues(table, "Allowed Connection Types", u.AllowedConnectionTypes)
+		}
 	}
-
 	return table.Render()
 }
 
@@ -557,10 +556,5 @@ func (o *OperatorDescriber) Describe() string {
 		AddListValues(table, "Signing Keys", o.SigningKeys)
 	}
 
-	if len(o.Tags) > 0 {
-		table.AddSeparator()
-		AddListValues(table, "Tags", o.Tags)
-	}
-
 	return table.Render()
 }
diff --git a/cmd/describeuser.go b/cmd/describeuser.go
index 77a48f0..438a731 100644
--- a/cmd/describeuser.go
+++ b/cmd/describeuser.go
@@ -127,7 +127,7 @@ func (p *DescribeUserParams) PostInteractive(_ ActionCtx) error {
 	return nil
 }
 
-func (p *DescribeUserParams) Run(_ ActionCtx) (store.Status, error) {
+func (p *DescribeUserParams) Run(ctx ActionCtx) (store.Status, error) {
 	if Raw || Json || JsonPath != "" {
 		if !IsStdOut(p.outputFile) {
 			var err error
@@ -143,6 +143,11 @@ func (p *DescribeUserParams) Run(_ ActionCtx) (store.Status, error) {
 		}
 	} else {
 		v := NewUserDescriber(p.UserClaims).Describe()
+		if aClaim, err := ctx.StoreCtx().Store.ReadAccountClaim(p.AccountContextParams.Name); err == nil {
+			if s, ok := aClaim.SigningKeys.GetScope(p.UserClaims.Issuer); ok && s != nil {
+				v = fmt.Sprintf("%s\n%s", v, NewScopedSkDescriber(s.(*jwt.UserScope)).Describe())
+			}
+		}
 		if err := Write(p.outputFile, []byte(v)); err != nil {
 			return nil, err
 		}
diff --git a/cmd/editscopedsk.go b/cmd/editscopedsk.go
index 3bda4a4..9a42617 100644
--- a/cmd/editscopedsk.go
+++ b/cmd/editscopedsk.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"fmt"
+
 	"github.com/nats-io/jwt/v2"
 	"github.com/nats-io/nkeys"
 	"github.com/nats-io/nsc/cmd/store"
@@ -27,7 +28,7 @@ func createEditSkopedSkCmd() *cobra.Command {
 	var params EditScopedSkParams
 	cmd := &cobra.Command{
 		Use:   "signing-key",
-		Short: "Edit a scoped signing key",
+		Short: "Edit a scoped signing key or promote a signing key to be scoped",
 		Long: `# Edit permissions associated with the account (n) signing key (sk):
 nsc edit signing-key --account <n> --sk <sk> --allow-pubsub <subject>,...
 nsc edit signing-key --account <n> --sk <sk> --allow-pub <subject>,...
diff --git a/cmd/edituser.go b/cmd/edituser.go
index ceacc3e..b6ae030 100644
--- a/cmd/edituser.go
+++ b/cmd/edituser.go
@@ -232,6 +232,12 @@ func (p *EditUserParams) Run(ctx ActionCtx) (store.Status, error) {
 		p.claim.IssuerAccount = ac.Subject
 	}
 
+	if err := checkUserForScope(ctx, p.AccountContextParams.Name, p.signerKP, p.claim); err != nil {
+		r.AddFromError(err)
+		r.AddWarning("user was NOT edited as the edits conflict with signing key scope")
+		return r, err
+	}
+
 	// we sign
 	p.token, err = p.claim.Encode(p.signerKP)
 	if err != nil {
diff --git a/cmd/numberparams.go b/cmd/numberparams.go
index c7b71d0..3d05224 100644
--- a/cmd/numberparams.go
+++ b/cmd/numberparams.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"fmt"
+
 	"github.com/dustin/go-humanize"
 
 	cli "github.com/nats-io/cliprompts/v2"
diff --git a/cmd/root.go b/cmd/root.go
index 18a95ad..5ffaa1a 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -215,7 +215,7 @@ func init() {
 // HoistRootFlags adds persistent flags that would be added by the cobra framework
 // but are not because the unit tests are testing the command directly
 func HoistRootFlags(cmd *cobra.Command) *cobra.Command {
-	cmd.PersistentFlags().StringVarP(&KeyPathFlag, "private-key", "K", "", "private key")
+	cmd.PersistentFlags().StringVarP(&KeyPathFlag, "private-key", "K", "", "Key used to sign. Can be specified as role (where applicable), public key (private portion is retrieved) or file path to a private key or private key ")
 	cmd.PersistentFlags().BoolVarP(&InteractiveFlag, "interactive", "i", false, "ask questions for various settings")
 	return cmd
 }
diff --git a/cmd/signerparams.go b/cmd/signerparams.go
index 4780d34..77bbe0c 100644
--- a/cmd/signerparams.go
+++ b/cmd/signerparams.go
@@ -20,6 +20,8 @@ import (
 	"os"
 	"strings"
 
+	"github.com/nats-io/jwt/v2"
+
 	cli "github.com/nats-io/cliprompts/v2"
 	"github.com/nats-io/nkeys"
 	"github.com/nats-io/nsc/cmd/store"
@@ -190,6 +192,33 @@ func (p *SignerParams) ResolveWithPriority(ctx ActionCtx, preferKey string) erro
 	var err error
 	// if they specified -K resolve or fail
 	if KeyPathFlag != "" {
+		// try to find key by role. on error try other methods
+		if acc := ctx.StoreCtx().Account.Name; acc != "" {
+			if claim, err := ctx.StoreCtx().Store.ReadAccountClaim(acc); err == nil {
+				for key, v := range claim.SigningKeys {
+					if v, ok := v.(*jwt.UserScope); ok {
+						if v.Role == KeyPathFlag {
+							if kp, _ := ctx.StoreCtx().KeyStore.GetKeyPair(key); kp != nil {
+								p.signerKP = kp
+								return nil
+							}
+						}
+					}
+				}
+			}
+		}
+		// try to interpret as public key. on error try other methods
+		if nkeys.IsValidPublicKey(KeyPathFlag) {
+			for _, k := range p.kind {
+				if k, err := nkeys.Decode(k, []byte(KeyPathFlag)); err == nil && k != nil {
+					if kp, _ := ctx.StoreCtx().KeyStore.GetKeyPair(KeyPathFlag); kp != nil {
+						p.signerKP = kp
+						return nil
+					}
+				}
+			}
+		}
+		// try file or seed
 		p.signerKP, err = ctx.StoreCtx().ResolveKey(KeyPathFlag, p.kind...)
 		if err != nil {
 			return err


Test patch:
diff --git a/cmd/adduser_test.go b/cmd/adduser_test.go
index 74f99af..cf82832 100644
--- a/cmd/adduser_test.go
+++ b/cmd/adduser_test.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"io/ioutil"
+	"os"
 	"path/filepath"
 	"testing"
 	"time"
@@ -469,3 +470,57 @@ func Test_AddUser_SrcPermissions(t *testing.T) {
 	require.True(t, u.Limits.Src.Contains("1.2.2.2/29"))
 	require.True(t, u.Limits.Src.Contains("1.2.0.3/32"))
 }
+
+func Test_AddUser_Scoped(t *testing.T) {
+	ts := NewTestStore(t, "O")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+	s, pk, kp := CreateAccountKey(t)
+
+	// store seed in temporary file
+	f, err := ioutil.TempFile("", "")
+	defer os.Remove(f.Name())
+	require.NoError(t, err)
+	f.Write(s)
+	f.Sync()
+
+	_, err = ts.KeyStore.Store(kp)
+	require.NoError(t, err)
+
+	_, _, err = ExecuteCmd(createEditAccount(), "--sk", pk)
+	require.NoError(t, err)
+
+	ac, err := ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Contains(t, ac.SigningKeys, pk)
+
+	_, _, err = ExecuteCmd(createEditSkopedSkCmd(), "--account", "A", "--sk", pk, "--subs", "5", "--role", "user-role")
+	require.NoError(t, err)
+
+	// fail using key outright
+	_, stderr, err := ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UA", "--tag", "foo", "--bearer", "-K", pk)
+	require.Error(t, err)
+	require.Contains(t, stderr, "[ERR ] scoped users require no permissions or limits set")
+
+	// fail using key via role name
+	_, stderr, err = ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UB", "--tag", "foo", "--bearer", "-K", "user-role")
+	require.Error(t, err)
+	require.Contains(t, stderr, "[ERR ] scoped users require no permissions or limits set")
+
+	// pass as no permissions/limits are modified.
+	_, _, err = ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UC", "--tag", "foo", "-K", "user-role")
+	require.NoError(t, err)
+
+	// pass as no permissions/limits are modified.
+	_, _, err = ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UD", "--tag", "foo", "-K", pk)
+	require.NoError(t, err)
+
+	// pass as no permissions/limits are modified.
+	_, _, err = ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UE", "--tag", "foo", "-K", string(s))
+	require.NoError(t, err)
+
+	// pass as no permissions/limits are modified.
+	_, _, err = ExecuteCmd(HoistRootFlags(CreateAddUserCmd()), "--name", "UF", "--tag", "foo", "-K", f.Name())
+	require.NoError(t, err)
+}
diff --git a/cmd/editscopedsk_test.go b/cmd/editscopedsk_test.go
index dd12f5d..4a008bf 100644
--- a/cmd/editscopedsk_test.go
+++ b/cmd/editscopedsk_test.go
@@ -16,9 +16,10 @@
 package cmd
 
 import (
+	"testing"
+
 	"github.com/nats-io/jwt/v2"
 	"github.com/stretchr/testify/require"
-	"testing"
 )
 
 func Test_EditScopedSk_NotFound(t *testing.T) {