mirror of https://github.com/hashicorp/boundary
feat(credential/static): Implement CRUDL support for password credentials (#6163)
parent
9f2bcfa4b7
commit
205cb64d8b
@ -0,0 +1,141 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/boundary/internal/credential"
|
||||
"github.com/hashicorp/boundary/internal/credential/static/store"
|
||||
"github.com/hashicorp/boundary/internal/db/timestamp"
|
||||
"github.com/hashicorp/boundary/internal/errors"
|
||||
"github.com/hashicorp/boundary/internal/libs/crypto"
|
||||
"github.com/hashicorp/boundary/internal/oplog"
|
||||
"github.com/hashicorp/boundary/internal/types/resource"
|
||||
wrapping "github.com/hashicorp/go-kms-wrapping/v2"
|
||||
"github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ credential.Static = (*PasswordCredential)(nil)
|
||||
|
||||
// PasswordCredential contains the credential with a password.
|
||||
// It is owned by a credential store.
|
||||
type PasswordCredential struct {
|
||||
*store.PasswordCredential
|
||||
tableName string `gorm:"-"`
|
||||
}
|
||||
|
||||
// NewPasswordCredential creates a new in memory static Credential containing a
|
||||
// password that is assigned to storeId. Name and description are the only
|
||||
// valid options. All other options are ignored.
|
||||
func NewPasswordCredential(
|
||||
storeId string,
|
||||
password credential.Password,
|
||||
opt ...Option,
|
||||
) (*PasswordCredential, error) {
|
||||
opts := getOpts(opt...)
|
||||
l := &PasswordCredential{
|
||||
PasswordCredential: &store.PasswordCredential{
|
||||
StoreId: storeId,
|
||||
Name: opts.withName,
|
||||
Description: opts.withDescription,
|
||||
Password: []byte(password),
|
||||
},
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func allocPasswordCredential() *PasswordCredential {
|
||||
return &PasswordCredential{
|
||||
PasswordCredential: &store.PasswordCredential{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PasswordCredential) clone() *PasswordCredential {
|
||||
cp := proto.Clone(c.PasswordCredential)
|
||||
return &PasswordCredential{
|
||||
PasswordCredential: cp.(*store.PasswordCredential),
|
||||
}
|
||||
}
|
||||
|
||||
// TableName returns the table name.
|
||||
func (c *PasswordCredential) TableName() string {
|
||||
if c.tableName != "" {
|
||||
return c.tableName
|
||||
}
|
||||
return "credential_static_password_credential"
|
||||
}
|
||||
|
||||
// SetTableName sets the table name.
|
||||
func (c *PasswordCredential) SetTableName(n string) {
|
||||
c.tableName = n
|
||||
}
|
||||
|
||||
// GetResourceType returns the resource type of the Credential
|
||||
func (c *PasswordCredential) GetResourceType() resource.Type {
|
||||
return resource.Credential
|
||||
}
|
||||
|
||||
func (c *PasswordCredential) oplog(op oplog.OpType) oplog.Metadata {
|
||||
metadata := oplog.Metadata{
|
||||
"resource-public-id": []string{c.PublicId},
|
||||
"resource-type": []string{"credential-static-password"},
|
||||
"op-type": []string{op.String()},
|
||||
}
|
||||
if c.StoreId != "" {
|
||||
metadata["store-id"] = []string{c.StoreId}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (c *PasswordCredential) encrypt(ctx context.Context, cipher wrapping.Wrapper) error {
|
||||
const op = "static.(PasswordCredential).encrypt"
|
||||
if len(c.Password) == 0 {
|
||||
return errors.New(ctx, errors.InvalidParameter, op, "no password defined")
|
||||
}
|
||||
if err := structwrapping.WrapStruct(ctx, cipher, c.PasswordCredential, nil); err != nil {
|
||||
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt))
|
||||
}
|
||||
keyId, err := cipher.KeyId(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("error reading cipher key id"))
|
||||
}
|
||||
c.KeyId = keyId
|
||||
if err := c.hmacPassword(ctx, cipher); err != nil {
|
||||
return errors.Wrap(ctx, err, op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PasswordCredential) decrypt(ctx context.Context, cipher wrapping.Wrapper) error {
|
||||
const op = "static.(PasswordCredential).decrypt"
|
||||
if err := structwrapping.UnwrapStruct(ctx, cipher, c.PasswordCredential, nil); err != nil {
|
||||
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PasswordCredential) hmacPassword(ctx context.Context, cipher wrapping.Wrapper) error {
|
||||
const op = "static.(PasswordCredential).hmacPassword"
|
||||
if cipher == nil {
|
||||
return errors.New(ctx, errors.InvalidParameter, op, "missing cipher")
|
||||
}
|
||||
hm, err := crypto.HmacSha256(ctx, c.Password, cipher, []byte(c.StoreId), nil, crypto.WithEd25519())
|
||||
if err != nil {
|
||||
return errors.Wrap(ctx, err, op)
|
||||
}
|
||||
c.PasswordHmac = []byte(hm)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deletedPasswordCredential struct {
|
||||
PublicId string `gorm:"primary_key"`
|
||||
DeleteTime *timestamp.Timestamp
|
||||
}
|
||||
|
||||
// TableName returns the tablename to override the default gorm table name
|
||||
func (s *deletedPasswordCredential) TableName() string {
|
||||
return "credential_static_password_credential_deleted"
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/boundary/internal/credential"
|
||||
"github.com/hashicorp/boundary/internal/credential/static/store"
|
||||
"github.com/hashicorp/boundary/internal/db"
|
||||
"github.com/hashicorp/boundary/internal/iam"
|
||||
"github.com/hashicorp/boundary/internal/kms"
|
||||
"github.com/hashicorp/boundary/internal/libs/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
)
|
||||
|
||||
func TestPasswordCredential_New(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn, _ := db.TestSetup(t, "postgres")
|
||||
wrapper := db.TestWrapper(t)
|
||||
kkms := kms.TestKms(t, conn, wrapper)
|
||||
rw := db.New(conn)
|
||||
|
||||
_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
|
||||
cs := TestCredentialStore(t, conn, wrapper, prj.PublicId)
|
||||
|
||||
type args struct {
|
||||
password credential.Password
|
||||
storeId string
|
||||
options []Option
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *PasswordCredential
|
||||
wantCreateErr bool
|
||||
wantEncryptErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing-password",
|
||||
args: args{
|
||||
storeId: cs.PublicId,
|
||||
},
|
||||
want: allocPasswordCredential(),
|
||||
wantEncryptErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing-store-id",
|
||||
args: args{
|
||||
password: "test-pass",
|
||||
},
|
||||
want: allocPasswordCredential(),
|
||||
wantCreateErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid-no-options",
|
||||
args: args{
|
||||
password: "test-pass",
|
||||
storeId: cs.PublicId,
|
||||
},
|
||||
want: &PasswordCredential{
|
||||
PasswordCredential: &store.PasswordCredential{
|
||||
Password: []byte("test-pass"),
|
||||
StoreId: cs.PublicId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-with-name",
|
||||
args: args{
|
||||
password: "test-pass",
|
||||
storeId: cs.PublicId,
|
||||
options: []Option{WithName("my-credential")},
|
||||
},
|
||||
want: &PasswordCredential{
|
||||
PasswordCredential: &store.PasswordCredential{
|
||||
Password: []byte("test-pass"),
|
||||
StoreId: cs.PublicId,
|
||||
Name: "my-credential",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-with-description",
|
||||
args: args{
|
||||
password: "test-pass",
|
||||
storeId: cs.PublicId,
|
||||
options: []Option{WithDescription("my-credential-description")},
|
||||
},
|
||||
want: &PasswordCredential{
|
||||
PasswordCredential: &store.PasswordCredential{
|
||||
Password: []byte("test-pass"),
|
||||
StoreId: cs.PublicId,
|
||||
Description: "my-credential-description",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert, require := assert.New(t), require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := NewPasswordCredential(tt.args.storeId, tt.args.password, tt.args.options...)
|
||||
|
||||
require.NoError(err)
|
||||
require.NotNil(got)
|
||||
assert.Emptyf(got.PublicId, "PublicId set")
|
||||
id, err := credential.NewPasswordCredentialId(ctx)
|
||||
require.NoError(err)
|
||||
|
||||
tt.want.PublicId = id
|
||||
got.PublicId = id
|
||||
|
||||
databaseWrapper, err := kkms.GetWrapper(context.Background(), prj.PublicId, kms.KeyPurposeDatabase)
|
||||
require.NoError(err)
|
||||
|
||||
err = got.encrypt(ctx, databaseWrapper)
|
||||
if tt.wantEncryptErr {
|
||||
require.Error(err)
|
||||
return
|
||||
}
|
||||
assert.NoError(err)
|
||||
|
||||
err = rw.Create(context.Background(), got)
|
||||
if tt.wantCreateErr {
|
||||
require.Error(err)
|
||||
return
|
||||
}
|
||||
assert.NoError(err)
|
||||
|
||||
got2 := allocPasswordCredential()
|
||||
got2.PublicId = id
|
||||
assert.Equal(id, got2.GetPublicId())
|
||||
require.NoError(rw.LookupById(ctx, got2))
|
||||
|
||||
err = got2.decrypt(ctx, databaseWrapper)
|
||||
require.NoError(err)
|
||||
|
||||
// Timestamps and version are automatically set
|
||||
tt.want.CreateTime = got2.CreateTime
|
||||
tt.want.UpdateTime = got2.UpdateTime
|
||||
tt.want.Version = got2.Version
|
||||
|
||||
// KeyId is allocated via kms no need to validate in this test
|
||||
tt.want.KeyId = got2.KeyId
|
||||
got2.CtPassword = nil
|
||||
|
||||
// encrypt also calculates the hmac, validate it is correct
|
||||
hm, err := crypto.HmacSha256(ctx, got.Password, databaseWrapper, []byte(got.StoreId), nil, crypto.WithEd25519())
|
||||
require.NoError(err)
|
||||
tt.want.PasswordHmac = []byte(hm)
|
||||
|
||||
assert.Empty(cmp.Diff(tt.want, got2.clone(), protocmp.Transform()))
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue