forked from TrueCloudLab/certificates
Use CertificateRequest type as input for ssh NewCertificate.
SSH does not have a real concept of ssh certificate request, but we are using the type to encapsulate the parameters coming in the request.
This commit is contained in:
parent
ad28f0f59a
commit
df1f7e5a2e
7 changed files with 118 additions and 72 deletions
|
@ -29,8 +29,8 @@ type Certificate struct {
|
|||
|
||||
// NewCertificate creates a new certificate with the given key after parsing a
|
||||
// template given in the options.
|
||||
func NewCertificate(key ssh.PublicKey, opts ...Option) (*Certificate, error) {
|
||||
o, err := new(Options).apply(key, opts)
|
||||
func NewCertificate(cr CertificateRequest, opts ...Option) (*Certificate, error) {
|
||||
o, err := new(Options).apply(cr, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ func NewCertificate(key ssh.PublicKey, opts ...Option) (*Certificate, error) {
|
|||
}
|
||||
|
||||
// Complete with public key
|
||||
cert.Key = key
|
||||
cert.Key = cr.Key
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
|
17
sshutil/certificate_request.go
Normal file
17
sshutil/certificate_request.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package sshutil
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
|
||||
// CertificateRequests simulates a certificate request for SSH. SSH does not
|
||||
// have a concept of certificate requests, but the CA accepts the key and some
|
||||
// other parameters in the requests that are part of the certificate. This
|
||||
// struct will hold these parameters.
|
||||
//
|
||||
// CertificateRequests object will be used in the templates to set parameters
|
||||
// passed with the API instead of the validated ones.
|
||||
type CertificateRequest struct {
|
||||
Key ssh.PublicKey
|
||||
Type CertType
|
||||
KeyID string
|
||||
Principals []string
|
||||
}
|
|
@ -36,9 +36,12 @@ func mustGeneratePublicKey(t *testing.T) ssh.PublicKey {
|
|||
|
||||
func TestNewCertificate(t *testing.T) {
|
||||
key := mustGeneratePublicKey(t)
|
||||
cr := CertificateRequest{
|
||||
Key: key,
|
||||
}
|
||||
|
||||
type args struct {
|
||||
key ssh.PublicKey
|
||||
cr CertificateRequest
|
||||
opts []Option
|
||||
}
|
||||
tests := []struct {
|
||||
|
@ -47,7 +50,7 @@ func TestNewCertificate(t *testing.T) {
|
|||
want *Certificate
|
||||
wantErr bool
|
||||
}{
|
||||
{"user", args{key, []Option{WithTemplate(DefaultCertificate, CreateTemplateData(UserCert, "jane@doe.com", []string{"jane"}))}}, &Certificate{
|
||||
{"user", args{cr, []Option{WithTemplate(DefaultCertificate, CreateTemplateData(UserCert, "jane@doe.com", []string{"jane"}))}}, &Certificate{
|
||||
Nonce: nil,
|
||||
Key: key,
|
||||
Serial: 0,
|
||||
|
@ -68,7 +71,7 @@ func TestNewCertificate(t *testing.T) {
|
|||
SignatureKey: nil,
|
||||
Signature: nil,
|
||||
}, false},
|
||||
{"host", args{key, []Option{WithTemplate(DefaultCertificate, CreateTemplateData(HostCert, "foobar", []string{"foo.internal", "bar.internal"}))}}, &Certificate{
|
||||
{"host", args{cr, []Option{WithTemplate(DefaultCertificate, CreateTemplateData(HostCert, "foobar", []string{"foo.internal", "bar.internal"}))}}, &Certificate{
|
||||
Nonce: nil,
|
||||
Key: key,
|
||||
Serial: 0,
|
||||
|
@ -83,12 +86,12 @@ func TestNewCertificate(t *testing.T) {
|
|||
SignatureKey: nil,
|
||||
Signature: nil,
|
||||
}, false},
|
||||
{"file", args{key, []Option{WithTemplateFile("./testdata/github.tpl", TemplateData{
|
||||
{"file", args{cr, []Option{WithTemplateFile("./testdata/github.tpl", TemplateData{
|
||||
TypeKey: UserCert,
|
||||
KeyIDKey: "john@doe.com",
|
||||
PrincipalsKey: []string{"john", "john@doe.com"},
|
||||
ExtensionsKey: DefaultExtensions(UserCert),
|
||||
InsecureKey: map[string]interface{}{
|
||||
InsecureKey: TemplateData{
|
||||
"User": map[string]interface{}{"username": "john"},
|
||||
},
|
||||
})}}, &Certificate{
|
||||
|
@ -102,18 +105,18 @@ func TestNewCertificate(t *testing.T) {
|
|||
ValidBefore: 0,
|
||||
CriticalOptions: nil,
|
||||
Extensions: map[string]string{
|
||||
"login@github.com": "john",
|
||||
"permit-X11-forwarding": "",
|
||||
"permit-agent-forwarding": "",
|
||||
"permit-port-forwarding": "",
|
||||
"permit-pty": "",
|
||||
"permit-user-rc": "",
|
||||
"login@github.com": "john",
|
||||
},
|
||||
Reserved: nil,
|
||||
SignatureKey: nil,
|
||||
Signature: nil,
|
||||
}, false},
|
||||
{"base64", args{key, []Option{WithTemplateBase64(base64.StdEncoding.EncodeToString([]byte(DefaultCertificate)), CreateTemplateData(HostCert, "foo.internal", nil))}}, &Certificate{
|
||||
{"base64", args{cr, []Option{WithTemplateBase64(base64.StdEncoding.EncodeToString([]byte(DefaultCertificate)), CreateTemplateData(HostCert, "foo.internal", nil))}}, &Certificate{
|
||||
Nonce: nil,
|
||||
Key: key,
|
||||
Serial: 0,
|
||||
|
@ -128,22 +131,22 @@ func TestNewCertificate(t *testing.T) {
|
|||
SignatureKey: nil,
|
||||
Signature: nil,
|
||||
}, false},
|
||||
{"failNilOptions", args{key, nil}, nil, true},
|
||||
{"failEmptyOptions", args{key, nil}, nil, true},
|
||||
{"badBase64Template", args{key, []Option{WithTemplateBase64("foobar", TemplateData{})}}, nil, true},
|
||||
{"badFileTemplate", args{key, []Option{WithTemplateFile("./testdata/missing.tpl", TemplateData{})}}, nil, true},
|
||||
{"badJsonTemplate", args{key, []Option{WithTemplate(`{"type":{{ .Type }}}`, TemplateData{})}}, nil, true},
|
||||
{"failTemplate", args{key, []Option{WithTemplate(`{{ fail "an error" }}`, TemplateData{})}}, nil, true},
|
||||
{"failNilOptions", args{cr, nil}, nil, true},
|
||||
{"failEmptyOptions", args{cr, nil}, nil, true},
|
||||
{"badBase64Template", args{cr, []Option{WithTemplateBase64("foobar", TemplateData{})}}, nil, true},
|
||||
{"badFileTemplate", args{cr, []Option{WithTemplateFile("./testdata/missing.tpl", TemplateData{})}}, nil, true},
|
||||
{"badJsonTemplate", args{cr, []Option{WithTemplate(`{"type":{{ .Type }}}`, TemplateData{})}}, nil, true},
|
||||
{"failTemplate", args{cr, []Option{WithTemplate(`{{ fail "an error" }}`, TemplateData{})}}, nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewCertificate(tt.args.key, tt.args.opts...)
|
||||
got, err := NewCertificate(tt.args.cr, tt.args.opts...)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("NewCertificate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("NewCertificate() = %v, want %v", got, tt.want)
|
||||
t.Errorf("NewCertificate() = \n%+v, want \n%+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/smallstep/cli/config"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func getFuncMap(failMessage *string) template.FuncMap {
|
||||
|
@ -26,9 +25,9 @@ type Options struct {
|
|||
CertBuffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (o *Options) apply(key ssh.PublicKey, opts []Option) (*Options, error) {
|
||||
func (o *Options) apply(cr CertificateRequest, opts []Option) (*Options, error) {
|
||||
for _, fn := range opts {
|
||||
if err := fn(key, o); err != nil {
|
||||
if err := fn(cr, o); err != nil {
|
||||
return o, err
|
||||
}
|
||||
}
|
||||
|
@ -36,12 +35,12 @@ func (o *Options) apply(key ssh.PublicKey, opts []Option) (*Options, error) {
|
|||
}
|
||||
|
||||
// Option is the type used as a variadic argument in NewCertificate.
|
||||
type Option func(key ssh.PublicKey, o *Options) error
|
||||
type Option func(cr CertificateRequest, o *Options) error
|
||||
|
||||
// WithTemplate is an options that executes the given template text with the
|
||||
// given data.
|
||||
func WithTemplate(text string, data TemplateData) Option {
|
||||
return func(key ssh.PublicKey, o *Options) error {
|
||||
return func(cr CertificateRequest, o *Options) error {
|
||||
terr := new(TemplateError)
|
||||
funcMap := getFuncMap(&terr.Message)
|
||||
|
||||
|
@ -51,7 +50,7 @@ func WithTemplate(text string, data TemplateData) Option {
|
|||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
data.SetPublicKey(key)
|
||||
data.SetCertificateRequest(cr)
|
||||
if err := tmpl.Execute(buf, data); err != nil {
|
||||
if terr.Message != "" {
|
||||
return terr
|
||||
|
@ -66,26 +65,26 @@ func WithTemplate(text string, data TemplateData) Option {
|
|||
// WithTemplateBase64 is an options that executes the given template base64
|
||||
// string with the given data.
|
||||
func WithTemplateBase64(s string, data TemplateData) Option {
|
||||
return func(key ssh.PublicKey, o *Options) error {
|
||||
return func(cr CertificateRequest, o *Options) error {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error decoding template")
|
||||
}
|
||||
fn := WithTemplate(string(b), data)
|
||||
return fn(key, o)
|
||||
return fn(cr, o)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTemplateFile is an options that reads the template file and executes it
|
||||
// with the given data.
|
||||
func WithTemplateFile(path string, data TemplateData) Option {
|
||||
return func(key ssh.PublicKey, o *Options) error {
|
||||
return func(cr CertificateRequest, o *Options) error {
|
||||
filename := config.StepAbs(path)
|
||||
b, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "error reading %s", path)
|
||||
}
|
||||
fn := WithTemplate(string(b), data)
|
||||
return fn(key, o)
|
||||
return fn(cr, o)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func Test_getFuncMap_fail(t *testing.T) {
|
||||
|
@ -28,11 +27,14 @@ func Test_getFuncMap_fail(t *testing.T) {
|
|||
|
||||
func TestWithTemplate(t *testing.T) {
|
||||
key := mustGeneratePublicKey(t)
|
||||
cr := CertificateRequest{
|
||||
Key: key,
|
||||
}
|
||||
|
||||
type args struct {
|
||||
text string
|
||||
data TemplateData
|
||||
key ssh.PublicKey
|
||||
cr CertificateRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -45,7 +47,7 @@ func TestWithTemplate(t *testing.T) {
|
|||
KeyIDKey: "jane@doe.com",
|
||||
PrincipalsKey: []string{"jane", "jane@doe.com"},
|
||||
ExtensionsKey: DefaultExtensions(UserCert),
|
||||
}, key}, Options{
|
||||
}, cr}, Options{
|
||||
CertBuffer: bytes.NewBufferString(`{
|
||||
"type": "user",
|
||||
"keyId": "jane@doe.com",
|
||||
|
@ -56,24 +58,24 @@ func TestWithTemplate(t *testing.T) {
|
|||
TypeKey: "host",
|
||||
KeyIDKey: "foo",
|
||||
PrincipalsKey: []string{"foo.internal"},
|
||||
}, key}, Options{
|
||||
}, cr}, Options{
|
||||
CertBuffer: bytes.NewBufferString(`{
|
||||
"type": "host",
|
||||
"keyId": "foo",
|
||||
"principals": ["foo.internal"],
|
||||
"extensions": null
|
||||
}`)}, false},
|
||||
{"fail", args{`{{ fail "a message" }}`, TemplateData{}, key}, Options{}, true},
|
||||
{"failTemplate", args{`{{ fail "fatal error }}`, TemplateData{}, key}, Options{}, true},
|
||||
{"fail", args{`{{ fail "a message" }}`, TemplateData{}, cr}, Options{}, true},
|
||||
{"failTemplate", args{`{{ fail "fatal error }}`, TemplateData{}, cr}, Options{}, true},
|
||||
{"error", args{`{{ mustHas 3 .Data }}`, TemplateData{
|
||||
"Data": 3,
|
||||
}, key}, Options{}, true},
|
||||
}, cr}, Options{}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got Options
|
||||
fn := WithTemplate(tt.args.text, tt.args.data)
|
||||
if err := fn(tt.args.key, &got); (err != nil) != tt.wantErr {
|
||||
if err := fn(tt.args.cr, &got); (err != nil) != tt.wantErr {
|
||||
t.Errorf("WithTemplate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
|
@ -85,11 +87,14 @@ func TestWithTemplate(t *testing.T) {
|
|||
|
||||
func TestWithTemplateBase64(t *testing.T) {
|
||||
key := mustGeneratePublicKey(t)
|
||||
cr := CertificateRequest{
|
||||
Key: key,
|
||||
}
|
||||
|
||||
type args struct {
|
||||
s string
|
||||
data TemplateData
|
||||
key ssh.PublicKey
|
||||
cr CertificateRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -102,20 +107,20 @@ func TestWithTemplateBase64(t *testing.T) {
|
|||
KeyIDKey: "foo.internal",
|
||||
PrincipalsKey: []string{"foo.internal", "bar.internal"},
|
||||
ExtensionsKey: map[string]interface{}{"foo": "bar"},
|
||||
}, key}, Options{
|
||||
}, cr}, Options{
|
||||
CertBuffer: bytes.NewBufferString(`{
|
||||
"type": "host",
|
||||
"keyId": "foo.internal",
|
||||
"principals": ["foo.internal","bar.internal"],
|
||||
"extensions": {"foo":"bar"}
|
||||
}`)}, false},
|
||||
{"badBase64", args{"foobar", TemplateData{}, key}, Options{}, true},
|
||||
{"badBase64", args{"foobar", TemplateData{}, cr}, Options{}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got Options
|
||||
fn := WithTemplateBase64(tt.args.s, tt.args.data)
|
||||
if err := fn(tt.args.key, &got); (err != nil) != tt.wantErr {
|
||||
if err := fn(tt.args.cr, &got); (err != nil) != tt.wantErr {
|
||||
t.Errorf("WithTemplateBase64() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
|
@ -127,13 +132,16 @@ func TestWithTemplateBase64(t *testing.T) {
|
|||
|
||||
func TestWithTemplateFile(t *testing.T) {
|
||||
key := mustGeneratePublicKey(t)
|
||||
cr := CertificateRequest{
|
||||
Key: key,
|
||||
}
|
||||
|
||||
data := TemplateData{
|
||||
TypeKey: "user",
|
||||
KeyIDKey: "jane@doe.com",
|
||||
PrincipalsKey: []string{"jane", "jane@doe.com"},
|
||||
ExtensionsKey: DefaultExtensions(UserCert),
|
||||
InsecureKey: map[string]interface{}{
|
||||
InsecureKey: TemplateData{
|
||||
UserKey: map[string]interface{}{
|
||||
"username": "jane",
|
||||
},
|
||||
|
@ -143,7 +151,7 @@ func TestWithTemplateFile(t *testing.T) {
|
|||
type args struct {
|
||||
path string
|
||||
data TemplateData
|
||||
key ssh.PublicKey
|
||||
cr CertificateRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -151,7 +159,7 @@ func TestWithTemplateFile(t *testing.T) {
|
|||
want Options
|
||||
wantErr bool
|
||||
}{
|
||||
{"github.com", args{"./testdata/github.tpl", data, key}, Options{
|
||||
{"github.com", args{"./testdata/github.tpl", data, cr}, Options{
|
||||
CertBuffer: bytes.NewBufferString(`{
|
||||
"type": "user",
|
||||
"keyId": "jane@doe.com",
|
||||
|
@ -159,13 +167,13 @@ func TestWithTemplateFile(t *testing.T) {
|
|||
"extensions": {"login@github.com":"jane","permit-X11-forwarding":"","permit-agent-forwarding":"","permit-port-forwarding":"","permit-pty":"","permit-user-rc":""}
|
||||
}`),
|
||||
}, false},
|
||||
{"missing", args{"./testdata/missing.tpl", data, key}, Options{}, true},
|
||||
{"missing", args{"./testdata/missing.tpl", data, cr}, Options{}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got Options
|
||||
fn := WithTemplateFile(tt.args.path, tt.args.data)
|
||||
if err := fn(tt.args.key, &got); (err != nil) != tt.wantErr {
|
||||
if err := fn(tt.args.cr, &got); (err != nil) != tt.wantErr {
|
||||
t.Errorf("WithTemplateFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package sshutil
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
|
||||
const (
|
||||
TypeKey = "Type"
|
||||
KeyIDKey = "KeyID"
|
||||
|
@ -10,7 +8,7 @@ const (
|
|||
TokenKey = "Token"
|
||||
InsecureKey = "Insecure"
|
||||
UserKey = "User"
|
||||
PublicKey = "PublicKey"
|
||||
CertificateRequestKey = "CR"
|
||||
)
|
||||
|
||||
// TemplateError represents an error in a template produced by the fail
|
||||
|
@ -117,10 +115,10 @@ func (t TemplateData) SetUserData(v interface{}) {
|
|||
t.SetInsecure(UserKey, v)
|
||||
}
|
||||
|
||||
// SetUserData sets the given user provided object in the insecure template
|
||||
// data.
|
||||
func (t TemplateData) SetPublicKey(v ssh.PublicKey) {
|
||||
t.Set(PublicKey, v)
|
||||
// SetCertificateRequest sets the simulated ssh certificate request the insecure
|
||||
// template data.
|
||||
func (t TemplateData) SetCertificateRequest(cr CertificateRequest) {
|
||||
t.SetInsecure(CertificateRequestKey, cr)
|
||||
}
|
||||
|
||||
// DefaultCertificate is the default template for an SSH certificate.
|
||||
|
@ -130,3 +128,18 @@ const DefaultCertificate = `{
|
|||
"principals": {{ toJson .Principals }},
|
||||
"extensions": {{ toJson .Extensions }}
|
||||
}`
|
||||
|
||||
const DefaultIIDCertificate = `{
|
||||
"type": "{{ .Type }}",
|
||||
{{- if .Insecure.CR.KeyID }}
|
||||
"keyId": "{{ .Insecure.CR.KeyID }}",
|
||||
{{- else }}
|
||||
"keyId": "{{ .KeyID }}",
|
||||
{{- end}}
|
||||
{{- if .Insecure.CR.Principals }}
|
||||
"principals": {{ toJson .Insecure.CR.Principals }},
|
||||
{{- else }}
|
||||
"principals": {{ toJson .Principals }},
|
||||
{{- end }}
|
||||
"extensions": {{ toJson .Extensions }}
|
||||
}`
|
||||
|
|
|
@ -3,8 +3,6 @@ package sshutil
|
|||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestTemplateError_Error(t *testing.T) {
|
||||
|
@ -374,11 +372,11 @@ func TestTemplateData_SetUserData(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTemplateData_SetPublicKey(t *testing.T) {
|
||||
k1 := mustGeneratePublicKey(t)
|
||||
k2 := mustGeneratePublicKey(t)
|
||||
func TestTemplateData_SetCertificateRequest(t *testing.T) {
|
||||
cr1 := CertificateRequest{Key: mustGeneratePublicKey(t)}
|
||||
cr2 := CertificateRequest{Key: mustGeneratePublicKey(t)}
|
||||
type args struct {
|
||||
v ssh.PublicKey
|
||||
cr CertificateRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -386,20 +384,28 @@ func TestTemplateData_SetPublicKey(t *testing.T) {
|
|||
args args
|
||||
want TemplateData
|
||||
}{
|
||||
{"ok", TemplateData{}, args{k1}, TemplateData{
|
||||
PublicKey: k1,
|
||||
{"ok", TemplateData{}, args{cr1}, TemplateData{
|
||||
InsecureKey: TemplateData{
|
||||
CertificateRequestKey: cr1,
|
||||
},
|
||||
}},
|
||||
{"overwrite", TemplateData{
|
||||
PublicKey: k1,
|
||||
}, args{k2}, TemplateData{
|
||||
PublicKey: k2,
|
||||
InsecureKey: TemplateData{
|
||||
UserKey: "data",
|
||||
CertificateRequestKey: cr1,
|
||||
},
|
||||
}, args{cr2}, TemplateData{
|
||||
InsecureKey: TemplateData{
|
||||
UserKey: "data",
|
||||
CertificateRequestKey: cr2,
|
||||
},
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.t.SetPublicKey(tt.args.v)
|
||||
tt.t.SetCertificateRequest(tt.args.cr)
|
||||
if !reflect.DeepEqual(tt.t, tt.want) {
|
||||
t.Errorf("TemplateData.SetPublicKey() = %v, want %v", tt.t, tt.want)
|
||||
t.Errorf("TemplateData.SetCertificateRequest() = %v, want %v", tt.t, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue