Repository: nats-io/nsc
Pull request #525 (2022-12-02 18:47:11)

[FEAT] added `nsc edit authcallout` command


Patch:
diff --git a/cmd/describer.go b/cmd/describer.go
index eb8288a..54f6db2 100644
--- a/cmd/describer.go
+++ b/cmd/describer.go
@@ -63,6 +63,14 @@ func (a *AccountDescriber) Describe() string {
 		table.AddSeparator()
 	}
 
+	if len(a.Authorization.AuthUsers) > 0 {
+		AddListValues(table, "Auth Callout Users", a.Authorization.AuthUsers)
+		if len(a.Authorization.AllowedAccounts) > 0 {
+			AddListValues(table, "Allowed Accounts", a.Authorization.AllowedAccounts)
+		}
+		table.AddSeparator()
+	}
+
 	addLimitRow := func(table *tablewriter.Table, name string, limit int64, inBytes bool) {
 		if limit > -1 {
 			val := fmt.Sprintf("%d", limit)
diff --git a/cmd/editauthorization.go b/cmd/editauthorization.go
new file mode 100644
index 0000000..43422cf
--- /dev/null
+++ b/cmd/editauthorization.go
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2022 The NATS Authors
+ * Licensed 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 cmd
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/nats-io/jwt/v2"
+	"github.com/nats-io/nkeys"
+	"github.com/nats-io/nsc/cmd/store"
+	"github.com/spf13/cobra"
+)
+
+type EditAccountCalloutParams struct {
+	AccountContextParams
+	SignerParams
+	disable           bool
+	AuthUsers         []string
+	AllowedAccounts   []string
+	RmAuthUsers       []string
+	RmAllowedAccounts []string
+	claim             *jwt.AccountClaims
+}
+
+func createEditAuthorizationCallout() *cobra.Command {
+	params := &EditAccountCalloutParams{}
+	cmd := &cobra.Command{
+		Use:          "authcallout",
+		Short:        "Edit an account authorization callout",
+		Args:         cobra.MaximumNArgs(1),
+		SilenceUsage: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return RunAction(cmd, args, params)
+		},
+	}
+
+	cmd.Flags().BoolVarP(&params.disable, "disable", "", false, "disable external authorization")
+	cmd.Flags().StringSliceVarP(&params.AuthUsers, "auth-user", "", nil, "adds a user public key that bypasses the authorization callout and is used by the authorization service itself")
+	cmd.Flags().StringSliceVarP(&params.AllowedAccounts, "allowed-account", "", nil, "adds an account public key that the authorization service can bind authorized users to")
+
+	cmd.Flags().StringSliceVarP(&params.RmAuthUsers, "rm-auth-user", "", nil, "removes a user public key that bypasses the authorization callout and is used by the authorization service itself")
+	cmd.Flags().StringSliceVarP(&params.RmAllowedAccounts, "rm-allowed-account", "", nil, "removes an account public key that the authorization service can bind authorized users to")
+
+	//cmd.Flags().StringVarP(&params.AccountContextParams.Name, "name", "n", "", "account to edit")
+	params.AccountContextParams.BindFlags(cmd)
+
+	return cmd
+}
+
+func init() {
+	editCmd.AddCommand(createEditAuthorizationCallout())
+}
+
+func (p *EditAccountCalloutParams) SetDefaults(ctx ActionCtx) error {
+	p.AccountContextParams.Name = NameFlagOrArgument(p.AccountContextParams.Name, ctx)
+	if err := p.AccountContextParams.SetDefaults(ctx); err != nil {
+		return err
+	}
+
+	p.SignerParams.SetDefaults(nkeys.PrefixByteOperator, true, ctx)
+
+	if ctx.NothingToDo("auth-user", "rm-auth-user", "allowed-account",
+		"rm-allowed-account", "disable") {
+		return errors.New("please specify some options")
+	}
+
+	return nil
+}
+
+func (p *EditAccountCalloutParams) PreInteractive(_ ActionCtx) error {
+	return nil
+}
+
+func (p *EditAccountCalloutParams) Load(ctx ActionCtx) error {
+	var err error
+
+	if err = p.AccountContextParams.Validate(ctx); err != nil {
+		return err
+	}
+
+	p.claim, err = ctx.StoreCtx().Store.ReadAccountClaim(p.AccountContextParams.Name)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (p *EditAccountCalloutParams) PostInteractive(_ ActionCtx) error {
+	return nil
+}
+
+// toPublicKey resolves the public and checks the key for proper type
+func toPublicKey(s string, kind nkeys.PrefixByte) (string, error) {
+	kp, err := store.ResolveKey(s)
+	if err != nil {
+		return "", err
+	}
+	if err := nkeys.CompatibleKeyPair(kp, kind); err != nil {
+		return "", fmt.Errorf("%s is not a valid %s key", s, kind)
+	}
+	return kp.PublicKey()
+}
+
+func (p *EditAccountCalloutParams) Validate(ctx ActionCtx) error {
+	var err error
+
+	if err := p.SignerParams.ResolveWithPriority(ctx, p.claim.Issuer); err != nil {
+		return err
+	}
+
+	if p.disable {
+		// don't look for anything
+		return nil
+	}
+
+	for idx, k := range p.AuthUsers {
+		p.AuthUsers[idx], err = toPublicKey(k, nkeys.PrefixByteUser)
+		if err != nil {
+			return err
+		}
+	}
+
+	for idx, k := range p.RmAuthUsers {
+		p.RmAuthUsers[idx], err = toPublicKey(k, nkeys.PrefixByteUser)
+		if err != nil {
+			return err
+		}
+	}
+
+	for idx, k := range p.AllowedAccounts {
+		p.AllowedAccounts[idx], err = toPublicKey(k, nkeys.PrefixByteAccount)
+		if err != nil {
+			return err
+		}
+	}
+
+	for idx, k := range p.RmAllowedAccounts {
+		p.RmAllowedAccounts[idx], err = toPublicKey(k, nkeys.PrefixByteAccount)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func report(list jwt.StringList, toAdd []string, hasMsgT string, missingMsgT string) []string {
+	var r = make([]string, len(toAdd))
+	for idx, v := range toAdd {
+		if list.Contains(v) {
+			r[idx] = fmt.Sprintf(hasMsgT, v)
+		} else {
+			r[idx] = fmt.Sprintf(missingMsgT, v)
+		}
+	}
+	return r
+}
+
+func (p *EditAccountCalloutParams) Run(ctx ActionCtx) (store.Status, error) {
+	var userReport []string
+	var userRmReport []string
+	var accountReport []string
+	var accountRmReport []string
+	if !p.disable {
+		userReport = report(p.claim.Account.Authorization.AuthUsers,
+			p.AuthUsers,
+			"skipped adding user %q - as it's already set",
+			"added user %q")
+
+		p.claim.Account.Authorization.AuthUsers.Add(p.AuthUsers...)
+		accountReport = report(p.claim.Account.Authorization.AllowedAccounts,
+			p.AllowedAccounts,
+			"skipped adding account %q - as it's already set",
+			"added account %q")
+		p.claim.Account.Authorization.AllowedAccounts.Add(p.AllowedAccounts...)
+
+		userRmReport = report(p.claim.Account.Authorization.AuthUsers,
+			p.RmAuthUsers,
+			"deleted user %q",
+			"skipping user %q - as it's not currently set")
+		p.claim.Authorization.AuthUsers.Remove(p.RmAuthUsers...)
+
+		accountRmReport = report(p.claim.Account.Authorization.AllowedAccounts,
+			p.RmAllowedAccounts,
+			"deleted account %q",
+			"skipping account %q - as it's not currently set")
+		p.claim.Account.Authorization.AllowedAccounts.Remove(p.RmAllowedAccounts...)
+	} else {
+		p.claim.Account.Authorization = jwt.ExternalAuthorization{}
+	}
+
+	// validate before we store it
+	var vr jwt.ValidationResults
+	p.claim.Validate(&vr)
+	errs := vr.Errors()
+	if len(errs) > 0 {
+		return nil, errs[0]
+	}
+
+	// encode and report
+	token, err := p.claim.Encode(p.signerKP)
+	if err != nil {
+		return nil, err
+	}
+
+	r := store.NewDetailedReport(false)
+	StoreAccountAndUpdateStatus(ctx, token, r)
+	if r.HasNoErrors() {
+		for _, v := range userReport {
+			r.AddOK(v)
+		}
+		for _, v := range userRmReport {
+			r.AddOK(v)
+		}
+		for _, v := range accountReport {
+			r.AddOK(v)
+		}
+		for _, v := range accountRmReport {
+			r.AddOK(v)
+		}
+		if p.disable {
+			r.AddOK("removed external authorization configuration")
+		}
+	}
+	return r, err
+}
diff --git a/go.mod b/go.mod
index b2bae84..c36695a 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/nats-io/cliprompts/v2 v2.0.0-20191226174129-372d79b36768
 	github.com/nats-io/jsm.go v0.0.33
 	github.com/nats-io/jwt v1.2.3-0.20210314221642-a826c77dc9d2
-	github.com/nats-io/jwt/v2 v2.3.0
+	github.com/nats-io/jwt/v2 v2.3.1-0.20221202180456-83b58fd54838
 	github.com/nats-io/nats-server/v2 v2.8.4
 	github.com/nats-io/nats.go v1.16.0
 	github.com/nats-io/nkeys v0.3.0
@@ -20,6 +20,7 @@ require (
 	github.com/spf13/pflag v1.0.3
 	github.com/stretchr/testify v1.7.0
 	github.com/xlab/tablewriter v0.0.0-20160610135559-80b567a11ad5
+	golang.org/x/text v0.4.0
 )
 
 require (
@@ -48,7 +49,6 @@ require (
 	golang.org/x/net v0.0.0-20220906165146-f3363e06e74c // indirect
 	golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 // indirect
 	golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
-	golang.org/x/text v0.4.0 // indirect
 	golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
 	google.golang.org/appengine v1.3.0 // indirect
 	google.golang.org/protobuf v1.23.0 // indirect
diff --git a/go.sum b/go.sum
index c87493e..c81e21d 100644
--- a/go.sum
+++ b/go.sum
@@ -71,8 +71,8 @@ github.com/nats-io/jsm.go v0.0.33 h1:mNxlZEnSiHo9BwAFpjZYuopVvtwVUdtoAana2ovyWOU
 github.com/nats-io/jsm.go v0.0.33/go.mod h1:1ySvWrDbPo/Rs1v0Ccoy7QjZKBGfVhvmolfJRBX+fCg=
 github.com/nats-io/jwt v1.2.3-0.20210314221642-a826c77dc9d2 h1:Y5jKuPP58S+Fm96nMqInCvSwUKLAm+u8hD96qpiRSCo=
 github.com/nats-io/jwt v1.2.3-0.20210314221642-a826c77dc9d2/go.mod h1:kIiOxe25rMg5oMi63AjFkNK2F1q7ISvH+aaKzAXprDs=
-github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI=
-github.com/nats-io/jwt/v2 v2.3.0/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k=
+github.com/nats-io/jwt/v2 v2.3.1-0.20221202180456-83b58fd54838 h1:qfKUzxi+hEeWiC2hjyth7RJI391XQ5nM+pZSx5Y/QbU=
+github.com/nats-io/jwt/v2 v2.3.1-0.20221202180456-83b58fd54838/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k=
 github.com/nats-io/nats-server/v2 v2.8.4 h1:0jQzze1T9mECg8YZEl8+WYUXb9JKluJfCBriPUtluB4=
 github.com/nats-io/nats-server/v2 v2.8.4/go.mod h1:8zZa+Al3WsESfmgSs98Fi06dRWLH5Bnq90m5bKD/eT4=
 github.com/nats-io/nats.go v1.16.0 h1:zvLE7fGBQYW6MWaFaRdsgm9qT39PJDQoju+DS8KsO1g=
@@ -136,7 +136,6 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=


Test patch:
diff --git a/cmd/describeaccount_test.go b/cmd/describeaccount_test.go
index 6d0d9a0..f9a4c20 100644
--- a/cmd/describeaccount_test.go
+++ b/cmd/describeaccount_test.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2020 The NATS Authors
+ * Copyright 2018-2022 The NATS Authors
  * Licensed 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
@@ -217,3 +217,22 @@ func TestDescribeAccount_JSTiers(t *testing.T) {
 	require.Contains(t, out, " | optional")
 	require.Contains(t, out, " | 99")
 }
+
+func TestDescribeAccount_Callout(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, uPK, _ := CreateUserKey(t)
+	_, aPK, _ := CreateAccountKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(),
+		"--auth-user", uPK,
+		"--allowed-account", aPK)
+	require.NoError(t, err)
+
+	out, _, err := ExecuteCmd(createDescribeAccountCmd())
+	require.NoError(t, err)
+	require.Contains(t, out, fmt.Sprintf(" | %s", uPK))
+	require.Contains(t, out, fmt.Sprintf(" | %s", aPK))
+}
diff --git a/cmd/editauthorization_test.go b/cmd/editauthorization_test.go
new file mode 100644
index 0000000..c8058cb
--- /dev/null
+++ b/cmd/editauthorization_test.go
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2022 The NATS Authors
+ * Licensed 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 cmd
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func Test_EditAuthorizationNoFlags(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout())
+	require.Error(t, err)
+	require.Equal(t, "please specify some options", err.Error())
+}
+
+func Test_EditAuthorizationBadUser(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, aPK, _ := CreateAccountKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(), "--auth-user", aPK)
+	require.Error(t, err)
+	require.Equal(t, fmt.Sprintf("%s is not a valid user key", aPK), err.Error())
+}
+
+func Test_EditAuthorizationBadAccount(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, aPK, _ := CreateUserKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(), "--allowed-account", aPK)
+	require.Error(t, err)
+	require.Equal(t, fmt.Sprintf("%s is not a valid account key", aPK), err.Error())
+}
+
+func Test_EditAuthorizationJustUser(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, uPK, _ := CreateUserKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(), "--auth-user", uPK)
+	require.NoError(t, err)
+
+	ac, err := ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Contains(t, ac.Authorization.AuthUsers, uPK)
+}
+
+func Test_EditAuthorizationJustAccount(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, aPK, _ := CreateAccountKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(), "--allowed-account", aPK)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "External authorization cannot have accounts without users specified")
+}
+
+func Test_EditAuthorizationDelete(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, uPK, _ := CreateUserKey(t)
+	_, aPK, _ := CreateAccountKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(), "--auth-user", uPK, "--allowed-account", aPK)
+	require.NoError(t, err)
+
+	ac, err := ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Contains(t, ac.Authorization.AuthUsers, uPK)
+	require.Contains(t, ac.Authorization.AllowedAccounts, aPK)
+
+	_, _, err = ExecuteCmd(createEditAuthorizationCallout(), "--disable")
+	require.NoError(t, err)
+
+	ac, err = ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Empty(t, ac.Authorization.AuthUsers)
+	require.Empty(t, ac.Authorization.AllowedAccounts)
+}
+
+func Test_EditAuthorizationDeleteUser(t *testing.T) {
+	ts := NewTestStore(t, "test")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	_, uPK, _ := CreateUserKey(t)
+	_, u2PK, _ := CreateUserKey(t)
+	_, aPK, _ := CreateAccountKey(t)
+	_, a2PK, _ := CreateAccountKey(t)
+	_, _, err := ExecuteCmd(createEditAuthorizationCallout(),
+		"--auth-user", fmt.Sprintf("%s,%s", uPK, u2PK),
+		"--allowed-account", fmt.Sprintf("%s,%s", aPK, a2PK))
+	require.NoError(t, err)
+
+	ac, err := ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Contains(t, ac.Authorization.AuthUsers, uPK)
+	require.Contains(t, ac.Authorization.AuthUsers, u2PK)
+	require.Contains(t, ac.Authorization.AllowedAccounts, aPK)
+	require.Contains(t, ac.Authorization.AllowedAccounts, a2PK)
+
+	_, _, err = ExecuteCmd(createEditAuthorizationCallout(),
+		"--rm-auth-user", u2PK,
+		"--rm-allowed-account", a2PK)
+	require.NoError(t, err)
+
+	ac, err = ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Contains(t, ac.Authorization.AuthUsers, uPK)
+	require.NotContains(t, ac.Authorization.AuthUsers, u2PK)
+	require.Contains(t, ac.Authorization.AllowedAccounts, aPK)
+	require.NotContains(t, ac.Authorization.AllowedAccounts, a2PK)
+}