Repository: smallstep/cli
Pull request #668 (2022-04-25 14:27:10)

Add commands for managing policies


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9862836c..222bcc67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 
 ## [Unreleased - 0.19.1] - DATE
 ### Added
+- Add commands for managing certificate issuance policies on authority, provisioner and ACME account level.
 ### Changed
 ### Deprecated
 ### Removed
diff --git a/command/ca/acme/acme.go b/command/ca/acme/acme.go
index 2d02cd23..cfee4d7f 100644
--- a/command/ca/acme/acme.go
+++ b/command/ca/acme/acme.go
@@ -1,17 +1,18 @@
 package acme
 
 import (
-	"github.com/smallstep/cli/command/ca/acme/eab"
 	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/acme/eab"
 )
 
 // Command returns the acme subcommand.
 func Command() cli.Command {
 	return cli.Command{
 		Name:        "acme",
-		Usage:       "manage ACME",
-		UsageText:   "**step beta ca acme** <subcommand> [arguments] [global-flags] [subcommand-flags]",
-		Description: `**step beta ca acme** command group provides facilities for managing ACME.`,
+		Usage:       "manage ACME settings",
+		UsageText:   "**step ca acme** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca acme** command group provides facilities for managing ACME.`,
 		Subcommands: cli.Commands{
 			eab.Command(),
 		},
diff --git a/command/ca/acme/eab/add.go b/command/ca/acme/eab/add.go
index 17cd953d..480359c8 100644
--- a/command/ca/acme/eab/add.go
+++ b/command/ca/acme/eab/add.go
@@ -5,11 +5,13 @@ import (
 	"os"
 
 	"github.com/pkg/errors"
+	"github.com/urfave/cli"
+
+	"go.step.sm/cli-utils/errs"
+
 	adminAPI "github.com/smallstep/certificates/authority/admin/api"
 	"github.com/smallstep/cli/flags"
 	"github.com/smallstep/cli/utils/cautils"
-	"github.com/urfave/cli"
-	"go.step.sm/cli-utils/errs"
 )
 
 func addCommand() cli.Command {
@@ -17,7 +19,7 @@ func addCommand() cli.Command {
 		Name:   "add",
 		Action: cli.ActionFunc(addAction),
 		Usage:  "add ACME External Account Binding Key",
-		UsageText: `**step beta ca acme eab add** <provisioner> [<reference>]
+		UsageText: `**step ca acme eab add** <provisioner> [<eab-key-reference>]
 [**--admin-cert**=<file>] [**--admin-key**=<file>]
 [**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
 [**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
@@ -32,26 +34,26 @@ func addCommand() cli.Command {
 			flags.Root,
 			flags.Context,
 		},
-		Description: `**step beta ca acme eab add** adds ACME External Account Binding Key.
+		Description: `**step ca acme eab add** adds ACME External Account Binding Key.
 
 ## POSITIONAL ARGUMENTS
 
 <provisioner>
 : Name of the provisioner to which the ACME EAB key will be added
 
-<reference>
+<eab-key-reference>
 : (Optional) reference (from external system) for the key that will be added
 
 ## EXAMPLES
 
 Add an ACME External Account Binding Key without reference:
 '''
-$ step beta ca acme eab add my_acme_provisioner
+$ step ca acme eab add my_acme_provisioner
 '''
 
 Add an ACME External Account Binding Key with reference:
 '''
-$ step beta ca acme eab add my_acme_provisioner my_first_eab_key
+$ step ca acme eab add my_acme_provisioner my_first_eab_key
 '''`,
 	}
 }
diff --git a/command/ca/acme/eab/eab.go b/command/ca/acme/eab/eab.go
index ea82aa61..ef9102a3 100644
--- a/command/ca/acme/eab/eab.go
+++ b/command/ca/acme/eab/eab.go
@@ -7,10 +7,12 @@ import (
 	"strconv"
 
 	"github.com/pkg/errors"
-	"github.com/smallstep/certificates/authority/admin"
-	"github.com/smallstep/certificates/ca"
 	"github.com/urfave/cli"
+
 	"go.step.sm/linkedca"
+
+	"github.com/smallstep/certificates/authority/admin"
+	"github.com/smallstep/certificates/ca"
 )
 
 type cliEAK struct {
@@ -44,30 +46,30 @@ func Command() cli.Command {
 	return cli.Command{
 		Name:      "eab",
 		Usage:     "create and manage ACME External Account Binding Keys",
-		UsageText: "**step beta ca acme eab** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		UsageText: "**step ca acme eab** <subcommand> [arguments] [global-flags] [subcommand-flags]",
 		Subcommands: cli.Commands{
 			listCommand(),
 			addCommand(),
 			removeCommand(),
 		},
-		Description: `**step beta ca acme eab** command group provides facilities for managing ACME 
+		Description: `**step ca acme eab** command group provides facilities for managing ACME 
 		External Account Binding Keys.
 
 ## EXAMPLES
 
 List the active ACME External Account Binding Keys:
 '''
-$ step beta ca acme eab list <provisioner>
+$ step ca acme eab list my_provisioner
 '''
 
 Add an ACME External Account Binding Key:
 '''
-$ step beta ca acme eab add provisioner_name some_name_or_reference
+$ step ca acme eab add my_provisioner my_reference
 '''
 
 Remove an ACME External Account Binding Key:
 '''
-$ step beta ca acme eab remove key_id
+$ step ca acme eab remove my_provisioner my_key_id
 '''
 `,
 	}
diff --git a/command/ca/acme/eab/list.go b/command/ca/acme/eab/list.go
index b9165572..da6639a6 100644
--- a/command/ca/acme/eab/list.go
+++ b/command/ca/acme/eab/list.go
@@ -7,11 +7,12 @@ import (
 	"os/exec"
 
 	"github.com/pkg/errors"
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
 	"github.com/smallstep/certificates/ca"
 	"github.com/smallstep/cli/flags"
 	"github.com/smallstep/cli/utils/cautils"
-	"github.com/urfave/cli"
-	"go.step.sm/cli-utils/errs"
 )
 
 func listCommand() cli.Command {
@@ -19,7 +20,7 @@ func listCommand() cli.Command {
 		Name:   "list",
 		Action: cli.ActionFunc(listAction),
 		Usage:  "list all ACME External Account Binding Keys",
-		UsageText: `**step beta ca acme eab list** <provisioner> [<reference>]
+		UsageText: `**step ca acme eab list** <provisioner> [<eab-key-reference>]
 [**--limit**=<number>] [**--admin-cert**=<file>] [**--admin-key**=<file>]
 [**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
 [**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
@@ -36,7 +37,7 @@ func listCommand() cli.Command {
 			flags.Root,
 			flags.Context,
 		},
-		Description: `**step beta ca acme eab list** lists all ACME External Account Binding (EAB) Keys.
+		Description: `**step ca acme eab list** lists all ACME External Account Binding (EAB) Keys.
 
 Output will go to stdout by default. If many EAB keys are stored in the ACME provisioner, output will be sent to $PAGER (when set). 
 
@@ -45,7 +46,7 @@ Output will go to stdout by default. If many EAB keys are stored in the ACME pro
 <provisioner>
 : Name of the provisioner to list ACME EAB keys for
 
-<reference>
+<eab-key-reference>
 : (Optional) reference (from external system) for the key to be listed
 
 
@@ -53,12 +54,12 @@ Output will go to stdout by default. If many EAB keys are stored in the ACME pro
 
 List all ACME External Account Binding Keys:
 '''
-$ step beta ca acme eab list my_acme_provisioner
+$ step ca acme eab list my_acme_provisioner
 '''
 
 Show ACME External Account Binding Key with specific reference:
 '''
-$ step beta ca acme eab list my_acme_provisioner my_reference
+$ step ca acme eab list my_acme_provisioner my_reference
 '''
 `,
 	}
diff --git a/command/ca/acme/eab/remove.go b/command/ca/acme/eab/remove.go
index adbfd6c7..a6b9164c 100644
--- a/command/ca/acme/eab/remove.go
+++ b/command/ca/acme/eab/remove.go
@@ -4,10 +4,12 @@ import (
 	"fmt"
 
 	"github.com/pkg/errors"
-	"github.com/smallstep/cli/flags"
-	"github.com/smallstep/cli/utils/cautils"
 	"github.com/urfave/cli"
+
 	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/utils/cautils"
 )
 
 func removeCommand() cli.Command {
@@ -15,7 +17,7 @@ func removeCommand() cli.Command {
 		Name:   "remove",
 		Action: cli.ActionFunc(removeAction),
 		Usage:  "remove an ACME EAB Key from the CA",
-		UsageText: `**step beta ca acme eab remove** <provisioner> <key_id>
+		UsageText: `**step ca acme eab remove** <provisioner> <eab-key-id>
 [**--admin-cert**=<file>] [**--admin-key**=<file>]
 [**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
 [**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
@@ -30,21 +32,21 @@ func removeCommand() cli.Command {
 			flags.Root,
 			flags.Context,
 		},
-		Description: `**step beta ca acme eab remove** removes an ACME EAB Key from the CA.
+		Description: `**step ca acme eab remove** removes an ACME EAB Key from the CA.
 
 ## POSITIONAL ARGUMENTS
 
 <provisioner>
 : Name of the provisioner to remove an ACME EAB key for
 
-<key_id>
+<eab-key-id>
 : The ACME EAB Key ID to remove
 
 ## EXAMPLES
 
 Remove ACME EAB Key with Key ID "zFGdKC1sHmNf3Wsx3OujY808chxwEdmr" from my_acme_provisioner:
 '''
-$ step beta ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr
+$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr
 '''
 `,
 	}
diff --git a/command/ca/ca.go b/command/ca/ca.go
index b4954331..22da5044 100644
--- a/command/ca/ca.go
+++ b/command/ca/ca.go
@@ -1,13 +1,15 @@
 package ca
 
 import (
-	"github.com/smallstep/cli/command/ca/acme"
+	"github.com/urfave/cli"
 
+	"go.step.sm/cli-utils/command"
+
+	"github.com/smallstep/cli/command/ca/acme"
 	"github.com/smallstep/cli/command/ca/admin"
+	"github.com/smallstep/cli/command/ca/policy"
 	"github.com/smallstep/cli/command/ca/provisioner"
 	"github.com/smallstep/cli/command/ca/provisionerbeta"
-	"github.com/urfave/cli"
-	"go.step.sm/cli-utils/command"
 )
 
 // init creates and registers the ca command
@@ -78,6 +80,8 @@ $ step ca renew internal.crt internal.key \
 			rootComand(),
 			rootsCommand(),
 			federationCommand(),
+			acme.Command(),
+			policy.Command(),
 		},
 	}
 
@@ -167,7 +171,6 @@ commands may change, disappear, or be promoted to a different subcommand in the
 		Subcommands: cli.Commands{
 			admin.Command(),
 			provisionerbeta.Command(),
-			acme.Command(),
 		},
 	}
 }
diff --git a/command/ca/policy/acme/acme.go b/command/ca/policy/acme/acme.go
new file mode 100644
index 00000000..05ee05d0
--- /dev/null
+++ b/command/ca/policy/acme/acme.go
@@ -0,0 +1,27 @@
+package acme
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/command/ca/policy/x509"
+)
+
+// Command returns the ACME account policy subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithACMEPolicyLevel(ctx)
+	return cli.Command{
+		Name:        "acme",
+		Usage:       "manage certificate issuance policies for ACME accounts.",
+		UsageText:   "**step ca policy acme** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy acme** command group provides facilities for managing certificate issuance policies for ACME accounts.`,
+		Subcommands: cli.Commands{
+			actions.ViewCommand(ctx),
+			actions.RemoveCommand(ctx),
+			x509.Command(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/actions/cn.go b/command/ca/policy/actions/cn.go
new file mode 100644
index 00000000..0ce07dcd
--- /dev/null
+++ b/command/ca/policy/actions/cn.go
@@ -0,0 +1,127 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// CommonNamesCommand returns the common names policy subcommand.
+func CommonNamesCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "cn")
+	return cli.Command{
+		Name:  "cn",
+		Usage: "add or remove common names",
+		UsageText: fmt.Sprintf(`**%s** <name> [**--remove**]
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages common names in policies
+		
+
+## EXAMPLES	
+
+Allow "My CA Name" as Common Name in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow cn "My CA Name"
+'''	
+
+Allow www.example.com as Common Name in X.509 certificates on authority level.
+This can be used in case www.example.com is not allowed as a DNS SAN, but is 
+allowed to be used in the Common Name.
+'''
+$ step ca policy authority x509 allow cn www.example.com
+'''
+
+Remove www.example.com from allowed Common Names in X.509 certificates on authority level.
+'''
+$ step ca policy authority x509 allow cn www.example.com --remove
+'''
+
+Deny "My Bad CA Name" as Common Name in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny cn "My Bad CA Name"
+'''	
+
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			commonNamesAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided Common Names from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func commonNamesAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return fmt.Errorf("error retrieving policy: %w", err)
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		return errors.New("SSH host policy does not support Common Names")
+	case policycontext.IsSSHUserPolicy(ctx):
+		return errors.New("SSH user policy does not support Common Names")
+	case policycontext.IsX509Policy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.X509.Allow.CommonNames = addOrRemoveArguments(policy.X509.Allow.CommonNames, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.X509.Deny.CommonNames = addOrRemoveArguments(policy.X509.Deny.CommonNames, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/dns.go b/command/ca/policy/actions/dns.go
new file mode 100644
index 00000000..95e0d82d
--- /dev/null
+++ b/command/ca/policy/actions/dns.go
@@ -0,0 +1,150 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// DNSCommand returns the dns policy subcommand.
+func DNSCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "dns")
+	return cli.Command{
+		Name:  "dns",
+		Usage: "add or remove DNS domains",
+		UsageText: fmt.Sprintf(`**%s** <domain> [**--remove**]
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages DNS domains in policies
+
+## EXAMPLES	
+
+Allow www.example.com DNS in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow dns www.example.com
+'''
+
+Allow all DNS subdomains of "local" in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow dns "*.local"
+'''
+
+Deny DNS badhost.local in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny dns "badhost.local"
+'''
+
+Remove badhost.local from denied DNS names in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny dns "badhost.local" --remove
+'''
+
+Allow all DNS subdomains of "example.com" in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 allow dns "*.example.com" --provisioner my_provisioner
+'''
+
+Allow all DNS subdomains of "account1.acme.example.com" in X.509 certificates on ACME Account level
+'''
+$ step ca policy acme x509 allow dns "*.account1.acme.example.com" --provisioner my_acme_provisioner --reference account1
+'''
+
+Allow all DNS subdomains of "local" in SSH host certificates on authority level
+'''
+$ step ca policy authority ssh host allow dns "*.local"
+'''
+
+Deny badsshhost.local in SSH host certificates on authority level
+'''
+$ step ca policy authority ssh host allow dns "badsshhost.local"
+'''
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			dnsAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided DNS names from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func dnsAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return fmt.Errorf("error retrieving policy: %w", err)
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.Ssh.Host.Allow.Dns = addOrRemoveArguments(policy.Ssh.Host.Allow.Dns, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.Ssh.Host.Deny.Dns = addOrRemoveArguments(policy.Ssh.Host.Deny.Dns, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	case policycontext.IsSSHUserPolicy(ctx):
+		return errors.New("SSH user policy does not support DNS names")
+	case policycontext.IsX509Policy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.X509.Allow.Dns = addOrRemoveArguments(policy.X509.Allow.Dns, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.X509.Deny.Dns = addOrRemoveArguments(policy.X509.Deny.Dns, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/emails.go b/command/ca/policy/actions/emails.go
new file mode 100644
index 00000000..ee311c14
--- /dev/null
+++ b/command/ca/policy/actions/emails.go
@@ -0,0 +1,138 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// EmailCommand returns the x509 email subcommand
+func EmailCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "email")
+	return cli.Command{
+		Name:  "email",
+		Usage: "add or remove email addresses",
+		UsageText: fmt.Sprintf(`**%s** <email> [**--remove**]
+[**--provisioner**=<name>] [**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages email addresses and domains in policies
+		
+## EXAMPLES	
+
+Allow all email addresses for the example.com domain in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow email @example.com
+'''
+
+Remove the email addresses for the example.com domain in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow email @example.com --remove
+'''
+
+Deny badmail@example.com in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny email badmail@example.com
+'''
+
+Allow all email addresses for the example.com domain in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 allow email @example.com --provisioner my_provisioner
+'''
+
+Allow all local parts for the example.com domain in SSH user certificates on provisioner level
+'''
+$ step ca policy provisioner ssh user allow email @example.com --provisioner my_provisioner
+'''
+
+Deny root@example.com domain in SSH user certificates on provisioner level
+'''
+$ step ca policy provisioner ssh user deny email @example.com --provisioner my_provisioner
+'''
+
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			emailAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided emails from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func emailAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return err
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		return errors.New("SSH host policy does not support emails")
+	case policycontext.IsSSHUserPolicy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.Ssh.User.Allow.Emails = addOrRemoveArguments(policy.Ssh.User.Allow.Emails, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.Ssh.User.Deny.Emails = addOrRemoveArguments(policy.Ssh.User.Deny.Emails, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	case policycontext.IsX509Policy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.X509.Allow.Emails = addOrRemoveArguments(policy.X509.Allow.Emails, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.X509.Deny.Emails = addOrRemoveArguments(policy.X509.Deny.Emails, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/ips.go b/command/ca/policy/actions/ips.go
new file mode 100644
index 00000000..1cd02ce0
--- /dev/null
+++ b/command/ca/policy/actions/ips.go
@@ -0,0 +1,170 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// Command returns the policy subcommand.
+func IPCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "ip")
+	return cli.Command{
+		Name:  "ip",
+		Usage: "add or remove ip addresses",
+		UsageText: fmt.Sprintf(`**%s** <ip address> [**--remove**]
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages IP addresses and ranges in policies
+
+## EXAMPLES	
+
+Allow IP address 127.0.0.1 in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow ip 127.0.0.1
+'''		
+
+Allow IP address range 10.0.0.0/24 in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow ip 10.0.0.0/24
+'''		
+
+Deny IP address 10.0.0.30 in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny ip 10.0.0.30
+'''		
+
+Remove IP address range 10.0.0.0/24 from being allowed in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow ip 10.0.0.0/24 --remove
+'''		
+
+Allow IP address range 10.10.0.0/24 in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 allow ip 10.10.0.0/24 --provisioner my_provisioner
+'''		
+
+Deny IP address 10.10.0.50 in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 deny ip 10.10.0.50 --provisioner my_provisioner
+'''		
+
+Remove IP address 10.10.0.50 from being denied in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 deny ip 10.10.0.50 --provisioner my_provisioner --remove
+'''		
+
+Allow IP address range 10.20.0.0/24 in X.509 certificates on ACME account level by EAB key reference
+'''
+$ step ca policy provisioner x509 allow ip 10.10.0.0/24 --provisioner my_acme_provisioner --eab-key-reference my_ref
+'''		
+
+Deny IP address 10.20.0.70 in X.509 certificates on ACME account level by EAB key reference
+'''
+$ step ca policy provisioner x509 deny ip 10.20.0.70 --provisioner my_acme_provisioner --eab-key-reference my_ref
+'''		
+
+Remove IP address 10.20.0.70 from being denied in X.509 certificates on ACME account level by EAB key reference
+'''
+$ step ca policy provisioner x509 deny ip 10.20.0.70 --provisioner my_acme_provisioner --eab-key-reference my_ref --remove
+'''		
+
+Allow IP address range 192.168.0.0/24 in SSH host certificates on authority level
+'''
+$ step ca policy authority ssh host allow ip 192.168.0.0/24
+'''		
+
+Deny IP address 192.168.0.40 in SSH host certificates on authority level
+'''
+$ step ca policy authority ssh host deny ip 192.168.0.40
+'''		
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			ipAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided IPs from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func ipAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return err
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.Ssh.Host.Allow.Ips = addOrRemoveArguments(policy.Ssh.Host.Allow.Ips, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.Ssh.Host.Deny.Ips = addOrRemoveArguments(policy.Ssh.Host.Deny.Ips, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	case policycontext.IsSSHUserPolicy(ctx):
+		return errors.New("SSH user policy does not support IP addresses or ranges")
+	case policycontext.IsX509Policy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.X509.Allow.Ips = addOrRemoveArguments(policy.X509.Allow.Ips, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.X509.Deny.Ips = addOrRemoveArguments(policy.X509.Deny.Ips, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/policy.go b/command/ca/policy/actions/policy.go
new file mode 100644
index 00000000..e4bc0d87
--- /dev/null
+++ b/command/ca/policy/actions/policy.go
@@ -0,0 +1,207 @@
+package actions
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"google.golang.org/protobuf/encoding/protojson"
+
+	"go.step.sm/cli-utils/errs"
+	"go.step.sm/linkedca"
+
+	"github.com/smallstep/certificates/ca"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/internal/command"
+)
+
+var provisionerFilterFlag = cli.StringFlag{
+	Name:  "provisioner",
+	Usage: `The provisioner <name>`,
+}
+
+func retrieveAndInitializePolicy(ctx context.Context, client *ca.AdminClient) (*linkedca.Policy, error) {
+
+	var (
+		policy *linkedca.Policy
+		err    error
+	)
+
+	clictx := command.CLIContextFromContext(ctx)
+	provisioner := clictx.String("provisioner")
+	reference := clictx.String("eab-key-reference")
+	keyID := clictx.String("eab-key-id")
+
+	switch {
+	case policycontext.IsAuthorityPolicyLevel(ctx):
+		policy, err = client.GetAuthorityPolicy()
+	case policycontext.IsProvisionerPolicyLevel(ctx):
+		if provisioner == "" {
+			return nil, errs.RequiredFlag(clictx, "provisioner")
+		}
+		policy, err = client.GetProvisionerPolicy(provisioner)
+	case policycontext.IsACMEPolicyLevel(ctx):
+		if provisioner == "" {
+			return nil, errs.RequiredFlag(clictx, "provisioner")
+		}
+		if reference == "" && keyID == "" {
+			return nil, errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id")
+		}
+		policy, err = client.GetACMEPolicy(provisioner, reference, keyID)
+	default:
+		panic("no context for policy retrieval set")
+	}
+
+	if err != nil {
+		var ae *ca.AdminClientError
+		if errors.As(err, &ae) && ae.Type == "notFound" { // TODO: use constant?
+			// when a policy doesn't exist yet, create a new, empty policy and
+			// send it to the CA.
+			newPolicy := newPolicy()
+			switch {
+			case policycontext.IsAuthorityPolicyLevel(ctx):
+				policy, err = client.CreateAuthorityPolicy(newPolicy)
+			case policycontext.IsProvisionerPolicyLevel(ctx):
+				policy, err = client.CreateProvisionerPolicy(provisioner, newPolicy)
+			case policycontext.IsACMEPolicyLevel(ctx):
+				policy, err = client.CreateACMEPolicy(provisioner, reference, keyID, newPolicy)
+			}
+			if err != nil {
+				return nil, fmt.Errorf("error creating policy: %w", err)
+			}
+		} else {
+			return nil, fmt.Errorf("error retrieving policy: %w", err)
+		}
+	}
+
+	// ensure all policy properties are set
+	policy = initPolicy(policy)
+
+	return policy, nil
+}
+
+func remove(item string, items []string) []string {
+	var i int
+	for _, v := range items {
+		if item != v {
+			items[i] = v
+			i++
+		}
+	}
+	return items[:i]
+}
+
+func newPolicy() *linkedca.Policy {
+	return initPolicy(nil)
+}
+
+// addOrRemoveArguments adds or removes args to/from existingNames
+func addOrRemoveArguments(existingNames, args []string, shouldRemove bool) []string {
+	if shouldRemove {
+		for _, name := range args {
+			existingNames = remove(name, existingNames)
+		}
+	} else {
+		existingNames = append(existingNames, args...)
+	}
+	return existingNames
+}
+
+func initPolicy(p *linkedca.Policy) *linkedca.Policy {
+	if p == nil {
+		p = &linkedca.Policy{}
+	}
+	if p.X509 == nil {
+		p.X509 = &linkedca.X509Policy{}
+		p.X509.AllowWildcardNames = false
+	}
+	if p.X509.Allow == nil {
+		p.X509.Allow = &linkedca.X509Names{}
+	}
+	if p.X509.Deny == nil {
+		p.X509.Deny = &linkedca.X509Names{}
+	}
+	if p.Ssh == nil {
+		p.Ssh = &linkedca.SSHPolicy{}
+	}
+	if p.Ssh.Host == nil {
+		p.Ssh.Host = &linkedca.SSHHostPolicy{}
+	}
+	if p.Ssh.Host.Allow == nil {
+		p.Ssh.Host.Allow = &linkedca.SSHHostNames{}
+	}
+	if p.Ssh.Host.Deny == nil {
+		p.Ssh.Host.Deny = &linkedca.SSHHostNames{}
+	}
+	if p.Ssh.User == nil {
+		p.Ssh.User = &linkedca.SSHUserPolicy{}
+	}
+	if p.Ssh.User.Allow == nil {
+		p.Ssh.User.Allow = &linkedca.SSHUserNames{}
+	}
+	if p.Ssh.User.Deny == nil {
+		p.Ssh.User.Deny = &linkedca.SSHUserNames{}
+	}
+	return p
+}
+
+func updatePolicy(ctx context.Context, client *ca.AdminClient, policy *linkedca.Policy) (*linkedca.Policy, error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+	provisioner := clictx.String("provisioner")
+	reference := clictx.String("eab-key-reference")
+	keyID := clictx.String("eab-key-id")
+
+	var (
+		updatedPolicy *linkedca.Policy
+		err           error
+	)
+
+	// deduplicate values before sending them
+	policy.Deduplicate()
+
+	switch {
+	case policycontext.IsAuthorityPolicyLevel(ctx):
+		updatedPolicy, err = client.UpdateAuthorityPolicy(policy)
+	case policycontext.IsProvisionerPolicyLevel(ctx):
+		if provisioner == "" {
+			return nil, errs.RequiredFlag(clictx, "provisioner")
+		}
+		updatedPolicy, err = client.UpdateProvisionerPolicy(provisioner, policy)
+	case policycontext.IsACMEPolicyLevel(ctx):
+		if provisioner == "" {
+			return nil, errs.RequiredFlag(clictx, "provisioner")
+		}
+		if reference == "" && keyID == "" {
+			return nil, errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id")
+		}
+		updatedPolicy, err = client.UpdateACMEPolicy(provisioner, reference, keyID, policy)
+	default:
+		panic("no context for policy update set")
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return updatedPolicy, nil
+}
+
+func prettyPrint(policy *linkedca.Policy) error {
+
+	b, err := protojson.Marshal(policy)
+	if err != nil {
+		return fmt.Errorf("error marshaling policy: %w", err)
+	}
+	var buf bytes.Buffer
+	if err := json.Indent(&buf, b, "", "   "); err != nil {
+		return fmt.Errorf("error indenting policy JSON representation: %w", err)
+	}
+
+	fmt.Println(buf.String())
+
+	return nil
+}
diff --git a/command/ca/policy/actions/principals.go b/command/ca/policy/actions/principals.go
new file mode 100644
index 00000000..52b2f8af
--- /dev/null
+++ b/command/ca/policy/actions/principals.go
@@ -0,0 +1,132 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// Command returns the principal policy subcommand.
+func PrincipalsCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "principal")
+	return cli.Command{
+		Name:  "principal",
+		Usage: "add or remove principals",
+		UsageText: fmt.Sprintf(`**%s** <principal> [**--remove**]
+[**--provisioner**=<name>] [**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages principals in SSH policies
+		
+## EXAMPLES	
+
+Allow all principals in SSH host certificates on authority level
+'''
+$ step ca policy authority ssh host allow principal "*"
+'''
+
+Allow all principals in SSH user certificates on authority level
+'''
+$ step ca policy authority ssh user allow principal "*"
+'''
+
+Allow principal machine-name in SSH host certificates on provisioner level
+'''
+$ step ca policy provisioner ssh host allow principal machine-name --provisioner my_ssh_host_provisioner
+'''
+
+Allow principal user in SSH user certificates on provisioner level
+'''
+$ step ca policy provisioner ssh host allow principal user --provisioner my_ssh_user_provisioner
+'''
+
+Deny principal root in SSH user certificates on provisioner level
+'''
+$ step ca policy provisioner ssh host deny principal root --provisioner my_ssh_user_provisioner
+'''
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			principalAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided Principals from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func principalAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return err
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.Ssh.Host.Allow.Principals = addOrRemoveArguments(policy.Ssh.Host.Allow.Principals, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.Ssh.Host.Deny.Principals = addOrRemoveArguments(policy.Ssh.Host.Deny.Principals, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	case policycontext.IsSSHUserPolicy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.Ssh.User.Allow.Principals = addOrRemoveArguments(policy.Ssh.User.Allow.Principals, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.Ssh.User.Deny.Principals = addOrRemoveArguments(policy.Ssh.User.Deny.Principals, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	case policycontext.IsX509Policy(ctx):
+		return errors.New("X.509 policy does not support principals")
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/remove.go b/command/ca/policy/actions/remove.go
new file mode 100644
index 00000000..2c9aecf5
--- /dev/null
+++ b/command/ca/policy/actions/remove.go
@@ -0,0 +1,114 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/smallstep/certificates/ca"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+)
+
+// RemoveCommand returns the policy remove subcommand.
+func RemoveCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "remove")
+	return cli.Command{
+		Name:  "remove",
+		Usage: "remove certificate issuance policy",
+		UsageText: `**step ca policy authority remove** 
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-ey-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`,
+		Description: fmt.Sprintf(`**%s** removes a certificate issuance policy.
+
+Remove the authority certificate issuance policy
+'''
+$ step ca policy authority remove
+'''
+
+Remove a provisioner certificate issuance policy
+'''
+$ step ca policy provisioner remove --provisioner my_provisioner
+'''
+
+Remove an ACME EAB certificate issuance policy by reference
+'''
+$ step ca policy acme remove --provisioner my_acme_provisioner --eab-key-reference my_reference
+'''
+
+Remove an ACME EAB certificate issuance policy by EAB Key ID
+'''
+$ step ca policy acme remove --provisioner my_acme_provisioner --eab-key-id "lUOTGwvFQADjk8nxsVufbhyTOwrFmvO2"
+'''
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			removeAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func removeAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+	provisioner := clictx.String("provisioner")
+	reference := clictx.String("eab-key-reference")
+	keyID := clictx.String("eab-key-id")
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	switch {
+	case policycontext.IsAuthorityPolicyLevel(ctx):
+		err = client.RemoveAuthorityPolicy()
+	case policycontext.IsProvisionerPolicyLevel(ctx):
+		if provisioner == "" {
+			return errs.RequiredFlag(clictx, "provisioner")
+		}
+		err = client.RemoveProvisionerPolicy(provisioner)
+	case policycontext.IsACMEPolicyLevel(ctx):
+		if provisioner == "" {
+			return errs.RequiredFlag(clictx, "provisioner")
+		}
+		if reference == "" && keyID == "" {
+			return errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id")
+		}
+		err = client.RemoveACMEPolicy(provisioner, reference, keyID)
+	default:
+		panic("no context for policy retrieval set")
+	}
+
+	if err != nil {
+		var ae *ca.AdminClientError
+		if errors.As(err, &ae) && ae.Type == "notFound" {
+			return errors.New("certificate issuance policy does not exist")
+		}
+		return fmt.Errorf("error deleting certificate issuance policy: %w", err)
+	}
+
+	fmt.Println("policy deleted")
+
+	return nil
+}
diff --git a/command/ca/policy/actions/uris.go b/command/ca/policy/actions/uris.go
new file mode 100644
index 00000000..ab127cca
--- /dev/null
+++ b/command/ca/policy/actions/uris.go
@@ -0,0 +1,120 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+// Command returns the policy subcommand.
+func URICommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "uri")
+	return cli.Command{
+		Name:  "uri",
+		Usage: "add or remove URI domains",
+		UsageText: fmt.Sprintf(`**%s** <uri domain> [**--remove**]
+[**--provisioner**=<name>] [**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`, commandName),
+		Description: fmt.Sprintf(`**%s** command manages URI domains in policies
+		
+## EXAMPLES	
+
+Allow all URI subdomains of "local" in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 allow uri "*.local"
+'''
+
+Deny URI badhost.local domain in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny uri badhost.local
+'''
+
+Remove badhost.local from denied URI domain names in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 deny uri badhost.local --remove
+'''
+
+Allow all URI subdomains of "example.com" in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 allow uri "*.example.com" --provisioner my_provisioner
+'''
+		
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			uriAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			cli.BoolFlag{
+				Name:  "remove",
+				Usage: `removes the provided URIs from the policy instead of adding them`,
+			},
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func uriAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	args := clictx.Args()
+	if len(args) == 0 {
+		return errs.TooFewArguments(clictx)
+	}
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return fmt.Errorf("error retrieving policy: %w", err)
+	}
+
+	shouldRemove := clictx.Bool("remove")
+
+	switch {
+	case policycontext.IsSSHHostPolicy(ctx):
+		return errors.New("SSH host policy does not support URIs")
+	case policycontext.IsSSHUserPolicy(ctx):
+		return errors.New("SSH user policy does not support URIs")
+	case policycontext.IsX509Policy(ctx):
+		switch {
+		case policycontext.IsAllow(ctx):
+			policy.X509.Allow.Uris = addOrRemoveArguments(policy.X509.Allow.Uris, args, shouldRemove)
+		case policycontext.IsDeny(ctx):
+			policy.X509.Deny.Uris = addOrRemoveArguments(policy.X509.Deny.Uris, args, shouldRemove)
+		default:
+			panic("no allow nor deny context set")
+		}
+	default:
+		panic("no SSH nor X.509 context set")
+	}
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/actions/view.go b/command/ca/policy/actions/view.go
new file mode 100644
index 00000000..aed36319
--- /dev/null
+++ b/command/ca/policy/actions/view.go
@@ -0,0 +1,124 @@
+package actions
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/smallstep/certificates/ca"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+	"github.com/urfave/cli"
+	"go.step.sm/cli-utils/errs"
+	"go.step.sm/linkedca"
+)
+
+// ViewCommand returns the policy view subcommand
+func ViewCommand(ctx context.Context) cli.Command {
+	commandName := policycontext.GetPrefixedCommandUsage(ctx, "view")
+	return cli.Command{
+		Name:  "view",
+		Usage: "view current certificate issuance policy",
+		UsageText: `**step ca policy authority view**
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`,
+		Description: fmt.Sprintf(`**%s** shows the currently configured policy.
+		
+## EXAMPLES
+
+View the authority certificate issuance policy
+'''
+$ step ca policy authority view
+'''
+
+View a provisioner certificate issuance policy
+'''
+$ step ca policy provisioner view --provisioner my_provisioner
+'''
+
+View an ACME EAB certificate issuance policy by reference
+'''
+$ step ca policy acme view --provisioner my_acme_provisioner --eab-key-reference my_reference
+'''
+
+View an ACME EAB certificate issuance policy by EAB Key ID
+'''
+$ step ca policy acme view --provisioner my_acme_provisioner --eab-key-id "lUOTGwvFQADjk8nxsVufbhyTOwrFmvO2"
+'''
+
+`, commandName),
+		Action: command.InjectContext(
+			ctx,
+			viewAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func viewAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+	provisioner := clictx.String("provisioner")
+	reference := clictx.String("eab-key-reference")
+	keyID := clictx.String("eab-key-id")
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	var (
+		policy *linkedca.Policy
+	)
+
+	switch {
+	case policycontext.IsAuthorityPolicyLevel(ctx):
+		policy, err = client.GetAuthorityPolicy()
+	case policycontext.IsProvisionerPolicyLevel(ctx):
+		if provisioner == "" {
+			return errs.RequiredFlag(clictx, "provisioner")
+		}
+		policy, err = client.GetProvisionerPolicy(provisioner)
+	case policycontext.IsACMEPolicyLevel(ctx):
+		if provisioner == "" {
+			return errs.RequiredFlag(clictx, "provisioner")
+		}
+		if reference == "" && keyID == "" {
+			return errs.RequiredOrFlag(clictx, "eab-key-reference", "eab-key-id")
+		}
+		policy, err = client.GetACMEPolicy(provisioner, reference, keyID)
+	default:
+		panic("no context for policy retrieval set")
+	}
+
+	if err != nil {
+		var ae *ca.AdminClientError
+		if errors.As(err, &ae) && ae.Type == "notFound" { // TODO: use constant?
+			fmt.Println("certificate issuance policy does not exist")
+			return nil
+		}
+
+		return fmt.Errorf("error retrieving authority policy: %w", err)
+	}
+
+	prettyPrint(policy)
+
+	return nil
+}
diff --git a/command/ca/policy/actions/wildcards.go b/command/ca/policy/actions/wildcards.go
new file mode 100644
index 00000000..c46e6629
--- /dev/null
+++ b/command/ca/policy/actions/wildcards.go
@@ -0,0 +1,57 @@
+package actions
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/smallstep/cli/internal/command"
+	"github.com/smallstep/cli/utils/cautils"
+)
+
+func AllowWildcardsAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return fmt.Errorf("error retrieving policy: %w", err)
+	}
+
+	policy.X509.AllowWildcardNames = true
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
+
+func DenyWildcardsAction(ctx context.Context) (err error) {
+
+	clictx := command.CLIContextFromContext(ctx)
+
+	client, err := cautils.NewAdminClient(clictx)
+	if err != nil {
+		return fmt.Errorf("error creating admin client: %w", err)
+	}
+
+	policy, err := retrieveAndInitializePolicy(ctx, client)
+	if err != nil {
+		return fmt.Errorf("error retrieving policy: %w", err)
+	}
+
+	policy.X509.AllowWildcardNames = false
+
+	updatedPolicy, err := updatePolicy(ctx, client, policy)
+	if err != nil {
+		return fmt.Errorf("error updating policy: %w", err)
+	}
+
+	return prettyPrint(updatedPolicy)
+}
diff --git a/command/ca/policy/authority/authority.go b/command/ca/policy/authority/authority.go
new file mode 100644
index 00000000..d3de33aa
--- /dev/null
+++ b/command/ca/policy/authority/authority.go
@@ -0,0 +1,29 @@
+package authority
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/command/ca/policy/ssh"
+	"github.com/smallstep/cli/command/ca/policy/x509"
+)
+
+// Command returns the authority policy subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithAuthorityPolicyLevel(ctx)
+	return cli.Command{
+		Name:        "authority",
+		Usage:       "manage certificate issuance policies for authorities",
+		UsageText:   "**step ca policy authority** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy authority** command group provides facilities for managing certificate issuance policies for authorities.`,
+		Subcommands: cli.Commands{
+			actions.ViewCommand(ctx),
+			actions.RemoveCommand(ctx),
+			x509.Command(ctx),
+			ssh.Command(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/policy.go b/command/ca/policy/policy.go
new file mode 100644
index 00000000..952f860b
--- /dev/null
+++ b/command/ca/policy/policy.go
@@ -0,0 +1,27 @@
+package policy
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/acme"
+	"github.com/smallstep/cli/command/ca/policy/authority"
+	"github.com/smallstep/cli/command/ca/policy/provisioner"
+)
+
+// Command returns the policy subcommand.
+func Command() cli.Command {
+	ctx := context.Background()
+	return cli.Command{
+		Name:        "policy",
+		Usage:       "manage certificate issuance policies",
+		UsageText:   "**step ca policy** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy** command group provides facilities for managing certificate issuance policies.`,
+		Subcommands: cli.Commands{
+			authority.Command(ctx),
+			provisioner.Command(ctx),
+			acme.Command(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/policycontext/context.go b/command/ca/policy/policycontext/context.go
new file mode 100644
index 00000000..2dcb761f
--- /dev/null
+++ b/command/ca/policy/policycontext/context.go
@@ -0,0 +1,168 @@
+package policycontext
+
+import (
+	"context"
+)
+
+type policyLevelContextKey struct{}
+
+type policyLevel int
+
+const (
+	authorityPolicyLevel policyLevel = iota + 1
+	provisionerPolicyLevel
+	acmePolicyLevel
+)
+
+// WithAuthorityPolicyLevel returns a new context.Context with
+// parent ctx and authority policy level set.
+func WithAuthorityPolicyLevel(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyLevelContextKey{}, authorityPolicyLevel)
+}
+
+// IsAuthorityPolicyLevel returns if the context.Context has authority policy level.
+func IsAuthorityPolicyLevel(ctx context.Context) bool {
+	return isPolicyLevel(ctx, authorityPolicyLevel)
+}
+
+// WithProvisionerPolicyLevel returns a new context.Context with
+// parent ctx and provisioner policy level set.
+func WithProvisionerPolicyLevel(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyLevelContextKey{}, provisionerPolicyLevel)
+}
+
+// IsProvisionerPolicyLevel returns if the context.Context has provisioner policy level.
+func IsProvisionerPolicyLevel(ctx context.Context) bool {
+	return isPolicyLevel(ctx, provisionerPolicyLevel)
+}
+
+// WithACMEPolicyLevel returns a new context.Context with
+// parent ctx and ACME account policy level set.
+func WithACMEPolicyLevel(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyLevelContextKey{}, acmePolicyLevel)
+}
+
+// IsACMEPolicyLevel returns if the context.Context has ACME account policy level.
+func IsACMEPolicyLevel(ctx context.Context) bool {
+	return isPolicyLevel(ctx, acmePolicyLevel)
+}
+
+// isPolicyLevel checks if the context.Context has the specified policy level set.
+func isPolicyLevel(ctx context.Context, level policyLevel) bool {
+	v, _ := ctx.Value(policyLevelContextKey{}).(policyLevel)
+	return v == level
+}
+
+type policyConfigurationTypeContextKey struct{}
+
+type policyConfigurationType int
+
+const (
+	x509PolicyType policyConfigurationType = iota + 1
+	sshHostPolicyType
+	sshUserPolicyType
+)
+
+// WithX509Policy returns a new context.Context with
+// parent ctx and X509 policy set.
+func WithX509Policy(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyConfigurationTypeContextKey{}, x509PolicyType)
+}
+
+// IsX509Policy returns if the context.Context has X.509 policy set.
+func IsX509Policy(ctx context.Context) bool {
+	v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType)
+	return v == x509PolicyType
+}
+
+// WithSSHHostPolicy returns a context.Context with SSH host policy set.
+func WithSSHHostPolicy(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyConfigurationTypeContextKey{}, sshHostPolicyType)
+}
+
+// IsSSHHostPolicy returns if the context.Context has SSH host policy set.
+func IsSSHHostPolicy(ctx context.Context) bool {
+	v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType)
+	return v == sshHostPolicyType
+}
+
+// WithSSHUserPolicy returns a context.Context with SSH user policy set.
+func WithSSHUserPolicy(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyConfigurationTypeContextKey{}, sshUserPolicyType)
+}
+
+// IsSSHUserPolicy returns if context.Context has SSH user policy set.
+func IsSSHUserPolicy(ctx context.Context) bool {
+	v, _ := ctx.Value(policyConfigurationTypeContextKey{}).(policyConfigurationType)
+	return v == sshUserPolicyType
+}
+
+type policyTypeContextKey struct{}
+
+type policyType int
+
+const (
+	allowType policyType = iota + 1
+	denyType
+)
+
+// WithAllow returns a context.Context with allow policy set.
+func WithAllow(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyTypeContextKey{}, allowType)
+}
+
+// IsAllow returns if the context.Context has allow set.
+func IsAllow(ctx context.Context) bool {
+	v, _ := ctx.Value(policyTypeContextKey{}).(policyType)
+	return v == allowType
+}
+
+// WithDeny returns a context.Context with deny set.
+func WithDeny(ctx context.Context) context.Context {
+	return context.WithValue(ctx, policyTypeContextKey{}, denyType)
+}
+
+// IsDeny returns if context.Context has deny set.
+func IsDeny(ctx context.Context) bool {
+	v, _ := ctx.Value(policyTypeContextKey{}).(policyType)
+	return v == denyType
+}
+
+func GetPrefixedCommandUsage(ctx context.Context, commandName string) string {
+	usage := "step ca policy"
+
+	switch {
+	case IsAuthorityPolicyLevel(ctx):
+		usage += " authority"
+	case IsProvisionerPolicyLevel(ctx):
+		usage += " provisioner"
+	case IsACMEPolicyLevel(ctx):
+		usage += " acme"
+	default:
+		panic("no policy level set")
+	}
+
+	switch {
+	case IsX509Policy(ctx):
+		usage += " x509"
+	case IsSSHHostPolicy(ctx):
+		usage += " ssh host"
+	case IsSSHUserPolicy(ctx):
+		usage += " ssh user"
+	default:
+		// noop; not every command using policycontext needs this to be set
+		break
+	}
+
+	switch {
+	case IsAllow(ctx):
+		usage += " allow"
+	case IsDeny(ctx):
+		usage += " deny"
+	default:
+		// noop; not every command using policycontext needs this to be set
+		break
+	}
+
+	return usage + " " + commandName
+}
diff --git a/command/ca/policy/provisioner/provisioner.go b/command/ca/policy/provisioner/provisioner.go
new file mode 100644
index 00000000..4548fae5
--- /dev/null
+++ b/command/ca/policy/provisioner/provisioner.go
@@ -0,0 +1,29 @@
+package provisioner
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+	"github.com/smallstep/cli/command/ca/policy/ssh"
+	"github.com/smallstep/cli/command/ca/policy/x509"
+)
+
+// Command returns the policy subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithProvisionerPolicyLevel(ctx)
+	return cli.Command{
+		Name:        "provisioner",
+		Usage:       "manage certificate issuance policies for provisioners",
+		UsageText:   "**step ca policy provisioner** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy provisioner** command group provides facilities for managing certificate issuance policies for provisioners.`,
+		Subcommands: cli.Commands{
+			actions.ViewCommand(ctx),
+			actions.RemoveCommand(ctx),
+			x509.Command(ctx),
+			ssh.Command(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/host/allow.go b/command/ca/policy/ssh/host/allow.go
new file mode 100644
index 00000000..ec6e70c7
--- /dev/null
+++ b/command/ca/policy/ssh/host/allow.go
@@ -0,0 +1,26 @@
+package host
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the SSH host allow subcommand.
+func allowCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithAllow(ctx)
+	return cli.Command{
+		Name:        "allow",
+		Usage:       "manage allowed SSH host certificate principals",
+		UsageText:   "**step ca policy ssh host allow** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh host allow** command group provides facilities for managing SSH host certificate principals to be allowed.`,
+		Subcommands: cli.Commands{
+			actions.DNSCommand(ctx),
+			actions.EmailCommand(ctx),
+			actions.PrincipalsCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/host/deny.go b/command/ca/policy/ssh/host/deny.go
new file mode 100644
index 00000000..2277c806
--- /dev/null
+++ b/command/ca/policy/ssh/host/deny.go
@@ -0,0 +1,26 @@
+package host
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the SSH host deny subcommand.
+func denyCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithDeny(ctx)
+	return cli.Command{
+		Name:        "deny",
+		Usage:       "manage denied dSSH host certificate principals",
+		UsageText:   "**step ca policy ssh host deny** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh host deny** command group provides facilities for managing SSH host certificate principals to be denied.`,
+		Subcommands: cli.Commands{
+			actions.DNSCommand(ctx),
+			actions.EmailCommand(ctx),
+			actions.PrincipalsCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/host/host.go b/command/ca/policy/ssh/host/host.go
new file mode 100644
index 00000000..d03b6eb0
--- /dev/null
+++ b/command/ca/policy/ssh/host/host.go
@@ -0,0 +1,24 @@
+package host
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the SSH host policy subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithSSHHostPolicy(ctx)
+	return cli.Command{
+		Name:        "host",
+		Usage:       "manage SSH host certificate issuance policies",
+		UsageText:   "**step ca policy ssh host** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh host** command group provides facilities for managing SSH host certificate issuance policies.`,
+		Subcommands: cli.Commands{
+			allowCommand(ctx),
+			denyCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/ssh.go b/command/ca/policy/ssh/ssh.go
new file mode 100644
index 00000000..bcb19aa5
--- /dev/null
+++ b/command/ca/policy/ssh/ssh.go
@@ -0,0 +1,24 @@
+package ssh
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/ssh/host"
+	"github.com/smallstep/cli/command/ca/policy/ssh/user"
+)
+
+// Command returns the ssh subcommand.
+func Command(ctx context.Context) cli.Command {
+	return cli.Command{
+		Name:        "ssh",
+		Usage:       "manage SSH certificate issuance policies",
+		UsageText:   "**step ca policy ssh** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh** command group provides facilities for managing SSH certificate issuance policies.`,
+		Subcommands: cli.Commands{
+			host.Command(ctx),
+			user.Command(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/user/allow.go b/command/ca/policy/ssh/user/allow.go
new file mode 100644
index 00000000..64015094
--- /dev/null
+++ b/command/ca/policy/ssh/user/allow.go
@@ -0,0 +1,25 @@
+package user
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// allowCommand returns the SSH user allow subcommand.
+func allowCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithAllow(ctx)
+	return cli.Command{
+		Name:        "allow",
+		Usage:       "manage allowed SSH user certificate principals",
+		UsageText:   "**step ca policy ssh user allow** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh user allow** command group provides facilities for managing SSH user certificate principals to be allowed.`,
+		Subcommands: cli.Commands{
+			actions.EmailCommand(ctx),
+			actions.PrincipalsCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/user/deny.go b/command/ca/policy/ssh/user/deny.go
new file mode 100644
index 00000000..9697d0fd
--- /dev/null
+++ b/command/ca/policy/ssh/user/deny.go
@@ -0,0 +1,25 @@
+package user
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// denyCommand returns the SSH user deny subcommand.
+func denyCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithDeny(ctx)
+	return cli.Command{
+		Name:        "deny",
+		Usage:       "manage denied SSH user certificate principals",
+		UsageText:   "**step ca policy ssh user deny** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh user deny** command group provides facilities for managing SSH user certificate principals to be denied.`,
+		Subcommands: cli.Commands{
+			actions.EmailCommand(ctx),
+			actions.PrincipalsCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/ssh/user/user.go b/command/ca/policy/ssh/user/user.go
new file mode 100644
index 00000000..22a597c1
--- /dev/null
+++ b/command/ca/policy/ssh/user/user.go
@@ -0,0 +1,24 @@
+package user
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the SSH user policy subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithSSHUserPolicy(ctx)
+	return cli.Command{
+		Name:        "user",
+		Usage:       "manage SSH user certificate issuance policies",
+		UsageText:   "**step ca policy ssh user** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy ssh user** command group provides facilities for managing SSH user certificate issuance policies.`,
+		Subcommands: cli.Commands{
+			allowCommand(ctx),
+			denyCommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/x509/allow.go b/command/ca/policy/x509/allow.go
new file mode 100644
index 00000000..d0e1c965
--- /dev/null
+++ b/command/ca/policy/x509/allow.go
@@ -0,0 +1,28 @@
+package x509
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// allowCommand returns the allow subcommand.
+func allowCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithAllow(ctx)
+	return cli.Command{
+		Name:        "allow",
+		Usage:       "manage allowed names for X.509 certificate issuance policies",
+		UsageText:   "**step ca policy x509 allow** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy x509 allow** command group provides facilities for managing X.509 names to be allowed.`,
+		Subcommands: cli.Commands{
+			actions.CommonNamesCommand(ctx),
+			actions.DNSCommand(ctx),
+			actions.EmailCommand(ctx),
+			actions.IPCommand(ctx),
+			actions.URICommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/x509/deny.go b/command/ca/policy/x509/deny.go
new file mode 100644
index 00000000..d4190a10
--- /dev/null
+++ b/command/ca/policy/x509/deny.go
@@ -0,0 +1,28 @@
+package x509
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the deny subcommand.
+func denyCommand(ctx context.Context) cli.Command {
+	ctx = policycontext.WithDeny(ctx)
+	return cli.Command{
+		Name:        "deny",
+		Usage:       "manage denied names for X.509 certificate issuance policies",
+		UsageText:   "**step ca policy x509 deny** <subcommand> [arguments] [global-flags] [subcommand-flags]",
+		Description: `**step ca policy x509 deny** command group provides facilities for managing X.509 names to be denied.`,
+		Subcommands: cli.Commands{
+			actions.CommonNamesCommand(ctx),
+			actions.DNSCommand(ctx),
+			actions.EmailCommand(ctx),
+			actions.IPCommand(ctx),
+			actions.URICommand(ctx),
+		},
+	}
+}
diff --git a/command/ca/policy/x509/wildcards.go b/command/ca/policy/x509/wildcards.go
new file mode 100644
index 00000000..095f7900
--- /dev/null
+++ b/command/ca/policy/x509/wildcards.go
@@ -0,0 +1,124 @@
+package x509
+
+import (
+	"context"
+
+	"github.com/smallstep/cli/command/ca/policy/actions"
+	"github.com/smallstep/cli/flags"
+	"github.com/smallstep/cli/internal/command"
+	"github.com/urfave/cli"
+)
+
+var provisionerFilterFlag = cli.StringFlag{
+	Name:  "provisioner",
+	Usage: `The provisioner <name>`,
+}
+
+func wildcardsCommand(ctx context.Context) cli.Command {
+	return cli.Command{
+		Name:        "wildcards",
+		Usage:       "manage wildcard name settings for X.509 certificate issuance policies",
+		UsageText:   `**step ca policy x509 wildcards**`,
+		Description: `**step ca policy x509 wildcards** command group provides facilities for managing X.509 wildcard names.`,
+		Subcommands: cli.Commands{
+			allowWildcardsCommand(ctx),
+			denyWildcardsCommand(ctx),
+		},
+	}
+}
+
+func allowWildcardsCommand(ctx context.Context) cli.Command {
+	return cli.Command{
+		Name:  "allow",
+		Usage: "allow wildcard names in X.509 certificate issuance policies",
+		UsageText: `**step ca policy x509 wildcards allow**
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`,
+		Description: `**step ca policy x509 wildcards allow** allow wildcard names in X.509 policy
+
+## EXAMPLES	
+
+Allow wildcard names in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 wildcards allow
+'''		
+
+Allow wildcard names in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 wildcards allow --provisioner my_provisioner
+'''		
+
+Allow wildcard names in X.509 certificates on ACME account level by reference
+'''
+$ step ca policy acme x509 wildcards allow --provisioner my_acme_provisioner --eab-reference my_reference
+'''`,
+		Action: command.InjectContext(
+			ctx,
+			actions.AllowWildcardsAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
+
+func denyWildcardsCommand(ctx context.Context) cli.Command {
+	return cli.Command{
+		Name:  "deny",
+		Usage: "deny wildcard names in X.509 certificate issuance policies",
+		UsageText: `**step ca policy x509 wildcards deny**
+[**--provisioner**=<name>] [**--eab-key-id**=<eab-key-id>] [**--eab-key-reference**=<eab-key-reference>]
+[**--admin-cert**=<file>] [**--admin-key**=<file>]
+[**--admin-provisioner**=<string>] [**--admin-subject**=<string>]
+[**--password-file**=<file>] [**--ca-url**=<uri>] [**--root**=<file>]
+[**--context**=<name>]`,
+		Description: `**step ca policy x509 wildcards deny** deny wildcard names in X.509 policy
+
+## EXAMPLES	
+
+Deny wildcard names in X.509 certificates on authority level
+'''
+$ step ca policy authority x509 wildcards deny
+'''		
+
+Deny wildcard names in X.509 certificates on provisioner level
+'''
+$ step ca policy provisioner x509 wildcards deny --provisioner my_provisioner
+'''		
+
+Deny wildcard names in X.509 certificates on ACME account level by reference
+'''
+$ step ca policy acme x509 wildcards deny --provisioner my_acme_provisioner --eab-reference my_reference
+'''`,
+		Action: command.InjectContext(
+			ctx,
+			actions.DenyWildcardsAction,
+		),
+		Flags: []cli.Flag{
+			provisionerFilterFlag,
+			flags.EABKeyID,
+			flags.EABReference,
+			flags.AdminCert,
+			flags.AdminKey,
+			flags.AdminProvisioner,
+			flags.AdminSubject,
+			flags.PasswordFile,
+			flags.CaURL,
+			flags.Root,
+			flags.Context,
+		},
+	}
+}
diff --git a/command/ca/policy/x509/x509.go b/command/ca/policy/x509/x509.go
new file mode 100644
index 00000000..2dbafc9b
--- /dev/null
+++ b/command/ca/policy/x509/x509.go
@@ -0,0 +1,25 @@
+package x509
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+
+	"github.com/smallstep/cli/command/ca/policy/policycontext"
+)
+
+// Command returns the x509 subcommand.
+func Command(ctx context.Context) cli.Command {
+	ctx = policycontext.WithX509Policy(ctx)
+	return cli.Command{
+		Name:        "x509",
+		Usage:       "manage X.509 certificate issuance policies",
+		UsageText:   `**step ca policy x509** <subcommand> [arguments] [global-flags] [subcommand-flags]`,
+		Description: `**step ca policy x509** command group provides facilities for managing X.509 certificate issuance policies.`,
+		Subcommands: cli.Commands{
+			allowCommand(ctx),
+			denyCommand(ctx),
+			wildcardsCommand(ctx),
+		},
+	}
+}
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 14e1e131..bf2deb6b 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -76,7 +76,7 @@ starting with a capital letter and no trailing period: *"Return error on wrong
 use of the Paginator"*, **NOT** *"returning some error."*
 
 Also, if your commit references one or more GitHub issues, always end your
-commit message body with *See #1234* or *Fixes #1234*.  Replace *1234* with the
+commit message body with *See #1234* or *Fixes #1234*. Replace *1234* with the
 GitHub issue ID. The last example will close the issue when the commit is
 merged into *master*.
 
diff --git a/flags/flags.go b/flags/flags.go
index cfb4fcf4..777a7b3a 100644
--- a/flags/flags.go
+++ b/flags/flags.go
@@ -375,6 +375,18 @@ domain name 'certs.example-team.ca.smallstep.com' the value would be 'certs'.`,
 		Usage: `The certificate identity. It is usually passed as a positional argument, but a
 flag exists so it can be configured in $STEPPATH/config/defaults.json.`,
 	}
+
+	// EABKeyID is a cli.Flag that points to an ACME EAB Key ID
+	EABKeyID = cli.StringFlag{
+		Name:  "eab-key-id",
+		Usage: "An ACME EAB Key ID",
+	}
+
+	// EABReference is a cli.Flag that points to an ACME EAB Key Reference
+	EABReference = cli.StringFlag{
+		Name:  "eab-key-reference",
+		Usage: "An ACME EAB Key Reference",
+	}
 )
 
 // ParseTimeOrDuration is a helper that returns the time or the current time
diff --git a/go.mod b/go.mod
index 4afa20fa..439b2d20 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/shurcooL/sanitized_anchor_name v1.0.0
 	github.com/slackhq/nebula v1.5.2
 	github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262
-	github.com/smallstep/certificates v0.19.0
+	github.com/smallstep/certificates v0.19.1-0.20220506171134-65090daac3d7
 	github.com/smallstep/certinfo v1.7.0
 	github.com/smallstep/truststore v0.11.0
 	github.com/smallstep/zcrypto v0.0.0-20210924233136-66c2600f6e71
@@ -24,12 +24,12 @@ require (
 	go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352
 	go.step.sm/cli-utils v0.7.2
 	go.step.sm/crypto v0.16.1
-	go.step.sm/linkedca v0.15.0
+	go.step.sm/linkedca v0.16.1
 	golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
-	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
-	golang.org/x/sys v0.0.0-20220209214540-3681064d5158
+	golang.org/x/net v0.0.0-20220403103023-749bd193bc2b
+	golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
-	google.golang.org/protobuf v1.27.1
+	google.golang.org/protobuf v1.28.0
 	gopkg.in/square/go-jose.v2 v2.6.0
 	software.sslmate.com/src/go-pkcs12 v0.2.0
 )
@@ -181,8 +181,8 @@ require (
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/api v0.70.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf // indirect
-	google.golang.org/grpc v1.44.0 // indirect
+	google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de // indirect
+	google.golang.org/grpc v1.45.0 // indirect
 	gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -192,7 +192,9 @@ require (
 )
 
 // replace github.com/smallstep/certificates => ../certificates
+
 // replace github.com/smallstep/certinfo => ../certinfo
 // replace go.step.sm/linkedca => ../linkedca
+
 // replace go.step.sm/cli-utils => ../cli-utils
 // replace go.step.sm/crypto => ../crypto
diff --git a/go.sum b/go.sum
index f7cd3536..5c06a1ae 100644
--- a/go.sum
+++ b/go.sum
@@ -120,7 +120,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
 github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20191009163259-e802c2cb94ae/go.mod h1:mjwGPas4yKduTyubHvD1Atl9r1rUq8DfVy+gkVvZ+oo=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
-github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
 github.com/Masterminds/glide v0.13.2/go.mod h1:STyF5vcenH/rUqTEv+/hBXlSTo7KYwg2oc2f4tzPWic=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -187,7 +186,6 @@ github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.25.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.30.29/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
 github.com/aws/aws-sdk-go v1.37.0 h1:GzFnhOIsrGyQ69s7VgqtrG2BG8v7X7vwB3Xpbd/DBBk=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
@@ -666,7 +664,6 @@ github.com/jhump/protoreflect v1.9.0 h1:npqHz788dryJiR/l6K/RUQAyh2SwV91+d1dnh4Rj
 github.com/jhump/protoreflect v1.9.0/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
@@ -950,8 +947,8 @@ github.com/slackhq/nebula v1.5.2/go.mod h1:xaCM6wqbFk/NRmmUe1bv88fWBm3a1UioXJVIp
 github.com/smallstep/assert v0.0.0-20180720014142-de77670473b5/go.mod h1:TC9A4+RjIOS+HyTH7wG17/gSqVv95uDw2J64dQZx7RE=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
-github.com/smallstep/certificates v0.19.0 h1:wW344Q/QpupjKKFKa4PqzEXfwgeq/54dkU/HNvGnwQQ=
-github.com/smallstep/certificates v0.19.0/go.mod h1:nkG4c+1HLnCmIBAgZ/bKaBPAsN7ePtyzkDWzPSqcObQ=
+github.com/smallstep/certificates v0.19.1-0.20220506171134-65090daac3d7 h1:5tQi9lp+HqVAx+8fZ8lMlnoPTu3siTL0ue4opGG5yNw=
+github.com/smallstep/certificates v0.19.1-0.20220506171134-65090daac3d7/go.mod h1:6nHwvdMjnVUiJ+fePztVLgOCSk80mMkq9rlj6xxYB5E=
 github.com/smallstep/certinfo v1.7.0 h1:1VzGgeSFLdXO242mRUkJu02epXV6E9uawjhPH0H2LzA=
 github.com/smallstep/certinfo v1.7.0/go.mod h1:QRjP6s+cuishA6cdB//RX357ysYGz/QxlpWGyWjnfII=
 github.com/smallstep/nosql v0.4.0 h1:Go3WYwttUuvwqMtFiiU4g7kBIlY+hR0bIZAqVdakQ3M=
@@ -1141,8 +1138,8 @@ go.step.sm/cli-utils v0.7.2/go.mod h1:Ur6bqA/yl636kCUJbp30J7Unv5JJ226eW2KqXPDwF/
 go.step.sm/crypto v0.9.0/go.mod h1:+CYG05Mek1YDqi5WK0ERc6cOpKly2i/a5aZmU1sfGj0=
 go.step.sm/crypto v0.16.1 h1:4mnZk21cSxyMGxsEpJwZKKvJvDu1PN09UVrWWFNUBdk=
 go.step.sm/crypto v0.16.1/go.mod h1:3G0yQr5lQqfEG0CMYz8apC/qMtjLRQlzflL2AxkcN+g=
-go.step.sm/linkedca v0.15.0 h1:lEkGRDY+u7FudGKt8yEo7nBy5OzceO9s3rl+/sZVL5M=
-go.step.sm/linkedca v0.15.0/go.mod h1:W59ucS4vFpuR0g4PtkGbbtXAwxbDEnNCg+ovkej1ANM=
+go.step.sm/linkedca v0.16.1 h1:CdbMV5SjnlRsgeYTXaaZmQCkYIgJq8BOzpewri57M2k=
+go.step.sm/linkedca v0.16.1/go.mod h1:W59ucS4vFpuR0g4PtkGbbtXAwxbDEnNCg+ovkej1ANM=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@@ -1292,8 +1289,9 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
+golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1423,8 +1421,9 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 h1:D1v9ucDTYBtbz5vNuBbAhIMAGhQhJ6Ym5ah3maMVNX4=
+golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -1659,8 +1658,9 @@ google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf h1:SVYXkUz2yZS9FWb2Gm8ivSlbNQzL2Z/NpPKE3RG2jWk=
 google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de h1:9Ti5SG2U4cAcluryUo/sFay3TQKoxiFMfaT0pbizU7k=
+google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1695,8 +1695,9 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
 google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
-google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
 google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -1711,8 +1712,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/command/inject.go b/internal/command/inject.go
new file mode 100644
index 00000000..b150cdb3
--- /dev/null
+++ b/internal/command/inject.go
@@ -0,0 +1,56 @@
+package command
+
+import (
+	"context"
+
+	"github.com/urfave/cli"
+)
+
+// InjectContext injects an existing Context as the first middleware
+// and then wraps a function with middleware using that context. It
+// returns a cli.ActionFunc with the cli.Context added to the Context.
+// By injecting the existing context as the first middleware, we ensure
+// that it's the Context all later middlewares operate on.
+func InjectContext(injectedCtx context.Context, fn func(context.Context) error, middleware ...func(context.Context) (context.Context, error)) cli.ActionFunc {
+	injectedMiddleware := []func(context.Context) (context.Context, error){
+		func(context.Context) (context.Context, error) {
+			return injectedCtx, nil
+		},
+	}
+	injectedMiddleware = append(injectedMiddleware, middleware...)
+	return wrap(fn, injectedMiddleware...)
+}
+
+type cliCtxKey struct{}
+
+// withCLIContext adds a cli.Context to the Context.
+func withCLIContext(ctx context.Context, clictx *cli.Context) context.Context {
+	return context.WithValue(ctx, cliCtxKey{}, clictx)
+}
+
+// CLIContextFromContext returns a pointer to a cli.Context
+// It panics when the cli.Context is not set.
+func CLIContextFromContext(ctx context.Context) *cli.Context {
+	return ctx.Value(cliCtxKey{}).(*cli.Context)
+}
+
+// wrap wraps a function with middleware using a new context. It returns a cli.ActionFunc with
+// the cli.Context added to the Context.
+func wrap(fn func(context.Context) error, middleware ...func(context.Context) (context.Context, error)) cli.ActionFunc {
+	return func(clictx *cli.Context) error {
+
+		ctx := context.Background()
+
+		// apply middleware to the new context
+		for _, fn := range middleware {
+			var err error
+			if ctx, err = fn(ctx); err != nil {
+				return err
+			}
+		}
+
+		ctx = withCLIContext(ctx, clictx)
+
+		return fn(ctx)
+	}
+}


Test patch:
diff --git a/command/ca/policy/actions/policy_test.go b/command/ca/policy/actions/policy_test.go
new file mode 100644
index 00000000..ff14111e
--- /dev/null
+++ b/command/ca/policy/actions/policy_test.go
@@ -0,0 +1,50 @@
+package actions
+
+import (
+	"reflect"
+	"testing"
+)
+
+func Test_remove(t *testing.T) {
+	type args struct {
+		item  string
+		items []string
+	}
+	tests := []struct {
+		name string
+		args args
+		want []string
+	}{
+		{
+			name: "empty-slice",
+			args: args{
+				item:  "test",
+				items: []string{},
+			},
+			want: []string{},
+		},
+		{
+			name: "empty-item",
+			args: args{
+				item:  "",
+				items: []string{"item"},
+			},
+			want: []string{"item"},
+		},
+		{
+			name: "ok",
+			args: args{
+				item:  "item1",
+				items: []string{"item1", "item2"},
+			},
+			want: []string{"item2"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := remove(tt.args.item, tt.args.items); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("remove() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/command/ca/policy/policycontext/context_test.go b/command/ca/policy/policycontext/context_test.go
new file mode 100644
index 00000000..2f2aa571
--- /dev/null
+++ b/command/ca/policy/policycontext/context_test.go
@@ -0,0 +1,58 @@
+package policycontext
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_hasPolicyLevel(t *testing.T) {
+	type args struct {
+		ctx   context.Context
+		level policyLevel
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{
+		{
+			name: "not-set",
+			args: args{
+				ctx:   context.Background(),
+				level: authorityPolicyLevel,
+			},
+			want: false,
+		},
+		{
+			name: "false",
+			args: args{
+				ctx:   WithAuthorityPolicyLevel(context.Background()),
+				level: provisionerPolicyLevel,
+			},
+			want: false,
+		},
+		{
+			name: "true",
+			args: args{
+				ctx:   WithAuthorityPolicyLevel(context.Background()),
+				level: authorityPolicyLevel,
+			},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := isPolicyLevel(tt.args.ctx, tt.args.level); got != tt.want {
+				t.Errorf("hasPolicyLevel() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_hasPolicyLevelPanics(t *testing.T) {
+	t.Parallel()
+	//nolint:staticcheck // explicit test for a nil context
+	assert.Panics(t, func() { isPolicyLevel(nil, authorityPolicyLevel) })
+}
diff --git a/internal/command/inject_test.go b/internal/command/inject_test.go
new file mode 100644
index 00000000..dbbc644d
--- /dev/null
+++ b/internal/command/inject_test.go
@@ -0,0 +1,132 @@
+package command
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/urfave/cli"
+)
+
+func TestCLIContextFromContext(t *testing.T) {
+	t.Parallel()
+
+	exp := new(cli.Context)
+
+	got := CLIContextFromContext(withCLIContext(context.Background(), exp))
+	assert.Same(t, exp, got)
+}
+
+func TestCLIContextFromContextPanics(t *testing.T) {
+	t.Parallel()
+
+	assert.Panics(t, func() { CLIContextFromContext(context.Background()) })
+}
+
+func TestInjectContext(t *testing.T) {
+	t.Parallel()
+
+	fnCalled := false
+
+	type contextKey struct{}
+
+	ctx := context.Background()
+	ctx = context.WithValue(ctx, contextKey{}, true)
+
+	fn := func(ctx context.Context) error {
+		assert.True(t, ctx.Value(contextKey{}).(bool))
+		clictx := CLIContextFromContext(ctx)
+		assert.NotNil(t, clictx)
+		fnCalled = true
+		return nil
+	}
+
+	got := InjectContext(ctx, fn)
+
+	assert.NotNil(t, got)
+
+	// execute the function to ensure injected context is OK
+	err := got(new(cli.Context))
+	assert.NoError(t, err)
+	assert.True(t, fnCalled)
+}
+
+func TestInjectContextWithMiddleware(t *testing.T) {
+	t.Parallel()
+
+	fnCalled := false
+
+	type contextKey struct{}
+	type firstMiddlewareContextKey struct{}
+	type secondMiddlewareContextKey struct{}
+
+	ctx := context.Background()
+	ctx = context.WithValue(ctx, contextKey{}, true)
+
+	fn := func(ctx context.Context) error {
+		assert.True(t, ctx.Value(contextKey{}).(bool))
+		clictx := CLIContextFromContext(ctx)
+		assert.NotNil(t, clictx)
+		assert.Equal(t, 42, ctx.Value(firstMiddlewareContextKey{}).(int))
+		assert.Equal(t, "value", ctx.Value(secondMiddlewareContextKey{}).(string))
+		fnCalled = true
+		return nil
+	}
+
+	middleware := []func(context.Context) (context.Context, error){
+		func(ctx context.Context) (context.Context, error) {
+			return context.WithValue(ctx, firstMiddlewareContextKey{}, 42), nil
+		},
+		func(ctx context.Context) (context.Context, error) {
+			return context.WithValue(ctx, secondMiddlewareContextKey{}, "value"), nil
+		},
+	}
+
+	got := InjectContext(ctx, fn, middleware...)
+
+	assert.NotNil(t, got)
+
+	// execute the function to ensure injected context is OK
+	err := got(new(cli.Context))
+	assert.NoError(t, err)
+	assert.True(t, fnCalled)
+}
+
+func TestInjectContextWithMiddlewareError(t *testing.T) {
+	t.Parallel()
+
+	fnCalled := false
+
+	type contextKey struct{}
+
+	ctx := context.Background()
+	ctx = context.WithValue(ctx, contextKey{}, true)
+
+	fn := func(ctx context.Context) error {
+		assert.True(t, ctx.Value(contextKey{}).(bool))
+		clictx := CLIContextFromContext(ctx)
+		assert.NotNil(t, clictx)
+		fnCalled = true
+		return nil
+	}
+
+	var middlewareError = errors.New("a middleware error")
+
+	middleware := []func(context.Context) (context.Context, error){
+		func(context.Context) (context.Context, error) {
+			assert.True(t, ctx.Value(contextKey{}).(bool))
+			return nil, middlewareError
+		},
+	}
+
+	got := InjectContext(ctx, fn, middleware...)
+
+	assert.NotNil(t, got)
+
+	// execute the function and verify middleware resulted in error
+	err := got(new(cli.Context))
+	assert.Error(t, err)
+	assert.Equal(t, middlewareError, err)
+	assert.False(t, fnCalled) // fn is not called because of middleware error
+}