mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-03 16:59:22 +02:00
authorize: add authorization (#59)
* authorize: authorization module adds support for per-route access policy. In this release we support the most common forms of identity based access policy: `allowed_users`, `allowed_groups`, and `allowed_domains`. In future versions, the authorization module will also support context and device based authorization policy and decisions. See website documentation for more details. * docs: updated `env.example` to include a `POLICY` setting example. * docs: added `IDP_SERVICE_ACCOUNT` to `env.example` . * docs: removed `PROXY_ROOT_DOMAIN` settings which has been replaced by `POLICY`. * all: removed `ALLOWED_DOMAINS` settings which has been replaced by `POLICY`. Authorization is now handled by the authorization service and is defined in the policy configuration files. * proxy: `ROUTES` settings which has been replaced by `POLICY`. * internal/log: `http.Server` and `httputil.NewSingleHostReverseProxy` now uses pomerium's logging package instead of the standard library's built in one. Closes #54 Closes #41 Closes #61 Closes #58
This commit is contained in:
parent
1187be2bf3
commit
c13459bb88
65 changed files with 1683 additions and 879 deletions
|
@ -31,11 +31,7 @@ type Options struct {
|
|||
SharedKey string `envconfig:"SHARED_SECRET"`
|
||||
|
||||
// RedirectURL specifies the callback url following third party authentication
|
||||
RedirectURL *url.URL `envconfig:"REDIRECT_URL"`
|
||||
|
||||
// Coarse authorization based on user email domain
|
||||
// todo(bdd) : to be replaced with authorization module
|
||||
AllowedDomains []string `envconfig:"ALLOWED_DOMAINS"`
|
||||
RedirectURL *url.URL `envconfig:"REDIRECT_URL"`
|
||||
ProxyRootDomains []string `envconfig:"PROXY_ROOT_DOMAIN"`
|
||||
|
||||
// Session/Cookie management
|
||||
|
@ -84,9 +80,6 @@ func (o *Options) Validate() error {
|
|||
if o.ClientSecret == "" {
|
||||
return errors.New("missing setting: client secret")
|
||||
}
|
||||
if len(o.AllowedDomains) == 0 {
|
||||
return errors.New("missing setting email domain")
|
||||
}
|
||||
if len(o.ProxyRootDomains) == 0 {
|
||||
return errors.New("missing setting: proxy root domain")
|
||||
}
|
||||
|
@ -105,14 +98,10 @@ func (o *Options) Validate() error {
|
|||
|
||||
// Authenticate validates a user's identity
|
||||
type Authenticate struct {
|
||||
SharedKey string
|
||||
|
||||
SharedKey string
|
||||
RedirectURL *url.URL
|
||||
AllowedDomains []string
|
||||
ProxyRootDomains []string
|
||||
|
||||
Validator func(string) bool
|
||||
|
||||
templates *template.Template
|
||||
csrfStore sessions.CSRFStore
|
||||
sessionStore sessions.SessionStore
|
||||
|
@ -122,9 +111,9 @@ type Authenticate struct {
|
|||
}
|
||||
|
||||
// New validates and creates a new authenticate service from a set of Options
|
||||
func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate, error) {
|
||||
func New(opts *Options) (*Authenticate, error) {
|
||||
if opts == nil {
|
||||
return nil, errors.New("options cannot be nil")
|
||||
return nil, errors.New("authenticate: options cannot be nil")
|
||||
}
|
||||
if err := opts.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
@ -166,7 +155,6 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate
|
|||
p := &Authenticate{
|
||||
SharedKey: opts.SharedKey,
|
||||
RedirectURL: opts.RedirectURL,
|
||||
AllowedDomains: opts.AllowedDomains,
|
||||
ProxyRootDomains: dotPrependDomains(opts.ProxyRootDomains),
|
||||
|
||||
templates: templates.New(),
|
||||
|
@ -176,14 +164,6 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate
|
|||
provider: provider,
|
||||
}
|
||||
|
||||
// validation via dependency injected function
|
||||
for _, optFunc := range optionFuncs {
|
||||
err := optFunc(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ func testOptions() *Options {
|
|||
redirectURL, _ := url.Parse("https://example.com/oauth2/callback")
|
||||
return &Options{
|
||||
ProxyRootDomains: []string{"example.com"},
|
||||
AllowedDomains: []string{"example.com"},
|
||||
RedirectURL: redirectURL,
|
||||
SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=",
|
||||
ClientID: "test-client-id",
|
||||
|
@ -35,8 +34,6 @@ func TestOptions_Validate(t *testing.T) {
|
|||
emptyClientID.ClientID = ""
|
||||
emptyClientSecret := testOptions()
|
||||
emptyClientSecret.ClientSecret = ""
|
||||
allowedDomains := testOptions()
|
||||
allowedDomains.AllowedDomains = nil
|
||||
proxyRootDomains := testOptions()
|
||||
proxyRootDomains.ProxyRootDomains = nil
|
||||
emptyCookieSecret := testOptions()
|
||||
|
@ -63,7 +60,6 @@ func TestOptions_Validate(t *testing.T) {
|
|||
{"no shared secret", badSharedKey, true},
|
||||
{"no client id", emptyClientID, true},
|
||||
{"no client secret", emptyClientSecret, true},
|
||||
{"empty allowed domains", allowedDomains, true},
|
||||
{"empty root domains", proxyRootDomains, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
//go:generate protoc -I ../proto/authenticate --go_out=plugins=grpc:../proto/authenticate ../proto/authenticate/authenticate.proto
|
||||
|
||||
package authenticate // import "github.com/pomerium/pomerium/authenticate"
|
||||
import (
|
||||
"context"
|
||||
|
@ -20,7 +22,7 @@ func (p *Authenticate) Authenticate(ctx context.Context, in *pb.AuthenticateRequ
|
|||
return newSessionProto, nil
|
||||
}
|
||||
|
||||
// Validate locally validates a JWT id token; does NOT do nonce or revokation validation.
|
||||
// Validate locally validates a JWT id_token; does NOT do nonce or revokation validation.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
func (p *Authenticate) Validate(ctx context.Context, in *pb.ValidateRequest) (*pb.ValidateReply, error) {
|
||||
isValid, err := p.provider.Validate(ctx, in.IdToken)
|
||||
|
|
|
@ -78,9 +78,6 @@ func TestAuthenticate_Refresh(t *testing.T) {
|
|||
false},
|
||||
{"test error", &identity.MockProvider{RefreshError: errors.New("hi")}, &pb.Session{RefreshToken: "refresh token", RefreshDeadline: fixedProtoTime, LifetimeDeadline: fixedProtoTime}, nil, true},
|
||||
{"test catch nil", nil, nil, nil, true},
|
||||
|
||||
// {"test error", "error", nil, true},
|
||||
// {"test bad time", "bad time", nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
@ -81,7 +81,6 @@ func (a *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// check if session refresh period is up
|
||||
if session.RefreshPeriodExpired() {
|
||||
newSession, err := a.provider.Refresh(r.Context(), session)
|
||||
if err != nil {
|
||||
|
@ -111,12 +110,6 @@ func (a *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se
|
|||
}
|
||||
}
|
||||
|
||||
// authenticate really should not be in the business of authorization
|
||||
// todo(bdd) : remove when authorization module added
|
||||
if !a.Validator(session.Email) {
|
||||
log.FromRequest(r).Error().Msg("invalid email user")
|
||||
return nil, httputil.ErrUserNotAuthorized
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
@ -238,12 +231,7 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) {
|
|||
// OAuthStart starts the authenticate process by redirecting to the identity provider.
|
||||
// https://tools.ietf.org/html/rfc6749#section-4.2.1
|
||||
func (a *Authenticate) OAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
authRedirectURL, err := url.Parse(r.URL.Query().Get("redirect_uri"))
|
||||
if err != nil {
|
||||
httputil.ErrorResponse(w, r, "Invalid redirect parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authRedirectURL = a.RedirectURL.ResolveReference(r.URL)
|
||||
authRedirectURL := a.RedirectURL.ResolveReference(r.URL)
|
||||
|
||||
nonce := fmt.Sprintf("%x", cryptutil.GenerateKey())
|
||||
a.csrfStore.SetCSRF(w, r, nonce)
|
||||
|
@ -345,11 +333,6 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request)
|
|||
return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "Invalid Redirect URI"}
|
||||
}
|
||||
|
||||
// Set cookie, or deny: validates the session email and group
|
||||
if !a.Validator(session.Email) {
|
||||
log.FromRequest(r).Error().Err(err).Str("email", session.Email).Msg("invalid email permissions denied")
|
||||
return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "You don't have access"}
|
||||
}
|
||||
err = a.sessionStore.SaveSession(w, r, session)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("internal error")
|
||||
|
|
|
@ -17,15 +17,10 @@ import (
|
|||
"github.com/pomerium/pomerium/internal/templates"
|
||||
)
|
||||
|
||||
// mocks for validator func
|
||||
func trueValidator(s string) bool { return true }
|
||||
func falseValidator(s string) bool { return false }
|
||||
|
||||
func testAuthenticate() *Authenticate {
|
||||
var auth Authenticate
|
||||
auth.RedirectURL, _ = url.Parse("https://auth.example.com/oauth/callback")
|
||||
auth.SharedKey = "IzY7MOZwzfOkmELXgozHDKTxoT3nOYhwkcmUVINsRww="
|
||||
auth.AllowedDomains = []string{"*"}
|
||||
auth.ProxyRootDomains = []string{"example.com"}
|
||||
auth.templates = templates.New()
|
||||
return &auth
|
||||
|
@ -86,66 +81,25 @@ func TestAuthenticate_authenticate(t *testing.T) {
|
|||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
validator func(string) bool
|
||||
want *sessions.SessionState
|
||||
wantErr bool
|
||||
name string
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
want *sessions.SessionState
|
||||
wantErr bool
|
||||
}{
|
||||
{"good", goodSession, identity.MockProvider{ValidateResponse: true}, trueValidator, nil, false},
|
||||
{"good but fails validation", goodSession, identity.MockProvider{ValidateResponse: true}, falseValidator, nil, true},
|
||||
{"can't load session", &sessions.MockSessionStore{LoadError: errors.New("error")}, identity.MockProvider{ValidateResponse: true}, trueValidator, nil, true},
|
||||
{"validation fails", goodSession, identity.MockProvider{ValidateResponse: false}, trueValidator, nil, true},
|
||||
{"session fails after good validation", &sessions.MockSessionStore{
|
||||
SaveError: errors.New("error"),
|
||||
Session: &sessions.SessionState{
|
||||
AccessToken: "AccessToken",
|
||||
RefreshToken: "RefreshToken",
|
||||
RefreshDeadline: time.Now().Add(10 * time.Second),
|
||||
}}, identity.MockProvider{ValidateResponse: true},
|
||||
trueValidator, nil, true},
|
||||
{"refresh expired",
|
||||
expiredRefresPeriod,
|
||||
identity.MockProvider{
|
||||
ValidateResponse: true,
|
||||
RefreshResponse: &sessions.SessionState{
|
||||
AccessToken: "new token",
|
||||
LifetimeDeadline: time.Now(),
|
||||
},
|
||||
},
|
||||
trueValidator, nil, false},
|
||||
{"refresh expired refresh error",
|
||||
expiredRefresPeriod,
|
||||
identity.MockProvider{
|
||||
ValidateResponse: true,
|
||||
RefreshError: errors.New("error"),
|
||||
},
|
||||
trueValidator, nil, true},
|
||||
{"refresh expired failed save",
|
||||
&sessions.MockSessionStore{
|
||||
SaveError: errors.New("error"),
|
||||
Session: &sessions.SessionState{
|
||||
AccessToken: "AccessToken",
|
||||
RefreshToken: "RefreshToken",
|
||||
|
||||
RefreshDeadline: time.Now().Add(10 * -time.Second),
|
||||
}},
|
||||
identity.MockProvider{
|
||||
ValidateResponse: true,
|
||||
RefreshResponse: &sessions.SessionState{
|
||||
AccessToken: "new token",
|
||||
LifetimeDeadline: time.Now(),
|
||||
},
|
||||
},
|
||||
trueValidator, nil, true},
|
||||
{"good", goodSession, identity.MockProvider{ValidateResponse: true}, nil, false},
|
||||
{"can't load session", &sessions.MockSessionStore{LoadError: errors.New("error")}, identity.MockProvider{ValidateResponse: true}, nil, true},
|
||||
{"validation fails", goodSession, identity.MockProvider{ValidateResponse: false}, nil, true},
|
||||
{"session fails after good validation", &sessions.MockSessionStore{SaveError: errors.New("error"), Session: &sessions.SessionState{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * time.Second)}}, identity.MockProvider{ValidateResponse: true}, nil, true},
|
||||
{"refresh expired", expiredRefresPeriod, identity.MockProvider{ValidateResponse: true, RefreshResponse: &sessions.SessionState{AccessToken: "new token", LifetimeDeadline: time.Now()}}, nil, false},
|
||||
{"refresh expired refresh error", expiredRefresPeriod, identity.MockProvider{ValidateResponse: true, RefreshError: errors.New("error")}, nil, true},
|
||||
{"refresh expired failed save", &sessions.MockSessionStore{SaveError: errors.New("error"), Session: &sessions.SessionState{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * -time.Second)}}, identity.MockProvider{ValidateResponse: true, RefreshResponse: &sessions.SessionState{AccessToken: "new token", LifetimeDeadline: time.Now()}}, nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Authenticate{
|
||||
sessionStore: tt.session,
|
||||
provider: tt.provider,
|
||||
Validator: tt.validator,
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/auth", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
@ -161,11 +115,10 @@ func TestAuthenticate_authenticate(t *testing.T) {
|
|||
|
||||
func TestAuthenticate_SignIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
validator func(string) bool
|
||||
wantCode int
|
||||
name string
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
wantCode int
|
||||
}{
|
||||
{"good",
|
||||
&sessions.MockSessionStore{
|
||||
|
@ -175,7 +128,7 @@ func TestAuthenticate_SignIn(t *testing.T) {
|
|||
RefreshDeadline: time.Now().Add(10 * time.Second),
|
||||
}},
|
||||
identity.MockProvider{ValidateResponse: true},
|
||||
trueValidator,
|
||||
|
||||
http.StatusForbidden},
|
||||
{"session fails after good validation", &sessions.MockSessionStore{
|
||||
SaveError: errors.New("error"),
|
||||
|
@ -184,14 +137,13 @@ func TestAuthenticate_SignIn(t *testing.T) {
|
|||
RefreshToken: "RefreshToken",
|
||||
RefreshDeadline: time.Now().Add(10 * time.Second),
|
||||
}}, identity.MockProvider{ValidateResponse: true},
|
||||
trueValidator, http.StatusBadRequest},
|
||||
http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Authenticate{
|
||||
sessionStore: tt.session,
|
||||
provider: tt.provider,
|
||||
Validator: tt.validator,
|
||||
RedirectURL: uriParse("http://www.pomerium.io"),
|
||||
csrfStore: &sessions.MockCSRFStore{},
|
||||
SharedKey: "secret",
|
||||
|
@ -592,11 +544,9 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
code string
|
||||
state string
|
||||
validDomains []string
|
||||
validator func(string) bool
|
||||
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
csrfStore sessions.MockCSRFStore
|
||||
session sessions.SessionStore
|
||||
provider identity.MockProvider
|
||||
csrfStore sessions.MockCSRFStore
|
||||
|
||||
want string
|
||||
wantErr bool
|
||||
|
@ -607,7 +557,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -629,7 +579,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -652,7 +602,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -674,7 +624,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateError: errors.New("error"),
|
||||
|
@ -691,30 +641,8 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
&sessions.MockSessionStore{SaveError: errors.New("error")},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
AccessToken: "AccessToken",
|
||||
RefreshToken: "RefreshToken",
|
||||
Email: "blah@blah.com",
|
||||
|
||||
RefreshDeadline: time.Now().Add(10 * time.Second),
|
||||
}},
|
||||
sessions.MockCSRFStore{
|
||||
ResponseCSRF: "csrf",
|
||||
Cookie: &http.Cookie{Value: "nonce"}},
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{"failed email validation",
|
||||
http.MethodGet,
|
||||
"",
|
||||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
falseValidator,
|
||||
&sessions.MockSessionStore{},
|
||||
&sessions.MockSessionStore{SaveError: errors.New("error")},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
AccessToken: "AccessToken",
|
||||
|
@ -736,7 +664,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -758,7 +686,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -780,7 +708,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
"nonce:https://corp.pomerium.io",
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -802,7 +730,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -824,7 +752,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
"code",
|
||||
base64.URLEncoding.EncodeToString([]byte("nonce:corp.pomerium.io")),
|
||||
[]string{"pomerium.io"},
|
||||
trueValidator,
|
||||
|
||||
&sessions.MockSessionStore{},
|
||||
identity.MockProvider{
|
||||
AuthenticateResponse: sessions.SessionState{
|
||||
|
@ -848,7 +776,6 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) {
|
|||
csrfStore: tt.csrfStore,
|
||||
provider: tt.provider,
|
||||
ProxyRootDomains: tt.validDomains,
|
||||
Validator: tt.validator,
|
||||
}
|
||||
u, _ := url.Parse("/oauthGet")
|
||||
params, _ := url.ParseQuery(u.RawQuery)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue