From 7897c1cf524abab6f3b6a18be7c72b6cf37f0b4a Mon Sep 17 00:00:00 2001 From: Michael Gaffney Date: Fri, 24 Jul 2020 10:23:35 -0400 Subject: [PATCH] Add base resources for password authentication (#213) * Add base SQL and protobuf files * Add options and public Ids * Add AuthMethod and Account resources * Add repository * Add CreateAuthMethod to repository * Add CreateAccount method to repository * Remove redundant foreign keys * Document relationships between iam and auth tables * Document relationships between auth and auth_password tables * Add documentation for subtype triggers * Use wt_scope_id domain type for scope_id * Refactor: move test helper function * Remove check from SetTableName * Remove check from another SetTableName * Use org scope in tests * Remove handling of CreateTime and UpdateTime from docs --- Makefile | 1 + internal/auth/db_test.go | 6 +- internal/auth/password/account.go | 77 ++++ internal/auth/password/account_test.go | 115 +++++ internal/auth/password/authmethod.go | 70 +++ internal/auth/password/authmethod_test.go | 144 +++++++ internal/auth/password/options.go | 37 ++ internal/auth/password/options_test.go | 23 + internal/auth/password/public_ids.go | 29 ++ internal/auth/password/public_ids_test.go | 22 + internal/auth/password/repository.go | 36 ++ internal/auth/password/repository_account.go | 72 ++++ .../auth/password/repository_account_test.go | 257 +++++++++++ .../auth/password/repository_authmethod.go | 55 +++ .../password/repository_authmethod_test.go | 196 +++++++++ internal/auth/password/repository_test.go | 100 +++++ internal/auth/password/store/password.pb.go | 407 ++++++++++++++++++ internal/db/migrations/postgres.gen.go | 313 +++++++++++++- .../db/migrations/postgres/07_auth.up.sql | 67 ++- .../postgres/12_auth_password.down.sql | 11 + .../postgres/12_auth_password.up.sql | 223 ++++++++++ .../auth/password/store/v1/password.proto | 69 +++ 22 files changed, 2307 insertions(+), 23 deletions(-) create mode 100644 internal/auth/password/account.go create mode 100644 internal/auth/password/account_test.go create mode 100644 internal/auth/password/authmethod.go create mode 100644 internal/auth/password/authmethod_test.go create mode 100644 internal/auth/password/options.go create mode 100644 internal/auth/password/options_test.go create mode 100644 internal/auth/password/public_ids.go create mode 100644 internal/auth/password/public_ids_test.go create mode 100644 internal/auth/password/repository.go create mode 100644 internal/auth/password/repository_account.go create mode 100644 internal/auth/password/repository_account_test.go create mode 100644 internal/auth/password/repository_authmethod.go create mode 100644 internal/auth/password/repository_authmethod_test.go create mode 100644 internal/auth/password/repository_test.go create mode 100644 internal/auth/password/store/password.pb.go create mode 100644 internal/db/migrations/postgres/12_auth_password.down.sql create mode 100644 internal/db/migrations/postgres/12_auth_password.up.sql create mode 100644 internal/proto/local/controller/storage/auth/password/store/v1/password.proto diff --git a/Makefile b/Makefile index 51ea3213d..dfbcfe0a0 100644 --- a/Makefile +++ b/Makefile @@ -78,6 +78,7 @@ protobuild: @protoc-go-inject-tag -input=./internal/host/static/store/static.pb.go @protoc-go-inject-tag -input=./internal/authtoken/store/authtoken.pb.go @protoc-go-inject-tag -input=./internal/iam/store/auth_account.pb.go + @protoc-go-inject-tag -input=./internal/auth/password/store/password.pb.go @rm -R ${TMP_DIR} protolint: diff --git a/internal/auth/db_test.go b/internal/auth/db_test.go index 979779a52..61bbe7d70 100644 --- a/internal/auth/db_test.go +++ b/internal/auth/db_test.go @@ -77,9 +77,9 @@ create table if not exists test_auth_account ( ` insert = ` insert into test_auth_account - (public_id, auth_method_id, scope_id) + (public_id, auth_method_id) values - ($1, $2, $3); + ($1, $2); ` addTriggers = ` create trigger @@ -118,7 +118,7 @@ values require.NoError(err) id := "l1Ocw0TpHn800CekIxIXlmQqRDgFDfYl" - _, err = db.Query(insert, id, meth_id, org.GetPublicId()) + _, err = db.Query(insert, id, meth_id) require.NoError(err) var count int diff --git a/internal/auth/password/account.go b/internal/auth/password/account.go new file mode 100644 index 000000000..ee51984ed --- /dev/null +++ b/internal/auth/password/account.go @@ -0,0 +1,77 @@ +package password + +import ( + "fmt" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" + "google.golang.org/protobuf/proto" +) + +// An Account contains a user name. It is owned by an auth method. +type Account struct { + *store.Account + tableName string + + // CredentialID is included when Authenticate or ChangePassword is + // called. A new CredentialID is generated when a password is changed. + CredentialID string `gorm:"-"` +} + +// NewAccount creates a new in memory Account with userName assigned to +// authMethodId. Name and description are the only valid options. All other +// options are ignored. +func NewAccount(authMethodId string, userName string, opt ...Option) (*Account, error) { + // NOTE(mgaffney): The scopeId in the embedded *store.Account is + // populated by a trigger in the database. + if authMethodId == "" { + return nil, fmt.Errorf("new: password account: no auth method id: %w", db.ErrInvalidParameter) + } + if userName == "" { + return nil, fmt.Errorf("new: password account: no user name: %w", db.ErrInvalidParameter) + } + + opts := getOpts(opt...) + a := &Account{ + Account: &store.Account{ + AuthMethodId: authMethodId, + UserName: userName, + Name: opts.withName, + Description: opts.withDescription, + }, + } + return a, nil +} + +func (a *Account) clone() *Account { + cp := proto.Clone(a.Account) + return &Account{ + Account: cp.(*store.Account), + } +} + +// TableName returns the table name. +func (a *Account) TableName() string { + if a.tableName != "" { + return a.tableName + } + return "auth_password_account" +} + +// SetTableName sets the table name. +func (a *Account) SetTableName(n string) { + a.tableName = n +} + +func (a *Account) oplog(op oplog.OpType) oplog.Metadata { + metadata := oplog.Metadata{ + "resource-public-id": []string{a.GetPublicId()}, + "resource-type": []string{"password account"}, + "op-type": []string{op.String()}, + } + if a.AuthMethodId != "" { + metadata["auth-method-id"] = []string{a.AuthMethodId} + } + return metadata +} diff --git a/internal/auth/password/account_test.go b/internal/auth/password/account_test.go new file mode 100644 index 000000000..cd96ff8f2 --- /dev/null +++ b/internal/auth/password/account_test.go @@ -0,0 +1,115 @@ +package password + +import ( + "context" + "testing" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccount_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + + w := db.New(conn) + auts := testAuthMethods(t, conn, 1) + aut := auts[0] + + type args struct { + authMethodId string + userName string + opts []Option + } + + var tests = []struct { + name string + args args + want *Account + wantErr bool + }{ + { + name: "blank-authMethodId", + args: args{ + authMethodId: "", + }, + want: nil, + wantErr: true, + }, + { + name: "valid-no-options", + args: args{ + authMethodId: aut.GetPublicId(), + userName: "kazmierczak", + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: aut.GetPublicId(), + UserName: "kazmierczak", + }, + }, + }, + { + name: "valid-with-name", + args: args{ + authMethodId: aut.GetPublicId(), + userName: "kazmierczak1", + opts: []Option{ + WithName("test-name"), + }, + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: aut.GetPublicId(), + UserName: "kazmierczak1", + Name: "test-name", + }, + }, + }, + { + name: "valid-with-description", + args: args{ + authMethodId: aut.GetPublicId(), + userName: "kazmierczak2", + opts: []Option{ + WithDescription("test-description"), + }, + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: aut.GetPublicId(), + UserName: "kazmierczak2", + Description: "test-description", + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewAccount(tt.args.authMethodId, tt.args.userName, tt.args.opts...) + if tt.wantErr { + assert.Error(err) + require.Nil(got) + return + } + assert.NoError(err) + require.NotNil(got) + + assert.Emptyf(got.PublicId, "PublicId set") + assert.Equal(tt.want, got) + + id, err := newAccountId() + assert.NoError(err) + + tt.want.PublicId = id + got.PublicId = id + + err2 := w.Create(context.Background(), got) + assert.NoError(err2) + }) + } +} diff --git a/internal/auth/password/authmethod.go b/internal/auth/password/authmethod.go new file mode 100644 index 000000000..8caa7abf4 --- /dev/null +++ b/internal/auth/password/authmethod.go @@ -0,0 +1,70 @@ +package password + +import ( + "fmt" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" + "google.golang.org/protobuf/proto" +) + +// A AuthMethod contains accounts and password configurations. It is owned +// by a scope. +type AuthMethod struct { + *store.AuthMethod + tableName string +} + +// NewAuthMethod creates a new in memory AuthMethod assigned to scopeId. +// Name and description are the only valid options. All other options are +// ignored. +func NewAuthMethod(scopeId string, opt ...Option) (*AuthMethod, error) { + if scopeId == "" { + return nil, fmt.Errorf("new: password auth method: no scope id: %w", db.ErrInvalidParameter) + } + + opts := getOpts(opt...) + a := &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: scopeId, + Name: opts.withName, + Description: opts.withDescription, + MinUserNameLength: 5, + MinPasswordLength: 8, + }, + } + return a, nil +} + +func (a *AuthMethod) clone() *AuthMethod { + cp := proto.Clone(a.AuthMethod) + return &AuthMethod{ + AuthMethod: cp.(*store.AuthMethod), + } +} + +// TableName returns the table name. +func (a *AuthMethod) TableName() string { + if a.tableName != "" { + return a.tableName + } + return "auth_password_method" +} + +// SetTableName sets the table name. +func (a *AuthMethod) SetTableName(n string) { + a.tableName = n +} + +func (a *AuthMethod) oplog(op oplog.OpType) oplog.Metadata { + metadata := oplog.Metadata{ + "resource-public-id": []string{a.GetPublicId()}, + "resource-type": []string{"password auth method"}, + "op-type": []string{op.String()}, + } + if a.ScopeId != "" { + metadata["scope-id"] = []string{a.ScopeId} + } + return metadata +} diff --git a/internal/auth/password/authmethod_test.go b/internal/auth/password/authmethod_test.go new file mode 100644 index 000000000..f47c879da --- /dev/null +++ b/internal/auth/password/authmethod_test.go @@ -0,0 +1,144 @@ +package password + +import ( + "context" + "testing" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/iam" + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testAuthMethods(t *testing.T, conn *gorm.DB, count int) []*AuthMethod { + t.Helper() + assert, require := assert.New(t), require.New(t) + w := db.New(conn) + org, _ := iam.TestScopes(t, conn) + var auts []*AuthMethod + for i := 0; i < count; i++ { + cat, err := NewAuthMethod(org.GetPublicId()) + assert.NoError(err) + require.NotNil(cat) + id, err := newAuthMethodId() + assert.NoError(err) + require.NotEmpty(id) + cat.PublicId = id + + ctx := context.Background() + _, err2 := w.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, iw db.Writer) error { + return iw.Create(ctx, cat) + }, + ) + + require.NoError(err2) + auts = append(auts, cat) + } + return auts +} + +func TestAuthMethod_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + + w := db.New(conn) + + type args struct { + scopeId string + opts []Option + } + + var tests = []struct { + name string + args args + want *AuthMethod + wantErr bool + }{ + { + name: "valid-no-options", + args: args{}, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + MinUserNameLength: 5, + MinPasswordLength: 8, + }, + }, + }, + { + name: "valid-with-name", + args: args{ + opts: []Option{ + WithName("test-name"), + }, + }, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + Name: "test-name", + MinUserNameLength: 5, + MinPasswordLength: 8, + }, + }, + }, + { + name: "valid-with-description", + args: args{ + opts: []Option{ + WithDescription("test-description"), + }, + }, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + Description: "test-description", + MinUserNameLength: 5, + MinPasswordLength: 8, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + org, _ := iam.TestScopes(t, conn) + got, err := NewAuthMethod(org.GetPublicId(), tt.args.opts...) + if tt.wantErr { + assert.Error(err) + require.Nil(got) + return + } + require.NoError(err) + require.NotNil(got) + + tt.want.ScopeId = org.GetPublicId() + + assert.Emptyf(got.PublicId, "PublicId set") + assert.Equal(tt.want, got) + + id, err := newAuthMethodId() + assert.NoError(err) + + tt.want.PublicId = id + got.PublicId = id + + conn.LogMode(true) + ctx := context.Background() + _, err2 := w.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, iw db.Writer) error { + return iw.Create(ctx, got) + }, + ) + conn.LogMode(false) + assert.NoError(err2) + }) + } + + t.Run("blank-scopeId", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewAuthMethod("") + assert.Error(err) + require.Nil(got) + }) +} diff --git a/internal/auth/password/options.go b/internal/auth/password/options.go new file mode 100644 index 000000000..923fba18d --- /dev/null +++ b/internal/auth/password/options.go @@ -0,0 +1,37 @@ +package password + +// getOpts - iterate the inbound Options and return a struct. +func getOpts(opt ...Option) options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*options) + +// options = how options are represented +type options struct { + withName string + withDescription string +} + +func getDefaultOptions() options { + return options{} +} + +// WithDescription provides an optional description. +func WithDescription(desc string) Option { + return func(o *options) { + o.withDescription = desc + } +} + +// WithName provides an optional name. +func WithName(name string) Option { + return func(o *options) { + o.withName = name + } +} diff --git a/internal/auth/password/options_test.go b/internal/auth/password/options_test.go new file mode 100644 index 000000000..5e9b6fa7c --- /dev/null +++ b/internal/auth/password/options_test.go @@ -0,0 +1,23 @@ +package password + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("WithName", func(t *testing.T) { + opts := getOpts(WithName("test")) + testOpts := getDefaultOptions() + testOpts.withName = "test" + assert.Equal(t, opts, testOpts) + }) + t.Run("WithDescription", func(t *testing.T) { + opts := getOpts(WithDescription("test desc")) + testOpts := getDefaultOptions() + testOpts.withDescription = "test desc" + assert.Equal(t, opts, testOpts) + }) +} diff --git a/internal/auth/password/public_ids.go b/internal/auth/password/public_ids.go new file mode 100644 index 000000000..1d20b45ac --- /dev/null +++ b/internal/auth/password/public_ids.go @@ -0,0 +1,29 @@ +package password + +import ( + "fmt" + + "github.com/hashicorp/watchtower/internal/db" +) + +// PublicId prefixes for the resources in the password package. +const ( + AuthMethodPrefix = "paum" + AccountPrefix = "pacc" +) + +func newAuthMethodId() (string, error) { + id, err := db.NewPublicId(AuthMethodPrefix) + if err != nil { + return "", fmt.Errorf("new password auth method id: %w", err) + } + return id, err +} + +func newAccountId() (string, error) { + id, err := db.NewPublicId(AccountPrefix) + if err != nil { + return "", fmt.Errorf("new password account id: %w", err) + } + return id, err +} diff --git a/internal/auth/password/public_ids_test.go b/internal/auth/password/public_ids_test.go new file mode 100644 index 000000000..a7d8e8872 --- /dev/null +++ b/internal/auth/password/public_ids_test.go @@ -0,0 +1,22 @@ +package password + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_PublicIds(t *testing.T) { + t.Run("authMethod", func(t *testing.T) { + id, err := newAuthMethodId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, AuthMethodPrefix+"_")) + }) + t.Run("account", func(t *testing.T) { + id, err := newAccountId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, AccountPrefix+"_")) + }) +} diff --git a/internal/auth/password/repository.go b/internal/auth/password/repository.go new file mode 100644 index 000000000..95dc4aca0 --- /dev/null +++ b/internal/auth/password/repository.go @@ -0,0 +1,36 @@ +package password + +import ( + "fmt" + + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/watchtower/internal/db" +) + +// A Repository stores and retrieves the persistent types in the password +// package. It is not safe to use a repository concurrently. +type Repository struct { + reader db.Reader + writer db.Writer + wrapper wrapping.Wrapper +} + +// NewRepository creates a new Repository. The returned repository should +// only be used for one transaction and it is not safe for concurrent go +// routines to access it. +func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper) (*Repository, error) { + switch { + case r == nil: + return nil, fmt.Errorf("db.Reader: %w", db.ErrNilParameter) + case w == nil: + return nil, fmt.Errorf("db.Writer: %w", db.ErrNilParameter) + case wrapper == nil: + return nil, fmt.Errorf("wrapping.Wrapper: %w", db.ErrNilParameter) + } + + return &Repository{ + reader: r, + writer: w, + wrapper: wrapper, + }, nil +} diff --git a/internal/auth/password/repository_account.go b/internal/auth/password/repository_account.go new file mode 100644 index 000000000..03c546731 --- /dev/null +++ b/internal/auth/password/repository_account.go @@ -0,0 +1,72 @@ +package password + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" +) + +// CreateAccount inserts a into the repository and returns a new +// Account containing the account's PublicId. a is not changed. a must +// contain a valid AuthMethodId. a must not contain a PublicId. The PublicId is +// generated and assigned by this method. +// +// Both a.Name and a.Description are optional. If a.Name is set, it must be +// unique within a.AuthMethodId. +func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Option) (*Account, error) { + if a == nil { + return nil, fmt.Errorf("create: password account: %w", db.ErrNilParameter) + } + if a.Account == nil { + return nil, fmt.Errorf("create: password account: embedded Account: %w", db.ErrNilParameter) + } + if a.AuthMethodId == "" { + return nil, fmt.Errorf("create: password account: no auth method id: %w", db.ErrInvalidParameter) + } + if a.PublicId != "" { + return nil, fmt.Errorf("create: password account: public id not empty: %w", db.ErrInvalidParameter) + } + if !validUserName(a.UserName) { + return nil, fmt.Errorf("create: password account: invalid user name: %w", db.ErrInvalidParameter) + } + + a = a.clone() + id, err := newAccountId() + if err != nil { + return nil, fmt.Errorf("create: password account: %w", err) + } + a.PublicId = id + + var newAccount *Account + _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + newAccount = a.clone() + if err := w.Create(ctx, newAccount, db.WithOplog(r.wrapper, a.oplog(oplog.OpType_OP_TYPE_CREATE))); err != nil { + return err + } + + return nil + }, + ) + + if err != nil { + if db.IsUniqueError(err) { + return nil, fmt.Errorf("create: password account: in auth method: %s: name %s already exists: %w", + a.AuthMethodId, a.Name, db.ErrNotUnique) + } + return nil, fmt.Errorf("create: password account: in auth method: %s: %w", a.AuthMethodId, err) + } + return newAccount, nil +} + +var reInvalidUserName = regexp.MustCompile("[^a-z0-9.]") + +func validUserName(u string) bool { + if u == "" { + return false + } + return !reInvalidUserName.Match([]byte(u)) +} diff --git a/internal/auth/password/repository_account_test.go b/internal/auth/password/repository_account_test.go new file mode 100644 index 000000000..57e5194fb --- /dev/null +++ b/internal/auth/password/repository_account_test.go @@ -0,0 +1,257 @@ +package password + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckUserName(t *testing.T) { + var tests = []struct { + in string + want bool + }{ + {"", false}, + {" leading-spaces", false}, + {"trailing-spaces ", false}, + {"contains spaces", false}, + {"NotLowerCase", false}, + {"valid.username", true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, validUserName(tt.in)) + }) + } +} + +func TestRepository_CreateAccount(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + authMethods := testAuthMethods(t, conn, 1) + authMethod := authMethods[0] + + var tests = []struct { + name string + in *Account + opts []Option + want *Account + wantIsErr error + }{ + { + name: "nil-Account", + wantIsErr: db.ErrNilParameter, + }, + { + name: "nil-embedded-Account", + in: &Account{}, + wantIsErr: db.ErrNilParameter, + }, + { + name: "invalid-no-scope-id", + in: &Account{ + Account: &store.Account{}, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-public-id-set", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + PublicId: "sthc_OOOOOOOOOO", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-username-uppercase", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "KaZmiErcZak11", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-username-leading-space", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: " kazmierczak12", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-username-trailing-space", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak13 ", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-username-space-in-name", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmier czak14", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "valid-no-options", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak", + }, + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak", + }, + }, + }, + { + name: "valid-with-name", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + Name: "test-name-repo", + UserName: "kazmierczak1", + }, + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + Name: "test-name-repo", + UserName: "kazmierczak1", + }, + }, + }, + { + name: "valid-with-description", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + Description: ("test-description-repo"), + UserName: "kazmierczak2", + }, + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + Description: ("test-description-repo"), + UserName: "kazmierczak2", + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.CreateAccount(context.Background(), tt.in, tt.opts...) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + assert.Empty(tt.in.PublicId) + require.NotNil(got) + assertPublicId(t, AccountPrefix, got.PublicId) + assert.NotSame(tt.in, got) + assert.Equal(tt.want.Name, got.Name) + assert.Equal(tt.want.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + }) + } + + t.Run("invalid-duplicate-names", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + + authMethods := testAuthMethods(t, conn, 1) + authMethod := authMethods[0] + + in := &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.GetPublicId(), + Name: "test-name-repo", + UserName: "kazmierczak3", + }, + } + + got, err := repo.CreateAccount(context.Background(), in) + require.NoError(err) + require.NotNil(got) + assertPublicId(t, AccountPrefix, got.PublicId) + assert.NotSame(in, got) + assert.Equal(in.Name, got.Name) + assert.Equal(in.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + + got2, err := repo.CreateAccount(context.Background(), in) + assert.Truef(errors.Is(err, db.ErrNotUnique), "want err: %v got: %v", db.ErrNotUnique, err) + assert.Nil(got2) + }) + + t.Run("valid-duplicate-names-diff-parents", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + + authMethods := testAuthMethods(t, conn, 2) + authMethoda, authMethodb := authMethods[0], authMethods[1] + in := &Account{ + Account: &store.Account{ + Name: "test-name-repo", + UserName: "kazmierczak4", + }, + } + in2 := in.clone() + + in.AuthMethodId = authMethoda.GetPublicId() + got, err := repo.CreateAccount(context.Background(), in) + require.NoError(err) + require.NotNil(got) + assertPublicId(t, AccountPrefix, got.PublicId) + assert.NotSame(in, got) + assert.Equal(in.Name, got.Name) + assert.Equal(in.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + + in2.AuthMethodId = authMethodb.GetPublicId() + got2, err := repo.CreateAccount(context.Background(), in2) + assert.NoError(err) + require.NotNil(got2) + assertPublicId(t, AccountPrefix, got2.PublicId) + assert.NotSame(in2, got2) + assert.Equal(in2.Name, got2.Name) + assert.Equal(in2.Description, got2.Description) + assert.Equal(got2.CreateTime, got2.UpdateTime) + }) +} diff --git a/internal/auth/password/repository_authmethod.go b/internal/auth/password/repository_authmethod.go new file mode 100644 index 000000000..104280eb5 --- /dev/null +++ b/internal/auth/password/repository_authmethod.go @@ -0,0 +1,55 @@ +package password + +import ( + "context" + "fmt" + + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" +) + +// CreateAuthMethod inserts m into the repository and returns a new +// AuthMethod containing the auth method's PublicId. m is not changed. m must +// contain a valid ScopeId. m must not contain a PublicId. The PublicId is +// generated and assigned by this method. +// +// Both m.Name and m.Description are optional. If m.Name is set, it must be +// unique within m.ScopeId. +func (r *Repository) CreateAuthMethod(ctx context.Context, m *AuthMethod, opt ...Option) (*AuthMethod, error) { + if m == nil { + return nil, fmt.Errorf("create: password auth method: %w", db.ErrNilParameter) + } + if m.AuthMethod == nil { + return nil, fmt.Errorf("create: password auth method: embedded AuthMethod: %w", db.ErrNilParameter) + } + if m.ScopeId == "" { + return nil, fmt.Errorf("create: password auth method: no scope id: %w", db.ErrInvalidParameter) + } + if m.PublicId != "" { + return nil, fmt.Errorf("create: password auth method: public id not empty: %w", db.ErrInvalidParameter) + } + m = m.clone() + + id, err := newAuthMethodId() + if err != nil { + return nil, fmt.Errorf("create: password auth method: %w", err) + } + m.PublicId = id + + var newAuthMethod *AuthMethod + _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + newAuthMethod = m.clone() + return w.Create(ctx, newAuthMethod, db.WithOplog(r.wrapper, m.oplog(oplog.OpType_OP_TYPE_CREATE))) + }, + ) + + if err != nil { + if db.IsUniqueError(err) { + return nil, fmt.Errorf("create: password auth method: in scope: %s: name %s already exists: %w", + m.ScopeId, m.Name, db.ErrNotUnique) + } + return nil, fmt.Errorf("create: password auth method: in scope: %s: %w", m.ScopeId, err) + } + return newAuthMethod, nil +} diff --git a/internal/auth/password/repository_authmethod_test.go b/internal/auth/password/repository_authmethod_test.go new file mode 100644 index 000000000..352f5bbd7 --- /dev/null +++ b/internal/auth/password/repository_authmethod_test.go @@ -0,0 +1,196 @@ +package password + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_CreateAuthMethod(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + org, _ := iam.TestScopes(t, conn) + + var tests = []struct { + name string + in *AuthMethod + opts []Option + want *AuthMethod + wantIsErr error + }{ + { + name: "nil-AuthMethod", + wantIsErr: db.ErrNilParameter, + }, + { + name: "nil-embedded-AuthMethod", + in: &AuthMethod{}, + wantIsErr: db.ErrNilParameter, + }, + { + name: "invalid-no-scope-id", + in: &AuthMethod{ + AuthMethod: &store.AuthMethod{}, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-public-id-set", + in: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + PublicId: "sthc_OOOOOOOOOO", + }, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "valid-no-options", + in: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + }, + }, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + }, + }, + }, + { + name: "valid-with-name", + in: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + Name: "test-name-repo", + }, + }, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + Name: "test-name-repo", + }, + }, + }, + { + name: "valid-with-description", + in: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + Description: ("test-description-repo"), + }, + }, + want: &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.PublicId, + Description: ("test-description-repo"), + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(err) + require.NotNil(repo) + got, err := repo.CreateAuthMethod(context.Background(), tt.in, tt.opts...) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + assert.Empty(tt.in.PublicId) + require.NotNil(got) + assertPublicId(t, AuthMethodPrefix, got.PublicId) + assert.NotSame(tt.in, got) + assert.Equal(tt.want.Name, got.Name) + assert.Equal(tt.want.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + }) + } + + t.Run("invalid-duplicate-names", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(err) + require.NotNil(repo) + + org, _ := iam.TestScopes(t, conn) + in := &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: org.GetPublicId(), + Name: "test-name-repo", + }, + } + + got, err := repo.CreateAuthMethod(context.Background(), in) + require.NoError(err) + require.NotNil(got) + assertPublicId(t, AuthMethodPrefix, got.PublicId) + assert.NotSame(in, got) + assert.Equal(in.Name, got.Name) + assert.Equal(in.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + + got2, err := repo.CreateAuthMethod(context.Background(), in) + assert.Truef(errors.Is(err, db.ErrNotUnique), "want err: %v got: %v", db.ErrNotUnique, err) + assert.Nil(got2) + }) + + t.Run("valid-duplicate-names-diff-scopes", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(err) + require.NotNil(repo) + + org1, _ := iam.TestScopes(t, conn) + in := &AuthMethod{ + AuthMethod: &store.AuthMethod{ + Name: "test-name-repo", + }, + } + in2 := in.clone() + + in.ScopeId = org1.GetPublicId() + got, err := repo.CreateAuthMethod(context.Background(), in) + require.NoError(err) + require.NotNil(got) + assertPublicId(t, AuthMethodPrefix, got.PublicId) + assert.NotSame(in, got) + assert.Equal(in.Name, got.Name) + assert.Equal(in.Description, got.Description) + assert.Equal(got.CreateTime, got.UpdateTime) + + org2, _ := iam.TestScopes(t, conn) + in2.ScopeId = org2.GetPublicId() + got2, err := repo.CreateAuthMethod(context.Background(), in2) + require.NoError(err) + require.NotNil(got2) + assertPublicId(t, AuthMethodPrefix, got2.PublicId) + assert.NotSame(in2, got2) + assert.Equal(in2.Name, got2.Name) + assert.Equal(in2.Description, got2.Description) + assert.Equal(got2.CreateTime, got2.UpdateTime) + }) +} + +func assertPublicId(t *testing.T, prefix, actual string) { + t.Helper() + assert.NotEmpty(t, actual) + parts := strings.Split(actual, "_") + assert.Equalf(t, 2, len(parts), "want one '_' in PublicId, got multiple in %q", actual) + assert.Equalf(t, prefix, parts[0], "PublicId want prefix: %q, got: %q in %q", prefix, parts[0], actual) +} diff --git a/internal/auth/password/repository_test.go b/internal/auth/password/repository_test.go new file mode 100644 index 000000000..103f63efb --- /dev/null +++ b/internal/auth/password/repository_test.go @@ -0,0 +1,100 @@ +package password + +import ( + "errors" + "testing" + + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + type args struct { + r db.Reader + w db.Writer + wrapper wrapping.Wrapper + } + + var tests = []struct { + name string + args args + want *Repository + wantIsErr error + }{ + { + name: "valid", + args: args{ + r: rw, + w: rw, + wrapper: wrapper, + }, + want: &Repository{ + reader: rw, + writer: rw, + wrapper: wrapper, + }, + }, + { + name: "nil-reader", + args: args{ + r: nil, + w: rw, + wrapper: wrapper, + }, + want: nil, + wantIsErr: db.ErrNilParameter, + }, + { + name: "nil-writer", + args: args{ + r: rw, + w: nil, + wrapper: wrapper, + }, + want: nil, + wantIsErr: db.ErrNilParameter, + }, + { + name: "nil-wrapper", + args: args{ + r: rw, + w: rw, + wrapper: nil, + }, + want: nil, + wantIsErr: db.ErrNilParameter, + }, + { + name: "all-nils", + args: args{ + r: nil, + w: nil, + wrapper: nil, + }, + want: nil, + wantIsErr: db.ErrNilParameter, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := NewRepository(tt.args.r, tt.args.w, tt.args.wrapper) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + assert.NoError(err) + require.NotNil(got) + assert.Equal(tt.want, got) + }) + } +} diff --git a/internal/auth/password/store/password.pb.go b/internal/auth/password/store/password.pb.go new file mode 100644 index 000000000..9bae60e7d --- /dev/null +++ b/internal/auth/password/store/password.pb.go @@ -0,0 +1,407 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.3 +// source: controller/storage/auth/password/store/v1/password.proto + +// Package store provides protobufs for storing types in the password package. + +package store + +import ( + proto "github.com/golang/protobuf/proto" + timestamp "github.com/hashicorp/watchtower/internal/db/timestamp" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type AuthMethod struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // @inject_tag: `gorm:"primary_key"` + PublicId string `protobuf:"bytes,1,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,3,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + // name is optional. If set, it must be unique within scope_id. + // @inject_tag: `gorm:"default:null"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty" gorm:"default:null"` + // description is optional. + // @inject_tag: `gorm:"default:null"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty" gorm:"default:null"` + // The scope_id of the owning scope. Must be set. + // @inject_tag: `gorm:"not_null"` + ScopeId string `protobuf:"bytes,6,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty" gorm:"not_null"` + // @inject_tag: `gorm:"default:null"` + MinUserNameLength uint32 `protobuf:"varint,8,opt,name=min_user_name_length,json=minUserNameLength,proto3" json:"min_user_name_length,omitempty" gorm:"default:null"` + // @inject_tag: `gorm:"default:null"` + MinPasswordLength uint32 `protobuf:"varint,9,opt,name=min_password_length,json=minPasswordLength,proto3" json:"min_password_length,omitempty" gorm:"default:null"` +} + +func (x *AuthMethod) Reset() { + *x = AuthMethod{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_auth_password_store_v1_password_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthMethod) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthMethod) ProtoMessage() {} + +func (x *AuthMethod) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_auth_password_store_v1_password_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthMethod.ProtoReflect.Descriptor instead. +func (*AuthMethod) Descriptor() ([]byte, []int) { + return file_controller_storage_auth_password_store_v1_password_proto_rawDescGZIP(), []int{0} +} + +func (x *AuthMethod) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *AuthMethod) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *AuthMethod) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *AuthMethod) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AuthMethod) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AuthMethod) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *AuthMethod) GetMinUserNameLength() uint32 { + if x != nil { + return x.MinUserNameLength + } + return 0 +} + +func (x *AuthMethod) GetMinPasswordLength() uint32 { + if x != nil { + return x.MinPasswordLength + } + return 0 +} + +type Account struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // @inject_tag: `gorm:"primary_key"` + PublicId string `protobuf:"bytes,1,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,3,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + // name is optional. If set, it must be unique within scope_id. + // @inject_tag: `gorm:"default:null"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty" gorm:"default:null"` + // description is optional. + // @inject_tag: `gorm:"default:null"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty" gorm:"default:null"` + // The scope_id of the owning scope. Must be set. + // @inject_tag: `gorm:"not_null"` + ScopeId string `protobuf:"bytes,6,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty" gorm:"not_null"` + // @inject_tag: `gorm:"not_null"` + AuthMethodId string `protobuf:"bytes,7,opt,name=auth_method_id,json=authMethodId,proto3" json:"auth_method_id,omitempty" gorm:"not_null"` + // @inject_tag: `gorm:"not_null"` + UserName string `protobuf:"bytes,8,opt,name=user_name,json=userName,proto3" json:"user_name,omitempty" gorm:"not_null"` +} + +func (x *Account) Reset() { + *x = Account{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_auth_password_store_v1_password_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Account) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Account) ProtoMessage() {} + +func (x *Account) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_auth_password_store_v1_password_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Account.ProtoReflect.Descriptor instead. +func (*Account) Descriptor() ([]byte, []int) { + return file_controller_storage_auth_password_store_v1_password_proto_rawDescGZIP(), []int{1} +} + +func (x *Account) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *Account) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *Account) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *Account) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Account) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *Account) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *Account) GetAuthMethodId() string { + if x != nil { + return x.AuthMethodId + } + return "" +} + +func (x *Account) GetUserName() string { + if x != nil { + return x.UserName + } + return "" +} + +var File_controller_storage_auth_password_store_v1_password_proto protoreflect.FileDescriptor + +var file_controller_storage_auth_password_store_v1_password_proto_rawDesc = []byte{ + 0x0a, 0x38, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x29, 0x63, 0x6f, 0x6e, 0x74, + 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x2e, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x2e, 0x73, 0x74, 0x6f, + 0x72, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, + 0x72, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf5, 0x02, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x4d, + 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, + 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, + 0x4b, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, + 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x49, 0x64, 0x12, 0x2f, 0x0a, + 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x6c, + 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x11, 0x6d, 0x69, 0x6e, + 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x12, 0x2e, + 0x0a, 0x13, 0x6d, 0x69, 0x6e, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x6c, + 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x11, 0x6d, 0x69, 0x6e, + 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0xd4, + 0x02, 0x0a, 0x07, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x75, + 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x54, 0x69, 0x6d, 0x65, 0x12, 0x4b, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, + 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, + 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, + 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x63, 0x6f, 0x70, 0x65, + 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, + 0x64, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x75, 0x74, 0x68, + 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x44, 0x5a, 0x42, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x77, 0x61, + 0x74, 0x63, 0x68, 0x74, 0x6f, 0x77, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x2f, + 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_controller_storage_auth_password_store_v1_password_proto_rawDescOnce sync.Once + file_controller_storage_auth_password_store_v1_password_proto_rawDescData = file_controller_storage_auth_password_store_v1_password_proto_rawDesc +) + +func file_controller_storage_auth_password_store_v1_password_proto_rawDescGZIP() []byte { + file_controller_storage_auth_password_store_v1_password_proto_rawDescOnce.Do(func() { + file_controller_storage_auth_password_store_v1_password_proto_rawDescData = protoimpl.X.CompressGZIP(file_controller_storage_auth_password_store_v1_password_proto_rawDescData) + }) + return file_controller_storage_auth_password_store_v1_password_proto_rawDescData +} + +var file_controller_storage_auth_password_store_v1_password_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_controller_storage_auth_password_store_v1_password_proto_goTypes = []interface{}{ + (*AuthMethod)(nil), // 0: controller.storage.auth.password.store.v1.AuthMethod + (*Account)(nil), // 1: controller.storage.auth.password.store.v1.Account + (*timestamp.Timestamp)(nil), // 2: controller.storage.timestamp.v1.Timestamp +} +var file_controller_storage_auth_password_store_v1_password_proto_depIdxs = []int32{ + 2, // 0: controller.storage.auth.password.store.v1.AuthMethod.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // 1: controller.storage.auth.password.store.v1.AuthMethod.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // 2: controller.storage.auth.password.store.v1.Account.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // 3: controller.storage.auth.password.store.v1.Account.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_controller_storage_auth_password_store_v1_password_proto_init() } +func file_controller_storage_auth_password_store_v1_password_proto_init() { + if File_controller_storage_auth_password_store_v1_password_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_controller_storage_auth_password_store_v1_password_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthMethod); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_controller_storage_auth_password_store_v1_password_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Account); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_controller_storage_auth_password_store_v1_password_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_controller_storage_auth_password_store_v1_password_proto_goTypes, + DependencyIndexes: file_controller_storage_auth_password_store_v1_password_proto_depIdxs, + MessageInfos: file_controller_storage_auth_password_store_v1_password_proto_msgTypes, + }.Build() + File_controller_storage_auth_password_store_v1_password_proto = out.File + file_controller_storage_auth_password_store_v1_password_proto_rawDesc = nil + file_controller_storage_auth_password_store_v1_password_proto_goTypes = nil + file_controller_storage_auth_password_store_v1_password_proto_depIdxs = nil +} diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index 059a63b08..5dd31e238 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -1227,12 +1227,51 @@ commit; bytes: []byte(` begin; - -- Design influenced by: - -- https://dba.stackexchange.com/questions/58970/enforcing-constraints-two-tables-away/58972#58972 - -- - -- iam_scope ←───── auth_method - -- ↑ ↑ - -- iam_user ←───── auth_account +/* + + ┌────────────────┐ ┌────────────────┐ + │ iam_scope │ │ auth_method │ + ├────────────────┤ ├────────────────┤ + │ public_id (pk) │ ╱│ public_id (pk) │ + │ │┼┼───────────○─│ scope_id (fk) │ + │ │ ╲│ │ + └────────────────┘ └────────────────┘ + ┼ ┼ + ┼ ┼ + │ │ + │ │ ▲fk1 + │ │ + ○ ○ + ╱│╲ ╱│╲ + ┌────────────────┐ ┌──────────────────────────┐ + │ iam_user │ │ auth_account │ + ├────────────────┤ ├──────────────────────────┤ + │ public_id (pk) │ │ public_id (pk) │ + │ scope_id (fk) │ ◀fk2 │ scope_id (fk1,fk2) │ + │ │┼○──────○┼│ auth_method_id (fk1) │ + │ │ │ iam_user_id (fk2) │ + └────────────────┘ └──────────────────────────┘ + + An iam_scope can have 0 to many iam_users. + An iam_scope can have 0 to many auth_methods. + + An iam_user belongs to 1 iam_scope. + An auth_method belongs to 1 iam_scope. + + An iam_user can have 0 or 1 auth_account. + An auth_account belongs to 0 or 1 iam_user. + + An auth_method can have 0 to many auth_accounts. + An auth_account belongs to 1 auth_account. + + An auth_account can only be associated with an iam_user in the same scope of + the auth_account's auth_method. Including scope_id in fk1 and fk2 ensures this + restriction is not violated. + + Design influenced by: + https://dba.stackexchange.com/questions/58970/enforcing-constraints-two-tables-away/58972#58972 + +*/ -- base table for auth methods create table auth_method ( @@ -1255,18 +1294,19 @@ begin; auth_method_id wt_public_id not null, scope_id wt_scope_id not null, iam_user_id wt_public_id, - foreign key (scope_id, auth_method_id) + -- including scope_id in fk1 and fk2 ensures the scope_id of the owning + -- auth_method and the scope_id of the owning iam_user are the same + foreign key (scope_id, auth_method_id) -- fk1 references auth_method (scope_id, public_id) on delete cascade on update cascade, - foreign key (scope_id, iam_user_id) + foreign key (scope_id, iam_user_id) -- fk2 references iam_user (scope_id, public_id) on delete set null on update cascade, unique(scope_id, auth_method_id, public_id) ); - create or replace function insert_auth_method_subtype() returns trigger @@ -1285,15 +1325,22 @@ begin; returns trigger as $$ begin + + select auth_method.scope_id + into new.scope_id + from auth_method + where auth_method.public_id = new.auth_method_id; + insert into auth_account (public_id, auth_method_id, scope_id) values (new.public_id, new.auth_method_id, new.scope_id); + return new; + end; $$ language plpgsql; - commit; `), @@ -1584,6 +1631,252 @@ begin; commit; +`), + }, + "migrations/12_auth_password.down.sql": { + name: "12_auth_password.down.sql", + bytes: []byte(` +begin; + + drop table auth_password_credential; + drop table auth_password_conf cascade; + drop table if exists auth_password_account; + drop table if exists auth_password_method; + + drop function insert_auth_password_credential_subtype; + drop function insert_auth_password_conf_subtype; + +commit; + +`), + }, + "migrations/12_auth_password.up.sql": { + name: "12_auth_password.up.sql", + bytes: []byte(` +begin; + +/* + + ┌────────────────┐ ┌──────────────────────┐ ┌────────────────────────────┐ + │ auth_method │ │ auth_password_method │ │ auth_password_conf │ + ├────────────────┤ ├──────────────────────┤ ├────────────────────────────┤ + │ public_id (pk) │ │ public_id (pk,fk) │ ╱│ public_id (pk,fk) │ + │ scope_id (fk) │┼┼─────────────○┼│ scope_id (fk) │┼┼─────────○─│ password_method_id (fk) │ + │ │ │ ... │ ╲│ │ + └────────────────┘ └──────────────────────┘ └────────────────────────────┘ + ┼ ┼ ┼ + ┼ ┼ ┼ + │ │ │ + │ ▲fk1 │ ▲fk1 │ ▲fk1 + │ │ │ + ○ ○ ○ + ╱│╲ ╱│╲ ╱│╲ + ┌──────────────────────────┐ ┌──────────────────────────┐ ┌───────────────────────────────┐ + │ auth_account │ │ auth_password_account │ │ auth_password_credential │ + ├──────────────────────────┤ ├──────────────────────────┤ ├───────────────────────────────┤ + │ public_id (pk) │ │ public_id (pk,fk2) │ │ public_id (pk) │ + │ scope_id (fk1,fk2) │ ◀fk2 │ scope_id (fk1,fk2) │ ◀fk2 │ password_method_id (fk1,fk2) │ + │ auth_method_id (fk1) │┼┼──────○┼│ auth_method_id (fk1,fk2) │┼┼──────○┼│ password_conf_id (fk1) │ + │ iam_user_id (fk2) │ │ ... │ │ password_account_id (fk2) │ + └──────────────────────────┘ └──────────────────────────┘ └───────────────────────────────┘ + + An auth_method is "abstract". An auth_password_method is a concrete + auth_method. For every row in auth_password_method there is one row in + auth_method with the same public_id and scope_id. + + Similarly, an auth_account is "abstract". An auth_password_account is a concrete + auth_account. For every row in auth_password_account there is one row in + auth_account with the same public_id, scope_id, and auth_method_id. + + Both auth_password_conf and auth_password_credential are abstract. Each + password key derivation function will require a concrete auth_password_conf + and auth_password_credential table. + + An auth_method can have 0 or 1 auth_password_method. + An auth_account can have 0 or 1 auth_password_account. + + An auth_password_method belongs to 1 auth_method. + An auth_password_method can have 0 to many auth_password_accounts. + An auth_password_method can have 0 to many auth_password_confs. + + An auth_password_account belongs to 1 auth_account. + An auth_password_account belongs to 1 auth_password_method. + An auth_password_account can have 0 or 1 auth_password_credential. + + An auth_password_conf belongs to 1 auth_password_method. + An auth_password_conf can have 0 to many auth_password_credentials. + + An auth_password_credential belongs to 1 auth_password_account. + An auth_password_credential belongs to 1 auth_password_conf. + +*/ + + create table auth_password_method ( + public_id wt_public_id primary key, + scope_id wt_scope_id not null, + name text, + description text, + create_time wt_timestamp, + update_time wt_timestamp, + min_user_name_length int not null default 5, + min_password_length int not null default 8, + foreign key (scope_id, public_id) + references auth_method (scope_id, public_id) + on delete cascade + on update cascade, + unique(scope_id, name), + unique(scope_id, public_id) + ); + + create trigger + insert_auth_method_subtype + before insert on auth_password_method + for each row execute procedure insert_auth_method_subtype(); + + create table auth_password_account ( + public_id wt_public_id primary key, + auth_method_id wt_public_id not null, + -- NOTE(mgaffney): The scope_id type is not wt_scope_id because the domain + -- check is executed before the insert trigger which retrieves the scope_id + -- causing an insert to fail. + scope_id text not null, + name text, + description text, + create_time wt_timestamp, + update_time wt_timestamp, + user_name text not null + check( + lower(trim(user_name)) = user_name + and + length(user_name) > 0 + ), + foreign key (scope_id, auth_method_id) + references auth_password_method (scope_id, public_id) + on delete cascade + on update cascade, + foreign key (scope_id, auth_method_id, public_id) + references auth_account (scope_id, auth_method_id, public_id) + on delete cascade + on update cascade, + unique(auth_method_id, name), + unique(auth_method_id, user_name), + unique(auth_method_id, public_id) + ); + + create trigger + insert_auth_account_subtype + before insert on auth_password_account + for each row execute procedure insert_auth_account_subtype(); + + create table auth_password_conf ( + public_id wt_public_id primary key, + password_method_id wt_public_id not null + references auth_password_method (public_id) + on delete cascade + on update cascade + deferrable initially deferred, + unique(password_method_id, public_id) + ); + + -- insert_auth_password_conf_subtype() is a trigger function for concrete + -- implementations of auth_password_conf + create or replace function + insert_auth_password_conf_subtype() + returns trigger + as $$ + begin + insert into auth_password_conf + (public_id, password_method_id) + values + (new.public_id, new.password_method_id); + return new; + end; + $$ language plpgsql; + + create table auth_password_credential ( + public_id wt_public_id primary key, + password_account_id wt_public_id not null unique, + password_conf_id wt_public_id not null, + password_method_id wt_public_id not null, + foreign key (password_method_id, password_conf_id) + references auth_password_conf (password_method_id, public_id) + on delete cascade + on update cascade, + foreign key (password_method_id, password_account_id) + references auth_password_account (auth_method_id, public_id) + on delete cascade + on update cascade, + unique(password_method_id, password_conf_id, password_account_id) + ); + + -- insert_auth_password_credential_subtype() is a trigger function for concrete + -- implementations of auth_password_credential + create or replace function + insert_auth_password_credential_subtype() + returns trigger + as $$ + begin + + select auth_password_account.auth_method_id + into new.password_method_id + from auth_password_account + where auth_password_account.public_id = new.password_account_id; + + insert into auth_password_credential + (public_id, password_account_id, password_conf_id, password_method_id) + values + (new.public_id, new.password_account_id, new.password_conf_id, new.password_method_id); + return new; + end; + $$ language plpgsql; + + -- + -- triggers for time columns + --- + + create trigger + update_time_column + before + update on auth_password_method + for each row execute procedure update_time_column(); + + create trigger + immutable_create_time + before + update on auth_password_method + for each row execute procedure immutable_create_time_func(); + + create trigger + default_create_time_column + before + insert on auth_password_method + for each row execute procedure default_create_time(); + + create trigger + update_time_column + before + update on auth_password_account + for each row execute procedure update_time_column(); + + create trigger + immutable_create_time + before + update on auth_password_account + for each row execute procedure immutable_create_time_func(); + + create trigger + default_create_time_column + before + insert on auth_password_account + for each row execute procedure default_create_time(); + + insert into oplog_ticket (name, version) + values + ('auth_password_method', 1), + ('auth_password_account', 1); + +commit; + `), }, } diff --git a/internal/db/migrations/postgres/07_auth.up.sql b/internal/db/migrations/postgres/07_auth.up.sql index ed992b446..1f190436e 100644 --- a/internal/db/migrations/postgres/07_auth.up.sql +++ b/internal/db/migrations/postgres/07_auth.up.sql @@ -1,11 +1,50 @@ begin; - -- Design influenced by: - -- https://dba.stackexchange.com/questions/58970/enforcing-constraints-two-tables-away/58972#58972 - -- - -- iam_scope ←───── auth_method - -- ↑ ↑ - -- iam_user ←───── auth_account +/* + + ┌────────────────┐ ┌────────────────┐ + │ iam_scope │ │ auth_method │ + ├────────────────┤ ├────────────────┤ + │ public_id (pk) │ ╱│ public_id (pk) │ + │ │┼┼───────────○─│ scope_id (fk) │ + │ │ ╲│ │ + └────────────────┘ └────────────────┘ + ┼ ┼ + ┼ ┼ + │ │ + │ │ ▲fk1 + │ │ + ○ ○ + ╱│╲ ╱│╲ + ┌────────────────┐ ┌──────────────────────────┐ + │ iam_user │ │ auth_account │ + ├────────────────┤ ├──────────────────────────┤ + │ public_id (pk) │ │ public_id (pk) │ + │ scope_id (fk) │ ◀fk2 │ scope_id (fk1,fk2) │ + │ │┼○──────○┼│ auth_method_id (fk1) │ + │ │ │ iam_user_id (fk2) │ + └────────────────┘ └──────────────────────────┘ + + An iam_scope can have 0 to many iam_users. + An iam_scope can have 0 to many auth_methods. + + An iam_user belongs to 1 iam_scope. + An auth_method belongs to 1 iam_scope. + + An iam_user can have 0 or 1 auth_account. + An auth_account belongs to 0 or 1 iam_user. + + An auth_method can have 0 to many auth_accounts. + An auth_account belongs to 1 auth_account. + + An auth_account can only be associated with an iam_user in the same scope of + the auth_account's auth_method. Including scope_id in fk1 and fk2 ensures this + restriction is not violated. + + Design influenced by: + https://dba.stackexchange.com/questions/58970/enforcing-constraints-two-tables-away/58972#58972 + +*/ -- base table for auth methods create table auth_method ( @@ -28,18 +67,19 @@ begin; auth_method_id wt_public_id not null, scope_id wt_scope_id not null, iam_user_id wt_public_id, - foreign key (scope_id, auth_method_id) + -- including scope_id in fk1 and fk2 ensures the scope_id of the owning + -- auth_method and the scope_id of the owning iam_user are the same + foreign key (scope_id, auth_method_id) -- fk1 references auth_method (scope_id, public_id) on delete cascade on update cascade, - foreign key (scope_id, iam_user_id) + foreign key (scope_id, iam_user_id) -- fk2 references iam_user (scope_id, public_id) on delete set null on update cascade, unique(scope_id, auth_method_id, public_id) ); - create or replace function insert_auth_method_subtype() returns trigger @@ -58,13 +98,20 @@ begin; returns trigger as $$ begin + + select auth_method.scope_id + into new.scope_id + from auth_method + where auth_method.public_id = new.auth_method_id; + insert into auth_account (public_id, auth_method_id, scope_id) values (new.public_id, new.auth_method_id, new.scope_id); + return new; + end; $$ language plpgsql; - commit; diff --git a/internal/db/migrations/postgres/12_auth_password.down.sql b/internal/db/migrations/postgres/12_auth_password.down.sql new file mode 100644 index 000000000..1e07b1af2 --- /dev/null +++ b/internal/db/migrations/postgres/12_auth_password.down.sql @@ -0,0 +1,11 @@ +begin; + + drop table auth_password_credential; + drop table auth_password_conf cascade; + drop table if exists auth_password_account; + drop table if exists auth_password_method; + + drop function insert_auth_password_credential_subtype; + drop function insert_auth_password_conf_subtype; + +commit; diff --git a/internal/db/migrations/postgres/12_auth_password.up.sql b/internal/db/migrations/postgres/12_auth_password.up.sql new file mode 100644 index 000000000..bf2c454eb --- /dev/null +++ b/internal/db/migrations/postgres/12_auth_password.up.sql @@ -0,0 +1,223 @@ +begin; + +/* + + ┌────────────────┐ ┌──────────────────────┐ ┌────────────────────────────┐ + │ auth_method │ │ auth_password_method │ │ auth_password_conf │ + ├────────────────┤ ├──────────────────────┤ ├────────────────────────────┤ + │ public_id (pk) │ │ public_id (pk,fk) │ ╱│ public_id (pk,fk) │ + │ scope_id (fk) │┼┼─────────────○┼│ scope_id (fk) │┼┼─────────○─│ password_method_id (fk) │ + │ │ │ ... │ ╲│ │ + └────────────────┘ └──────────────────────┘ └────────────────────────────┘ + ┼ ┼ ┼ + ┼ ┼ ┼ + │ │ │ + │ ▲fk1 │ ▲fk1 │ ▲fk1 + │ │ │ + ○ ○ ○ + ╱│╲ ╱│╲ ╱│╲ + ┌──────────────────────────┐ ┌──────────────────────────┐ ┌───────────────────────────────┐ + │ auth_account │ │ auth_password_account │ │ auth_password_credential │ + ├──────────────────────────┤ ├──────────────────────────┤ ├───────────────────────────────┤ + │ public_id (pk) │ │ public_id (pk,fk2) │ │ public_id (pk) │ + │ scope_id (fk1,fk2) │ ◀fk2 │ scope_id (fk1,fk2) │ ◀fk2 │ password_method_id (fk1,fk2) │ + │ auth_method_id (fk1) │┼┼──────○┼│ auth_method_id (fk1,fk2) │┼┼──────○┼│ password_conf_id (fk1) │ + │ iam_user_id (fk2) │ │ ... │ │ password_account_id (fk2) │ + └──────────────────────────┘ └──────────────────────────┘ └───────────────────────────────┘ + + An auth_method is "abstract". An auth_password_method is a concrete + auth_method. For every row in auth_password_method there is one row in + auth_method with the same public_id and scope_id. + + Similarly, an auth_account is "abstract". An auth_password_account is a concrete + auth_account. For every row in auth_password_account there is one row in + auth_account with the same public_id, scope_id, and auth_method_id. + + Both auth_password_conf and auth_password_credential are abstract. Each + password key derivation function will require a concrete auth_password_conf + and auth_password_credential table. + + An auth_method can have 0 or 1 auth_password_method. + An auth_account can have 0 or 1 auth_password_account. + + An auth_password_method belongs to 1 auth_method. + An auth_password_method can have 0 to many auth_password_accounts. + An auth_password_method can have 0 to many auth_password_confs. + + An auth_password_account belongs to 1 auth_account. + An auth_password_account belongs to 1 auth_password_method. + An auth_password_account can have 0 or 1 auth_password_credential. + + An auth_password_conf belongs to 1 auth_password_method. + An auth_password_conf can have 0 to many auth_password_credentials. + + An auth_password_credential belongs to 1 auth_password_account. + An auth_password_credential belongs to 1 auth_password_conf. + +*/ + + create table auth_password_method ( + public_id wt_public_id primary key, + scope_id wt_scope_id not null, + name text, + description text, + create_time wt_timestamp, + update_time wt_timestamp, + min_user_name_length int not null default 5, + min_password_length int not null default 8, + foreign key (scope_id, public_id) + references auth_method (scope_id, public_id) + on delete cascade + on update cascade, + unique(scope_id, name), + unique(scope_id, public_id) + ); + + create trigger + insert_auth_method_subtype + before insert on auth_password_method + for each row execute procedure insert_auth_method_subtype(); + + create table auth_password_account ( + public_id wt_public_id primary key, + auth_method_id wt_public_id not null, + -- NOTE(mgaffney): The scope_id type is not wt_scope_id because the domain + -- check is executed before the insert trigger which retrieves the scope_id + -- causing an insert to fail. + scope_id text not null, + name text, + description text, + create_time wt_timestamp, + update_time wt_timestamp, + user_name text not null + check( + lower(trim(user_name)) = user_name + and + length(user_name) > 0 + ), + foreign key (scope_id, auth_method_id) + references auth_password_method (scope_id, public_id) + on delete cascade + on update cascade, + foreign key (scope_id, auth_method_id, public_id) + references auth_account (scope_id, auth_method_id, public_id) + on delete cascade + on update cascade, + unique(auth_method_id, name), + unique(auth_method_id, user_name), + unique(auth_method_id, public_id) + ); + + create trigger + insert_auth_account_subtype + before insert on auth_password_account + for each row execute procedure insert_auth_account_subtype(); + + create table auth_password_conf ( + public_id wt_public_id primary key, + password_method_id wt_public_id not null + references auth_password_method (public_id) + on delete cascade + on update cascade + deferrable initially deferred, + unique(password_method_id, public_id) + ); + + -- insert_auth_password_conf_subtype() is a trigger function for concrete + -- implementations of auth_password_conf + create or replace function + insert_auth_password_conf_subtype() + returns trigger + as $$ + begin + insert into auth_password_conf + (public_id, password_method_id) + values + (new.public_id, new.password_method_id); + return new; + end; + $$ language plpgsql; + + create table auth_password_credential ( + public_id wt_public_id primary key, + password_account_id wt_public_id not null unique, + password_conf_id wt_public_id not null, + password_method_id wt_public_id not null, + foreign key (password_method_id, password_conf_id) + references auth_password_conf (password_method_id, public_id) + on delete cascade + on update cascade, + foreign key (password_method_id, password_account_id) + references auth_password_account (auth_method_id, public_id) + on delete cascade + on update cascade, + unique(password_method_id, password_conf_id, password_account_id) + ); + + -- insert_auth_password_credential_subtype() is a trigger function for concrete + -- implementations of auth_password_credential + create or replace function + insert_auth_password_credential_subtype() + returns trigger + as $$ + begin + + select auth_password_account.auth_method_id + into new.password_method_id + from auth_password_account + where auth_password_account.public_id = new.password_account_id; + + insert into auth_password_credential + (public_id, password_account_id, password_conf_id, password_method_id) + values + (new.public_id, new.password_account_id, new.password_conf_id, new.password_method_id); + return new; + end; + $$ language plpgsql; + + -- + -- triggers for time columns + --- + + create trigger + update_time_column + before + update on auth_password_method + for each row execute procedure update_time_column(); + + create trigger + immutable_create_time + before + update on auth_password_method + for each row execute procedure immutable_create_time_func(); + + create trigger + default_create_time_column + before + insert on auth_password_method + for each row execute procedure default_create_time(); + + create trigger + update_time_column + before + update on auth_password_account + for each row execute procedure update_time_column(); + + create trigger + immutable_create_time + before + update on auth_password_account + for each row execute procedure immutable_create_time_func(); + + create trigger + default_create_time_column + before + insert on auth_password_account + for each row execute procedure default_create_time(); + + insert into oplog_ticket (name, version) + values + ('auth_password_method', 1), + ('auth_password_account', 1); + +commit; diff --git a/internal/proto/local/controller/storage/auth/password/store/v1/password.proto b/internal/proto/local/controller/storage/auth/password/store/v1/password.proto new file mode 100644 index 000000000..1659c296e --- /dev/null +++ b/internal/proto/local/controller/storage/auth/password/store/v1/password.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +// Package store provides protobufs for storing types in the password package. +package controller.storage.auth.password.store.v1; +option go_package = "github.com/hashicorp/watchtower/internal/auth/password/store;store"; + +import "controller/storage/timestamp/v1/timestamp.proto"; + +message AuthMethod { + // @inject_tag: `gorm:"primary_key"` + string public_id = 1; + + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 2; + + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 3; + + // name is optional. If set, it must be unique within scope_id. + // @inject_tag: `gorm:"default:null"` + string name = 4; + + // description is optional. + // @inject_tag: `gorm:"default:null"` + string description = 5; + + // The scope_id of the owning scope. Must be set. + // @inject_tag: `gorm:"not_null"` + string scope_id = 6; + + // @inject_tag: `gorm:"default:null"` + uint32 min_user_name_length = 8; + + // @inject_tag: `gorm:"default:null"` + uint32 min_password_length = 9; +} + +message Account { + // @inject_tag: `gorm:"primary_key"` + string public_id = 1; + + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 2; + + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 3; + + // name is optional. If set, it must be unique within scope_id. + // @inject_tag: `gorm:"default:null"` + string name = 4; + + // description is optional. + // @inject_tag: `gorm:"default:null"` + string description = 5; + + // The scope_id of the owning scope. Must be set. + // @inject_tag: `gorm:"not_null"` + string scope_id = 6; + + // @inject_tag: `gorm:"not_null"` + string auth_method_id = 7; + + // @inject_tag: `gorm:"not_null"` + string user_name = 8; +}