Skip to content

Commit

Permalink
feat: adding persister impl for identifier search
Browse files Browse the repository at this point in the history
  • Loading branch information
Ajay Kelkar committed Feb 8, 2023
1 parent 5a78fd4 commit 969a909
Show file tree
Hide file tree
Showing 14 changed files with 77 additions and 33 deletions.
7 changes: 5 additions & 2 deletions identity/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ type (
PrivilegedPool interface {
Pool

// FindByCredentialsIdentifier returns an identity by querying for it's credential identifiers.
FindByCredentialsIdentifier(ctx context.Context, ct CredentialsType, match string) (*Identity, *Credentials, error)
// FindByCredentialsTypeAndIdentifier returns an identity by querying for it's credential identifiers.
FindByCredentialsIdentifier(ctx context.Context, match string) (*Identity, error)

// FindByCredentialsTypeAndIdentifier returns an identity by querying for it's credential type and identifiers.
FindByCredentialsTypeAndIdentifier(ctx context.Context, ct CredentialsType, match string) (*Identity, *Credentials, error)

// DeleteIdentity removes an identity by its id. Will return an error
// if identity exists, backend connectivity is broken, or trait validation fails.
Expand Down
18 changes: 9 additions & 9 deletions identity/test/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {
require.NoError(t, p.CreateIdentity(ctx, expected))
createdIDs = append(createdIDs, expected.ID)

actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, "[email protected]")
actual, creds, err := p.FindByCredentialsTypeAndIdentifier(ctx, identity.CredentialsTypePassword, "[email protected]")
require.NoError(t, err)

assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID)
Expand All @@ -614,7 +614,7 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {

t.Run("not if on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, "[email protected]")
_, _, err := p.FindByCredentialsTypeAndIdentifier(ctx, identity.CredentialsTypePassword, "[email protected]")
require.ErrorIs(t, err, sqlcon.ErrNoRows)
})
})
Expand Down Expand Up @@ -643,10 +643,10 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {
identity.CredentialsTypeLookup,
} {
t.Run(ct.String(), func(t *testing.T) {
_, _, err := p.FindByCredentialsIdentifier(ctx, ct, caseInsensitiveWithSpaces)
_, _, err := p.FindByCredentialsTypeAndIdentifier(ctx, ct, caseInsensitiveWithSpaces)
require.Error(t, err)

actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, caseSensitive)
actual, creds, err := p.FindByCredentialsTypeAndIdentifier(ctx, ct, caseSensitive)
require.NoError(t, err)
assertx.EqualAsJSONExcept(t, expected.Credentials[ct], creds, []string{"created_at", "updated_at", "id"})
assertx.EqualAsJSONExcept(t, expected, actual, []string{"created_at", "state_changed_at", "updated_at", "id"})
Expand All @@ -661,7 +661,7 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {
} {
t.Run(ct.String(), func(t *testing.T) {
for _, cs := range []string{caseSensitive, caseInsensitiveWithSpaces} {
actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, cs)
actual, creds, err := p.FindByCredentialsTypeAndIdentifier(ctx, ct, cs)
require.NoError(t, err)
ec := expected.Credentials[ct]
ec.Identifiers = []string{strings.ToLower(caseSensitive)}
Expand All @@ -681,7 +681,7 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {
require.NoError(t, p.CreateIdentity(ctx, expected))
createdIDs = append(createdIDs, expected.ID)

actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier)
actual, creds, err := p.FindByCredentialsTypeAndIdentifier(ctx, identity.CredentialsTypePassword, identifier)
require.NoError(t, err)

assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID)
Expand All @@ -693,7 +693,7 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {

t.Run("not if on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier)
_, _, err := p.FindByCredentialsTypeAndIdentifier(ctx, identity.CredentialsTypePassword, identifier)
require.ErrorIs(t, err, sqlcon.ErrNoRows)
})
})
Expand Down Expand Up @@ -1009,12 +1009,12 @@ func TestPool(ctx context.Context, conf *config.Config, p interface {
_, err = p.GetIdentityConfidential(ctx, nid1)
require.ErrorIs(t, err, sqlcon.ErrNoRows)

i, c, err := p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid1")
i, c, err := p.FindByCredentialsTypeAndIdentifier(ctx, m[0].Name, "nid1")
require.NoError(t, err)
assert.Equal(t, "nid1", c.Identifiers[0])
require.Len(t, i.Credentials, 0)

_, _, err = p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid2")
_, _, err = p.FindByCredentialsTypeAndIdentifier(ctx, m[0].Name, "nid2")
require.ErrorIs(t, err, sqlcon.ErrNoRows)

i, err = p.GetIdentityConfidential(ctx, iid)
Expand Down
45 changes: 43 additions & 2 deletions persistence/sql/persister_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"database/sql"
"fmt"
"github.com/gobuffalo/pop"
"strings"
"time"

Expand All @@ -24,7 +25,6 @@ import (
"github.com/ory/kratos/otp"
"github.com/ory/kratos/x"

"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/pkg/errors"

Expand Down Expand Up @@ -83,7 +83,7 @@ func (p *Persister) normalizeIdentifier(ct identity.CredentialsType, match strin
return match
}

func (p *Persister) FindByCredentialsIdentifier(ctx context.Context, ct identity.CredentialsType, match string) (*identity.Identity, *identity.Credentials, error) {
func (p *Persister) FindByCredentialsIdentifier(ctx context.Context, match string) (*identity.Identity, error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindByCredentialsIdentifier")
defer span.End()

Expand All @@ -93,6 +93,47 @@ func (p *Persister) FindByCredentialsIdentifier(ctx context.Context, ct identity
IdentityID uuid.UUID `db:"identity_id"`
}

// #nosec G201
if err := p.GetConnection(ctx).RawQuery(fmt.Sprintf(`SELECT
ic.identity_id
FROM identity_credentials ic
INNER JOIN identity_credential_identifiers ici on ic.id = ici.identity_credential_id
WHERE ici.identifier = ?
AND ic.nid = ?
AND ici.nid = ?`,
"identity_credentials",
"identity_credential_types",
"identity_credential_identifiers",
),
match,
nid,
nid,
).First(&find); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, sqlcon.HandleError(err) // herodot.ErrNotFound.WithTrace(err).WithReasonf(`No identity matching credentials identifier "%s" could be found.`, match)
}

return nil, sqlcon.HandleError(err)
}

i, err := p.GetIdentityConfidential(ctx, find.IdentityID)
if err != nil {
return nil, err
}

return i.CopyWithoutCredentials(), nil
}

func (p *Persister) FindByCredentialsTypeAndIdentifier(ctx context.Context, ct identity.CredentialsType, match string) (*identity.Identity, *identity.Credentials, error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindByCredentialsTypeAndIdentifier")
defer span.End()

nid := p.NetworkID(ctx)

var find struct {
IdentityID uuid.UUID `db:"identity_id"`
}

// Force case-insensitivity and trimming for identifiers
match = p.normalizeIdentifier(ct, match)

Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/lookup/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
return nil, s.handleLoginError(r, f, err)
}

i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), identityID.String())
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), s.ID(), identityID.String())
if errors.Is(err, sqlcon.ErrNoRows) {
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoLookupDefined()))
} else if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/lookup/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func (s *Strategy) continueSettingsFlowReveal(w http.ResponseWriter, r *http.Req
return errors.WithStack(herodot.ErrBadRequest.WithReasonf("Can not reveal lookup codes because you have none."))
}

_, cred, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), ctxUpdate.Session.IdentityID.String())
_, cred, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), s.ID(), ctxUpdate.Session.IdentityID.String())
if err != nil {
return err
}
Expand Down
10 changes: 5 additions & 5 deletions selfservice/strategy/lookup/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func TestCompleteSettings(t *testing.T) {
id, codes := createIdentity(t, reg)

checkIdentity := func(t *testing.T) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
require.NoError(t, err)
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
}
Expand Down Expand Up @@ -280,7 +280,7 @@ func TestCompleteSettings(t *testing.T) {
const reason = "You must (re-)generate recovery backup codes before you can save them."

checkIdentity := func(t *testing.T) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
require.NoError(t, err)
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
}
Expand Down Expand Up @@ -315,7 +315,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
require.NoError(t, err)
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
}
Expand Down Expand Up @@ -377,7 +377,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T, id *identity.Identity, f *kratos.SettingsFlow) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
require.NoError(t, err)
assert.NotContains(t, gjson.GetBytes(cred.Config, "recovery_codes").Raw, "key-1")
assert.NotContains(t, gjson.GetBytes(cred.Config, "recovery_codes").Raw, "key-0")
Expand Down Expand Up @@ -470,7 +470,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T, id *identity.Identity, f *kratos.SettingsFlow) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows)

actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id))
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/strategy_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ type UpdateLoginFlowWithOidcMethod struct {
}

func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login.Flow, token *oauth2.Token, claims *Claims, provider Provider, container *authCodeContainer) (*registration.Flow, error) {
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, identity.OIDCUniqueID(provider.Config().ID, claims.Subject))
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), identity.CredentialsTypeOIDC, identity.OIDCUniqueID(provider.Config().ID, claims.Subject))
if err != nil {
if errors.Is(err, sqlcon.ErrNoRows) {
// If no account was found we're "manually" creating a new registration flow and redirecting the browser
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/strategy_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat
}

func (s *Strategy) processRegistration(w http.ResponseWriter, r *http.Request, a *registration.Flow, token *oauth2.Token, claims *Claims, provider Provider, container *authCodeContainer) (*login.Flow, error) {
if _, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, identity.OIDCUniqueID(provider.Config().ID, claims.Subject)); err == nil {
if _, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), identity.CredentialsTypeOIDC, identity.OIDCUniqueID(provider.Config().ID, claims.Subject)); err == nil {
// If the identity already exists, we should perform the login flow instead.

// That will execute the "pre registration" hook which allows to e.g. disallow this flow. The registration
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/password/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
return nil, s.handleLoginError(w, r, f, &p, err)
}

i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), stringsx.Coalesce(p.Identifier, p.LegacyIdentifier))
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), s.ID(), stringsx.Coalesce(p.Identifier, p.LegacyIdentifier))
if err != nil {
time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(r.Context()).ExpectedDuration, s.d.Config().HasherArgon2(r.Context()).ExpectedDeviation))
return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError()))
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/password/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ func TestCompleteLogin(t *testing.T) {
assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body)

// check if password hash algorithm is upgraded
_, c, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypePassword, identifier)
_, c, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypePassword, identifier)
require.NoError(t, err)
var o identity.CredentialsPassword
require.NoError(t, json.NewDecoder(bytes.NewBuffer(c.Config)).Decode(&o))
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/totp/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
return nil, s.handleLoginError(r, f, err)
}

i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), identityID.String())
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), s.ID(), identityID.String())
if err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoTOTPDeviceRegistered()))
}
Expand Down
10 changes: 5 additions & 5 deletions selfservice/strategy/totp/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
require.NoError(t, err)
assert.Equal(t, key.URL(), gjson.GetBytes(cred.Config, "totp_url").String())
}
Expand Down Expand Up @@ -195,7 +195,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows)
}

Expand Down Expand Up @@ -230,7 +230,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T, id *identity.Identity) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows)
}

Expand Down Expand Up @@ -268,7 +268,7 @@ func TestCompleteSettings(t *testing.T) {
}

checkIdentity := func(t *testing.T, id *identity.Identity) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows)
}

Expand Down Expand Up @@ -302,7 +302,7 @@ func TestCompleteSettings(t *testing.T) {

t.Run("type=set up TOTP device", func(t *testing.T) {
checkIdentity := func(t *testing.T, id *identity.Identity, key string) {
i, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
i, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeTOTP, id.ID.String())
require.NoError(t, err)
var c identity.CredentialsTOTPConfig
require.NoError(t, json.Unmarshal(cred.Config, &c))
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/webauthn/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ func (s *Strategy) loginPasswordless(w http.ResponseWriter, r *http.Request, f *
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrBadRequest.WithReason("identifier is required")))
}

i, _, err = s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), p.Identifier)
i, _, err = s.d.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(r.Context(), s.ID(), p.Identifier)
if err != nil {
time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(r.Context()).ExpectedDuration, s.d.Config().HasherArgon2(r.Context()).ExpectedDeviation))
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnCredentials()))
Expand Down
4 changes: 2 additions & 2 deletions selfservice/strategy/webauthn/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func TestRegistration(t *testing.T) {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}

i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email)
i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
Expand All @@ -319,7 +319,7 @@ func TestRegistration(t *testing.T) {
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)

i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email)
i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsTypeAndIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
Expand Down

0 comments on commit 969a909

Please sign in to comment.