diff --git a/Makefile b/Makefile index 0b4de93ab..8fd7edc54 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,7 @@ protobuild: @protoc --proto_path=internal/proto/local --proto_path=internal/proto/third_party --swagger_out=logtostderr=true,disable_default_errors=true,include_package_in_tags=true,fqn_for_swagger_name=true,allow_merge,merge_file_name=controller:internal/gen/. internal/proto/local/controller/api/services/v1/*.proto @protoc-go-inject-tag -input=./internal/oplog/store/oplog.pb.go @protoc-go-inject-tag -input=./internal/oplog/oplog_test/oplog_test.pb.go + @protoc-go-inject-tag -input=./internal/iam/store/user.pb.go @protoc-go-inject-tag -input=./internal/iam/store/scope.pb.go @protoc-go-inject-tag -input=./internal/db/db_test/db_test.pb.go @protoc-go-inject-tag -input=./internal/host/static/store/static.pb.go diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index f7e88d45e..82ae4ed57 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -356,6 +356,13 @@ BEGIN; drop table if exists iam_scope CASCADE; drop trigger if exists iam_scope_insert; drop function if exists iam_sub_scopes_func; +drop table if exists iam_user cascade; +drop function iam_sub_scopes_func cascade; + +-- TODO: we cannot "drop function update_time_column cascade" since it will +-- affect more that this migration. It would delete any table in any migration +-- that depends on it. We need to move this drop function up to the 01 +-- migration, so it's at the top level. Need to discuss with mgaffney drop trigger if exists update_iam_scope_update_time on iam_scope; drop trigger if exists update_iam_scope_create_time on iam_scope; @@ -508,6 +515,36 @@ before update on iam_scope for each row execute procedure iam_sub_names(); + +create table iam_user ( + public_id wt_public_id not null primary key, + create_time wt_timestamp, + update_time wt_timestamp, + name text, + description text, + scope_id wt_public_id not null references iam_scope_organization(scope_id) on delete cascade on update cascade, + unique(name, scope_id), + disabled boolean not null default false + ); + +create trigger + update_time_column +before update on iam_user + for each row execute procedure update_time_column(); + +create trigger + immutable_create_time +before +update on iam_user + for each row execute procedure immutable_create_time_func(); + +create trigger + default_create_time_column +before +insert on iam_user + for each row execute procedure default_create_time(); + + commit; `), diff --git a/internal/db/migrations/postgres/04_iam.down.sql b/internal/db/migrations/postgres/04_iam.down.sql index 74363b865..7fa01366f 100644 --- a/internal/db/migrations/postgres/04_iam.down.sql +++ b/internal/db/migrations/postgres/04_iam.down.sql @@ -3,6 +3,13 @@ BEGIN; drop table if exists iam_scope CASCADE; drop trigger if exists iam_scope_insert; drop function if exists iam_sub_scopes_func; +drop table if exists iam_user cascade; +drop function iam_sub_scopes_func cascade; + +-- TODO: we cannot "drop function update_time_column cascade" since it will +-- affect more that this migration. It would delete any table in any migration +-- that depends on it. We need to move this drop function up to the 01 +-- migration, so it's at the top level. Need to discuss with mgaffney drop trigger if exists update_iam_scope_update_time on iam_scope; drop trigger if exists update_iam_scope_create_time on iam_scope; diff --git a/internal/db/migrations/postgres/04_iam.up.sql b/internal/db/migrations/postgres/04_iam.up.sql index ad0749985..1112a817a 100644 --- a/internal/db/migrations/postgres/04_iam.up.sql +++ b/internal/db/migrations/postgres/04_iam.up.sql @@ -140,4 +140,34 @@ before update on iam_scope for each row execute procedure iam_sub_names(); + +create table iam_user ( + public_id wt_public_id not null primary key, + create_time wt_timestamp, + update_time wt_timestamp, + name text, + description text, + scope_id wt_public_id not null references iam_scope_organization(scope_id) on delete cascade on update cascade, + unique(name, scope_id), + disabled boolean not null default false + ); + +create trigger + update_time_column +before update on iam_user + for each row execute procedure update_time_column(); + +create trigger + immutable_create_time +before +update on iam_user + for each row execute procedure immutable_create_time_func(); + +create trigger + default_create_time_column +before +insert on iam_user + for each row execute procedure default_create_time(); + + commit; diff --git a/internal/db/read_writer.go b/internal/db/read_writer.go index df26830cf..c5bd7df04 100644 --- a/internal/db/read_writer.go +++ b/internal/db/read_writer.go @@ -277,6 +277,9 @@ func (rw *Db) Update(ctx context.Context, i interface{}, fieldMaskPaths []string underlying := rw.underlying.Model(i).Updates(updateFields) if underlying.Error != nil { + if err == gorm.ErrRecordNotFound { + return NoRowsAffected, fmt.Errorf("update: failed %w", ErrRecordNotFound) + } return NoRowsAffected, fmt.Errorf("update: failed %w", underlying.Error) } rowsUpdated := int(underlying.RowsAffected) diff --git a/internal/iam/repository_user.go b/internal/iam/repository_user.go new file mode 100644 index 000000000..b6edc9490 --- /dev/null +++ b/internal/iam/repository_user.go @@ -0,0 +1,107 @@ +package iam + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/watchtower/internal/db" +) + +// CreateUser will create a user in the repository and return the written user +func (r *Repository) CreateUser(ctx context.Context, user *User, opt ...Option) (*User, error) { + if user == nil { + return nil, fmt.Errorf("create user: missing user %w", db.ErrNilParameter) + } + if user.PublicId != "" { + return nil, fmt.Errorf("create user: public id is not empty %w", db.ErrInvalidParameter) + } + id, err := newUserId() + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + u := user.Clone() + u.(*User).PublicId = id + resource, err := r.create(ctx, u.(*User)) + if err != nil { + if db.IsUniqueError(err) { + return nil, fmt.Errorf("create user: user %s already exists in organization %s", user.Name, user.ScopeId) + } + return nil, fmt.Errorf("create user: %w for %s", err, u.(*User).PublicId) + } + return resource.(*User), err +} + +// UpdateUser will update a user in the repository and return the written user. +// fieldMaskPaths provides field_mask.proto paths for fields that should +// be updated. Fields will be set to NULL if the field is a zero value and +// included in fieldMask. Name and Description are the only updatable fields, +// If no updatable fields are included in the fieldMaskPaths, then an error is returned. +func (r *Repository) UpdateUser(ctx context.Context, user *User, fieldMaskPaths []string, opt ...Option) (*User, int, error) { + if user == nil { + return nil, db.NoRowsAffected, fmt.Errorf("update user: missing user %w", db.ErrNilParameter) + } + if user.PublicId == "" { + return nil, db.NoRowsAffected, fmt.Errorf("update user: missing user public id %w", db.ErrInvalidParameter) + } + for _, f := range fieldMaskPaths { + switch { + case strings.EqualFold("name", f): + case strings.EqualFold("description", f): + default: + return nil, db.NoRowsAffected, fmt.Errorf("update user: field: %s: %w", f, db.ErrInvalidFieldMask) + } + } + var dbMask, nullFields []string + dbMask, nullFields = buildUpdatePaths( + map[string]interface{}{ + "name": user.Name, + "description": user.Description, + }, + fieldMaskPaths, + ) + if len(dbMask) == 0 && len(nullFields) == 0 { + return nil, db.NoRowsAffected, fmt.Errorf("update user: %w", db.ErrEmptyFieldMask) + } + u := user.Clone() + resource, rowsUpdated, err := r.update(ctx, u.(*User), dbMask, nullFields) + if err != nil { + if db.IsUniqueError(err) { + return nil, db.NoRowsAffected, fmt.Errorf("update user: user %s already exists in organization %s", user.Name, user.ScopeId) + } + return nil, db.NoRowsAffected, fmt.Errorf("update user: %w for %s", err, user.PublicId) + } + return resource.(*User), rowsUpdated, err +} + +// LookupUser will look up a user in the repository. If the user is not +// found, it will return nil, nil. +func (r *Repository) LookupUser(ctx context.Context, withPublicId string, opt ...Option) (*User, error) { + if withPublicId == "" { + return nil, fmt.Errorf("lookup user: missing public id %w", db.ErrNilParameter) + } + + user := allocUser() + user.PublicId = withPublicId + if err := r.reader.LookupByPublicId(ctx, &user); err != nil { + return nil, fmt.Errorf("lookup user: failed %w for %s", err, withPublicId) + } + return &user, nil +} + +// DeleteUser will delete a user from the repository +func (r *Repository) DeleteUser(ctx context.Context, withPublicId string, opt ...Option) (int, error) { + if withPublicId == "" { + return db.NoRowsAffected, fmt.Errorf("delete user: missing public id %w", db.ErrNilParameter) + } + user := allocUser() + user.PublicId = withPublicId + if err := r.reader.LookupByPublicId(ctx, &user); err != nil { + return db.NoRowsAffected, fmt.Errorf("delete user: failed %w for %s", err, withPublicId) + } + rowsDeleted, err := r.delete(ctx, &user) + if err != nil { + return db.NoRowsAffected, fmt.Errorf("delete user: failed %w for %s", err, withPublicId) + } + return rowsDeleted, nil +} diff --git a/internal/iam/repository_user_test.go b/internal/iam/repository_user_test.go new file mode 100644 index 000000000..432dbe129 --- /dev/null +++ b/internal/iam/repository_user_test.go @@ -0,0 +1,457 @@ +package iam + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/watchtower/internal/db" + dbassert "github.com/hashicorp/watchtower/internal/db/assert" + "github.com/hashicorp/watchtower/internal/oplog" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" +) + +func TestRepository_CreateUser(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + a := assert.New(t) + defer conn.Close() + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + a.NoError(err) + id, err := uuid.GenerateUUID() + a.NoError(err) + + org, _ := TestScopes(t, conn) + + type args struct { + user *User + opt []Option + } + tests := []struct { + name string + args args + wantDup bool + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + args: args{ + user: func() *User { + u, err := NewUser(org.PublicId, WithName("valid"+id), WithDescription(id)) + assert.NoError(t, err) + return u + }(), + }, + wantErr: false, + }, + { + name: "bad-scope-id", + args: args{ + user: func() *User { + u, err := NewUser(id) + assert.NoError(t, err) + return u + }(), + }, + wantErr: true, + wantErrMsg: "create user: error getting metadata for create: unable to get scope for standard metadata: record not found for", + }, + { + name: "dup-name", + args: args{ + user: func() *User { + u, err := NewUser(org.PublicId, WithName("dup-name"+id)) + assert.NoError(t, err) + return u + }(), + }, + wantDup: true, + wantErr: true, + wantErrMsg: "create user: user %s already exists in organization %s", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + if tt.wantDup { + dup, err := repo.CreateUser(context.Background(), tt.args.user, tt.args.opt...) + assert.NoError(err) + assert.NotNil(dup) + } + u, err := repo.CreateUser(context.Background(), tt.args.user, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + assert.Nil(u) + switch tt.name { + case "dup-name": + assert.Equal(fmt.Sprintf(tt.wantErrMsg, "dup-name"+id, org.PublicId), err.Error()) + default: + assert.True(strings.HasPrefix(err.Error(), tt.wantErrMsg)) + } + return + } + assert.NoError(err) + assert.NotNil(u.CreateTime) + assert.NotNil(u.UpdateTime) + + foundUser, err := repo.LookupUser(context.Background(), u.PublicId) + assert.NoError(err) + assert.True(proto.Equal(foundUser, u)) + + err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second)) + assert.NoError(err) + }) + } +} + +func TestRepository_UpdateUser(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + a := assert.New(t) + defer conn.Close() + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + a.NoError(err) + id, err := uuid.GenerateUUID() + a.NoError(err) + + org, proj := TestScopes(t, conn) + pubId := func(s string) *string { return &s } + + type args struct { + name string + description string + fieldMaskPaths []string + opt []Option + ScopeId string + PublicId *string + } + tests := []struct { + name string + newUserOpts []Option + args args + wantRowsUpdate int + wantErr bool + wantErrMsg string + wantIsErr error + wantDup bool + }{ + { + name: "valid", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "valid-no-op", + args: args{ + name: "valid-no-op" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + newUserOpts: []Option{WithName("valid-no-op" + id)}, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "not-found", + args: args{ + name: "not-found" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + PublicId: func() *string { s := "1"; return &s }(), + }, + wantErr: true, + wantRowsUpdate: 0, + wantErrMsg: "update user: update: lookup error lookup after write: failed record not found for 1", + wantIsErr: db.ErrRecordNotFound, + }, + { + name: "null-name", + args: args{ + name: "", + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + newUserOpts: []Option{WithName("null-name" + id)}, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "null-description", + args: args{ + name: "", + fieldMaskPaths: []string{"Description"}, + ScopeId: org.PublicId, + }, + newUserOpts: []Option{WithDescription("null-description" + id)}, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "empty-field-mask", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{}, + ScopeId: org.PublicId, + }, + wantErr: true, + wantRowsUpdate: 0, + wantErrMsg: "update user: empty field mask", + }, + { + name: "nil-fieldmask", + args: args{ + name: "valid" + id, + fieldMaskPaths: nil, + ScopeId: org.PublicId, + }, + wantErr: true, + wantRowsUpdate: 0, + wantErrMsg: "update user: empty field mask", + }, + { + name: "read-only-fields", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{"CreateTime"}, + ScopeId: org.PublicId, + }, + wantErr: true, + wantRowsUpdate: 0, + wantErrMsg: "update user: field: CreateTime: invalid field mask", + }, + { + name: "unknown-fields", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{"Alice"}, + ScopeId: org.PublicId, + }, + wantErr: true, + wantRowsUpdate: 0, + wantErrMsg: "update user: field: Alice: invalid field mask", + }, + { + name: "no-public-id", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + PublicId: pubId(""), + }, + wantErr: true, + wantErrMsg: "update user: missing user public id invalid parameter", + wantRowsUpdate: 0, + }, + { + name: "proj-scope-id", + args: args{ + name: "proj-scope-id" + id, + fieldMaskPaths: []string{"ScopeId"}, + ScopeId: proj.PublicId, + }, + wantErr: true, + wantErrMsg: "update user: field: ScopeId: invalid field mask", + }, + { + name: "empty-scope-id-with-name-mask", + args: args{ + name: "empty-scope-id" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: "", + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "dup-name", + args: args{ + name: "dup-name" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + wantErr: true, + wantDup: true, + wantErrMsg: `update user: user %s already exists in organization %s`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + if tt.wantDup { + u := TestUser(t, conn, org.PublicId, tt.newUserOpts...) + u.Name = tt.args.name + _, _, err := repo.UpdateUser(context.Background(), u, tt.args.fieldMaskPaths, tt.args.opt...) + assert.NoError(err) + } + + u := TestUser(t, conn, org.PublicId, tt.newUserOpts...) + + updateUser := allocUser() + updateUser.PublicId = u.PublicId + if tt.args.PublicId != nil { + updateUser.PublicId = *tt.args.PublicId + } + updateUser.ScopeId = tt.args.ScopeId + updateUser.Name = tt.args.name + updateUser.Description = tt.args.description + + userAfterUpdate, updatedRows, err := repo.UpdateUser(context.Background(), &updateUser, tt.args.fieldMaskPaths, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + if tt.wantIsErr != nil { + assert.True(errors.Is(err, db.ErrRecordNotFound)) + } + assert.Nil(userAfterUpdate) + assert.Equal(0, updatedRows) + switch tt.name { + case "dup-name": + assert.Equal(fmt.Sprintf(tt.wantErrMsg, "dup-name"+id, org.PublicId), err.Error()) + default: + assert.Equal(tt.wantErrMsg, err.Error()) + } + err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.Equal("record not found", err.Error()) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsUpdate, updatedRows) + assert.NotEqual(u.UpdateTime, userAfterUpdate.UpdateTime) + foundUser, err := repo.LookupUser(context.Background(), u.PublicId) + assert.NoError(err) + assert.True(proto.Equal(userAfterUpdate, foundUser)) + + conn.LogMode(true) + dbassert := dbassert.New(t, rw) + if tt.args.name == "" { + dbassert.IsNull(foundUser, "name") + } + if tt.args.description == "" { + dbassert.IsNull(foundUser, "description") + } + conn.LogMode(false) + + err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) + assert.NoError(err) + }) + } +} + +func TestRepository_DeleteUser(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + a := assert.New(t) + defer conn.Close() + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + a.NoError(err) + org, _ := TestScopes(t, conn) + + type args struct { + user *User + opt []Option + } + tests := []struct { + name string + args args + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + args: args{ + user: TestUser(t, conn, org.PublicId), + }, + wantRowsDeleted: 1, + wantErr: false, + }, + { + name: "no-public-id", + args: args{ + user: func() *User { + u := allocUser() + return &u + }(), + }, + wantRowsDeleted: 0, + wantErr: true, + wantErrMsg: "delete user: missing public id nil parameter", + }, + { + name: "not-found", + args: args{ + user: func() *User { + u, err := NewUser(org.PublicId) + a.NoError(err) + id, err := newUserId() + a.NoError(err) + u.PublicId = id + return u + }(), + }, + wantRowsDeleted: 1, + wantErr: true, + wantErrMsg: "delete user: failed record not found for", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + deletedRows, err := repo.DeleteUser(context.Background(), tt.args.user.PublicId, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + assert.Equal(0, deletedRows) + assert.True(strings.HasPrefix(err.Error(), tt.wantErrMsg)) + err = db.TestVerifyOplog(t, rw, tt.args.user.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.Error(err) + assert.Equal("record not found", err.Error()) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundUser, err := repo.LookupUser(context.Background(), tt.args.user.PublicId) + assert.Error(err) + assert.Nil(foundUser) + assert.True(errors.Is(err, db.ErrRecordNotFound)) + + err = db.TestVerifyOplog(t, rw, tt.args.user.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) + assert.NoError(err) + }) + } +} diff --git a/internal/iam/resource.go b/internal/iam/resource.go index f23ed0244..564dce7a7 100644 --- a/internal/iam/resource.go +++ b/internal/iam/resource.go @@ -79,6 +79,7 @@ type Clonable interface { type ResourceWithScope interface { GetPublicId() string GetScopeId() string + validScopeTypes() []ScopeType } // LookupScope looks up the resource's scope @@ -89,12 +90,56 @@ func LookupScope(ctx context.Context, reader db.Reader, resource ResourceWithSco if resource == nil { return nil, errors.New("error resource is nil for LookupScope") } + if resource.GetPublicId() == "" { + return nil, errors.New("error resource has an unset public id") + } if resource.GetScopeId() == "" { - return nil, errors.New("error scope is unset for LookupScope") + // try to retrieve it from db with it's scope id + if err := reader.LookupByPublicId(ctx, resource); err != nil { + return nil, fmt.Errorf("unable to get resource by public id: %w", err) + } + // if it's still not set after getting it from the db... + if resource.GetScopeId() == "" { + return nil, errors.New("error scope is unset for LookupScope") + } } var p Scope if err := reader.LookupWhere(ctx, &p, "public_id = ?", resource.GetScopeId()); err != nil { - return nil, fmt.Errorf("error getting scope for LookupScope: %w", err) + return nil, err } return &p, nil } + +// validateScopeForWrite will validate that the scope is okay for db write operations +func validateScopeForWrite(ctx context.Context, r db.Reader, resource ResourceWithScope, opType db.OpType, opt ...db.Option) error { + opts := db.GetOpts(opt...) + + if opType == db.CreateOp { + if resource.GetScopeId() == "" { + return errors.New("error scope id not set for user write") + } + ps, err := LookupScope(ctx, r, resource) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return errors.New("scope is not found") + } + return err + } + validScopeType := false + for _, t := range resource.validScopeTypes() { + if ps.Type == t.String() { + validScopeType = true + } + } + if !validScopeType { + return fmt.Errorf("%s not a valid scope type for this resource", ps.Type) + } + + } + if opType == db.UpdateOp && resource.GetScopeId() != "" { + if contains(opts.WithFieldMaskPaths, "ScopeId") || contains(opts.WithNullPaths, "ScopeId") { + return errors.New("not allowed to change a resource's scope") + } + } + return nil +} diff --git a/internal/iam/resource_test.go b/internal/iam/resource_test.go new file mode 100644 index 000000000..643ff0f04 --- /dev/null +++ b/internal/iam/resource_test.go @@ -0,0 +1,56 @@ +package iam + +import ( + "context" + "testing" + + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" +) + +func Test_LookupScope(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + assert := assert.New(t) + defer conn.Close() + t.Run("valid-scope", func(t *testing.T) { + w := db.New(conn) + org, _ := TestScopes(t, conn) + user := TestUser(t, conn, org.PublicId) + + foundScope, err := LookupScope(context.Background(), w, user) + assert.NoError(err) + assert.Equal(foundScope.PublicId, user.ScopeId) + + user2 := allocUser() + user2.PublicId = user.PublicId + foundScope, err = LookupScope(context.Background(), w, user) + assert.NoError(err) + assert.True(proto.Equal(foundScope, org)) + }) + t.Run("bad-scope", func(t *testing.T) { + w := db.New(conn) + org, _ := TestScopes(t, conn) + user := TestUser(t, conn, org.PublicId) + + s, err := LookupScope(context.Background(), nil, user) + assert.Nil(s) + assert.Equal("error reader is nil for LookupScope", err.Error()) + + s, err = LookupScope(context.Background(), w, nil) + assert.Nil(s) + assert.Equal("error resource is nil for LookupScope", err.Error()) + + user2 := allocUser() + s, err = LookupScope(context.Background(), w, &user2) + assert.Nil(s) + assert.Equal("error resource has an unset public id", err.Error()) + + }) +} diff --git a/internal/iam/store/user.pb.go b/internal/iam/store/user.pb.go new file mode 100644 index 000000000..155214232 --- /dev/null +++ b/internal/iam/store/user.pb.go @@ -0,0 +1,243 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.11.4 +// source: controller/storage/iam/store/v1/user.proto + +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 User struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // public_id is used to access the User via an API + // @inject_tag: gorm:"primary_key" + PublicId string `protobuf:"bytes,1,opt,name=public_id,json=publicId,proto3" json:"public_id,omitempty" gorm:"primary_key"` + // create_time from the RDBMS + // @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"` + // update_time from the RDBMS + // @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 the optional friendly name used to + // access the User via an API + // @inject_tag: `gorm:"default:null"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty" gorm:"default:null"` + // description of the user + // @inject_tag: `gorm:"default:null"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty" gorm:"default:null"` + // scope id for the user + // @inject_tag: `gorm:"default:null"` + ScopeId string `protobuf:"bytes,6,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty" gorm:"default:null"` + // disabled is by default false and allows a User to be marked disabled. + // @inject_tag: `gorm:"default:null"` + Disabled bool `protobuf:"varint,7,opt,name=disabled,proto3" json:"disabled,omitempty" gorm:"default:null"` +} + +func (x *User) Reset() { + *x = User{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_iam_store_v1_user_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_iam_store_v1_user_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 User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_controller_storage_iam_store_v1_user_proto_rawDescGZIP(), []int{0} +} + +func (x *User) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *User) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *User) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *User) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *User) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *User) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *User) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + +var File_controller_storage_iam_store_v1_user_proto protoreflect.FileDescriptor + +var file_controller_storage_iam_store_v1_user_proto_rawDesc = []byte{ + 0x0a, 0x2a, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x2f, 0x69, 0x61, 0x6d, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x76, + 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1f, 0x63, 0x6f, + 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, + 0x2e, 0x69, 0x61, 0x6d, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x2b, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x2f, 0x69, 0x61, 0x6d, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, + 0x63, 0x6f, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 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, 0xaa, 0x02, 0x0a, 0x04, + 0x55, 0x73, 0x65, 0x72, 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, 0x1a, 0x0a, 0x08, + 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, + 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, 0x3a, 0x5a, 0x38, 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, 0x69, 0x61, 0x6d, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_controller_storage_iam_store_v1_user_proto_rawDescOnce sync.Once + file_controller_storage_iam_store_v1_user_proto_rawDescData = file_controller_storage_iam_store_v1_user_proto_rawDesc +) + +func file_controller_storage_iam_store_v1_user_proto_rawDescGZIP() []byte { + file_controller_storage_iam_store_v1_user_proto_rawDescOnce.Do(func() { + file_controller_storage_iam_store_v1_user_proto_rawDescData = protoimpl.X.CompressGZIP(file_controller_storage_iam_store_v1_user_proto_rawDescData) + }) + return file_controller_storage_iam_store_v1_user_proto_rawDescData +} + +var file_controller_storage_iam_store_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_controller_storage_iam_store_v1_user_proto_goTypes = []interface{}{ + (*User)(nil), // 0: controller.storage.iam.store.v1.User + (*timestamp.Timestamp)(nil), // 1: controller.storage.timestamp.v1.Timestamp +} +var file_controller_storage_iam_store_v1_user_proto_depIdxs = []int32{ + 1, // 0: controller.storage.iam.store.v1.User.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 1, // 1: controller.storage.iam.store.v1.User.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_controller_storage_iam_store_v1_user_proto_init() } +func file_controller_storage_iam_store_v1_user_proto_init() { + if File_controller_storage_iam_store_v1_user_proto != nil { + return + } + file_controller_storage_iam_store_v1_scope_proto_init() + if !protoimpl.UnsafeEnabled { + file_controller_storage_iam_store_v1_user_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*User); 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_iam_store_v1_user_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_controller_storage_iam_store_v1_user_proto_goTypes, + DependencyIndexes: file_controller_storage_iam_store_v1_user_proto_depIdxs, + MessageInfos: file_controller_storage_iam_store_v1_user_proto_msgTypes, + }.Build() + File_controller_storage_iam_store_v1_user_proto = out.File + file_controller_storage_iam_store_v1_user_proto_rawDesc = nil + file_controller_storage_iam_store_v1_user_proto_goTypes = nil + file_controller_storage_iam_store_v1_user_proto_depIdxs = nil +} diff --git a/internal/iam/testing.go b/internal/iam/testing.go index e2c9b4550..a6a750507 100644 --- a/internal/iam/testing.go +++ b/internal/iam/testing.go @@ -66,3 +66,20 @@ func testPublicId(t *testing.T, prefix string) string { assert.NoError(err) return publicId } + +// TestUser creates a user suitable for testing. +func TestUser(t *testing.T, conn *gorm.DB, orgId string, opt ...Option) *User { + t.Helper() + assert := assert.New(t) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + + user, err := NewUser(orgId, opt...) + assert.NoError(err) + user, err = repo.CreateUser(context.Background(), user) + assert.NoError(err) + assert.NotEmpty(user.PublicId) + return user +} diff --git a/internal/iam/testing_test.go b/internal/iam/testing_test.go index 746180902..f6d53e0f7 100644 --- a/internal/iam/testing_test.go +++ b/internal/iam/testing_test.go @@ -61,3 +61,23 @@ func Test_TestScopes(t *testing.T) { assert.NotNil(prj) assert.NotEmpty(prj.GetPublicId()) } +func Test_TestUser(t *testing.T) { + t.Helper() + assert := assert.New(t) + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + defer conn.Close() + + org, _ := TestScopes(t, conn) + + assert.NotNil(org) + assert.NotEmpty(org.GetPublicId()) + + user := TestUser(t, conn, org.PublicId) + assert.NotNil(user) + assert.NotEmpty(user.PublicId) +} diff --git a/internal/iam/user.go b/internal/iam/user.go new file mode 100644 index 000000000..07a90bb70 --- /dev/null +++ b/internal/iam/user.go @@ -0,0 +1,107 @@ +package iam + +import ( + "context" + "fmt" + + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/iam/store" + "google.golang.org/protobuf/proto" +) + +// User defines watchtower users which are scoped to an Organization +type User struct { + *store.User + tableName string `gorm:"-"` +} + +// ensure that User implements the interfaces of: Resource, Clonable and db.VetForWriter +var _ Resource = (*User)(nil) +var _ Clonable = (*User)(nil) +var _ db.VetForWriter = (*User)(nil) + +// NewUser creates a new in memory user and allows options: +// WithName - to specify the user's friendly name and WithDescription - to +// specify a user description +func NewUser(organizationPublicId string, opt ...Option) (*User, error) { + opts := getOpts(opt...) + if organizationPublicId == "" { + return nil, fmt.Errorf("new user: missing organization id %w", db.ErrInvalidParameter) + } + u := &User{ + User: &store.User{ + Name: opts.withName, + Description: opts.withDescription, + ScopeId: organizationPublicId, + }, + } + return u, nil +} + +func allocUser() User { + return User{ + User: &store.User{}, + } +} + +// Clone creates a clone of the User +func (u *User) Clone() interface{} { + cp := proto.Clone(u.User) + return &User{ + User: cp.(*store.User), + } +} + +// VetForWrite implements db.VetForWrite() interface and validates the user +// before it's written. +func (u *User) VetForWrite(ctx context.Context, r db.Reader, opType db.OpType, opt ...db.Option) error { + if u.PublicId == "" { + return fmt.Errorf("user vet for write: missing public id: %w", db.ErrNilParameter) + } + if err := validateScopeForWrite(ctx, r, u, opType, opt...); err != nil { + return err + } + return nil +} + +func (u *User) validScopeTypes() []ScopeType { + return []ScopeType{OrganizationScope} +} + +// GetScope returns the scope for the User +func (u *User) GetScope(ctx context.Context, r db.Reader) (*Scope, error) { + return LookupScope(ctx, r, u) +} + +// ResourceType returns the type of the User +func (*User) ResourceType() ResourceType { return ResourceTypeUser } + +// Actions returns the available actions for Users +func (*User) Actions() map[string]Action { + return CrudActions() +} + +// TableName returns the tablename to override the default gorm table name +func (u *User) TableName() string { + if u.tableName != "" { + return u.tableName + } + return "iam_user" +} + +// SetTableName sets the tablename and satisfies the ReplayableMessage interface +func (u *User) SetTableName(n string) { + if n != "" { + u.tableName = n + } +} + +const UserPrefix = "u" + +func newUserId() (string, error) { + id, err := db.NewPublicId(UserPrefix) + if err != nil { + return "", fmt.Errorf("new user id: %w", err) + } + return id, nil +} diff --git a/internal/iam/user_test.go b/internal/iam/user_test.go new file mode 100644 index 000000000..7dad75b29 --- /dev/null +++ b/internal/iam/user_test.go @@ -0,0 +1,422 @@ +package iam + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" +) + +func TestNewUser(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + assert := assert.New(t) + defer conn.Close() + org, _ := TestScopes(t, conn) + + id, err := uuid.GenerateUUID() + assert.NoError(err) + + type args struct { + organizationPublicId string + opt []Option + } + tests := []struct { + name string + args args + wantErr bool + wantErrMsg string + wantName string + wantDescription string + }{ + { + name: "valid", + args: args{ + organizationPublicId: org.PublicId, + opt: []Option{WithName(id), WithDescription(id)}, + }, + wantErr: false, + wantName: id, + wantDescription: id, + }, + { + name: "valid-with-no-name", + args: args{ + organizationPublicId: org.PublicId, + }, + wantErr: false, + }, + { + name: "no-org", + args: args{ + opt: []Option{WithName(id)}, + }, + wantErr: true, + wantErrMsg: "new user: missing organization id invalid parameter", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewUser(tt.args.organizationPublicId, tt.args.opt...) + if tt.wantErr { + assert.Error(err) + assert.Equal(tt.wantErrMsg, err.Error()) + return + } + assert.NoError(err) + assert.Equal(tt.wantName, got.Name) + assert.Empty(got.PublicId) + }) + } +} + +func Test_UserCreate(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + assert := assert.New(t) + defer conn.Close() + org, _ := TestScopes(t, conn) + + id, err := uuid.GenerateUUID() + assert.NoError(err) + t.Run("valid-user", func(t *testing.T) { + w := db.New(conn) + user, err := NewUser(org.PublicId) + assert.NoError(err) + id, err := newUserId() + assert.NoError(err) + user.PublicId = id + err = w.Create(context.Background(), user) + assert.NoError(err) + assert.NotEmpty(user.PublicId) + + foundUser := allocUser() + foundUser.PublicId = user.PublicId + err = w.LookupByPublicId(context.Background(), &foundUser) + assert.NoError(err) + assert.Equal(user, &foundUser) + }) + t.Run("bad-orgid", func(t *testing.T) { + w := db.New(conn) + user, err := NewUser(id) + assert.NoError(err) + id, err := newUserId() + assert.NoError(err) + user.PublicId = id + err = w.Create(context.Background(), user) + assert.Error(err) + assert.Equal("create: vet for write failed scope is not found", err.Error()) + }) +} + +func Test_UserUpdate(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + a := assert.New(t) + defer conn.Close() + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + a.NoError(err) + id, err := uuid.GenerateUUID() + a.NoError(err) + + org, proj := TestScopes(t, conn) + + type args struct { + name string + description string + fieldMaskPaths []string + ScopeId string + } + tests := []struct { + name string + args args + wantRowsUpdate int + wantErr bool + wantErrMsg string + wantDup bool + }{ + { + name: "valid", + args: args{ + name: "valid" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "proj-scope-id", + args: args{ + name: "proj-scope-id" + id, + fieldMaskPaths: []string{"ScopeId"}, + ScopeId: proj.PublicId, + }, + wantErr: true, + wantErrMsg: "update: vet for write failed not allowed to change a resource's scope", + }, + { + name: "proj-scope-id-not-in-mask", + args: args{ + name: "proj-scope-id" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: proj.PublicId, + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "empty-scope-id", + args: args{ + name: "empty-scope-id" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: "", + }, + wantErr: false, + wantRowsUpdate: 1, + }, + { + name: "dup-name", + args: args{ + name: "dup-name" + id, + fieldMaskPaths: []string{"Name"}, + ScopeId: org.PublicId, + }, + wantErr: true, + wantDup: true, + wantErrMsg: `update: failed pq: duplicate key value violates unique constraint "iam_user_name_scope_id_key"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + if tt.wantDup { + u := TestUser(t, conn, org.PublicId) + u.Name = tt.args.name + _, err := rw.Update(context.Background(), u, tt.args.fieldMaskPaths, nil) + assert.NoError(err) + } + + u := TestUser(t, conn, org.PublicId) + + updateUser := allocUser() + updateUser.PublicId = u.PublicId + updateUser.ScopeId = tt.args.ScopeId + updateUser.Name = tt.args.name + updateUser.Description = tt.args.description + + updatedRows, err := rw.Update(context.Background(), &updateUser, tt.args.fieldMaskPaths, nil) + if tt.wantErr { + assert.Error(err) + assert.Equal(0, updatedRows) + assert.Equal(tt.wantErrMsg, err.Error()) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsUpdate, updatedRows) + assert.NotEqual(u.UpdateTime, updateUser.UpdateTime) + foundUser, err := repo.LookupUser(context.Background(), u.PublicId) + assert.NoError(err) + assert.True(proto.Equal(updateUser, foundUser)) + }) + } +} + +func Test_UserDelete(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + a := assert.New(t) + defer conn.Close() + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + a.NoError(err) + id, err := uuid.GenerateUUID() + a.NoError(err) + org, _ := TestScopes(t, conn) + + tests := []struct { + name string + user *User + wantRowsDeleted int + wantErr bool + wantErrMsg string + }{ + { + name: "valid", + user: TestUser(t, conn, org.PublicId), + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-id", + user: func() *User { u := allocUser(); u.PublicId = id; return &u }(), + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + deleteUser := allocUser() + deleteUser.PublicId = tt.user.GetPublicId() + deletedRows, err := rw.Delete(context.Background(), &deleteUser) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + assert.NoError(err) + assert.Equal(tt.wantRowsDeleted, deletedRows) + foundUser, err := repo.LookupUser(context.Background(), tt.user.GetPublicId()) + assert.True(errors.Is(err, db.ErrRecordNotFound)) + assert.Nil(foundUser) + }) + } +} + +func Test_UserGetScope(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + defer conn.Close() + assert := assert.New(t) + + org, _ := TestScopes(t, conn) + t.Run("valid-scope", func(t *testing.T) { + w := db.New(conn) + user, err := NewUser(org.PublicId) + assert.NoError(err) + id, err := newUserId() + assert.NoError(err) + user.PublicId = id + err = w.Create(context.Background(), user) + assert.NoError(err) + assert.NotEmpty(user.PublicId) + assert.Equal(user.ScopeId, org.PublicId) + + childScope, err := NewProject(org.PublicId) + assert.NoError(err) + assert.NotNil(childScope.Scope) + assert.Equal(childScope.GetParentId(), org.PublicId) + err = w.Create(context.Background(), childScope) + assert.NoError(err) + + userScope, err := user.GetScope(context.Background(), w) + assert.NoError(err) + assert.True(proto.Equal(org, userScope)) + }) + +} + +func TestUser_Clone(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + assert := assert.New(t) + defer conn.Close() + org, _ := TestScopes(t, conn) + + t.Run("valid", func(t *testing.T) { + w := db.New(conn) + user, err := NewUser(org.PublicId) + assert.NoError(err) + id, err := newUserId() + assert.NoError(err) + user.PublicId = id + err = w.Create(context.Background(), user) + assert.NoError(err) + + cp := user.Clone() + assert.True(proto.Equal(cp.(*User).User, user.User)) + }) + t.Run("not-equal-test", func(t *testing.T) { + w := db.New(conn) + + user, err := NewUser(org.PublicId) + assert.NoError(err) + id, err := newUserId() + assert.NoError(err) + user.PublicId = id + err = w.Create(context.Background(), user) + assert.NoError(err) + + user2, err := NewUser(org.PublicId) + assert.NoError(err) + id, err = newUserId() + assert.NoError(err) + user2.PublicId = id + err = w.Create(context.Background(), user2) + assert.NoError(err) + + cp := user.Clone() + assert.True(!proto.Equal(cp.(*User).User, user2.User)) + }) +} + +func TestUser_Actions(t *testing.T) { + assert := assert.New(t) + u := &User{} + a := u.Actions() + assert.Equal(a[ActionCreate.String()], ActionCreate) + assert.Equal(a[ActionUpdate.String()], ActionUpdate) + assert.Equal(a[ActionRead.String()], ActionRead) + assert.Equal(a[ActionDelete.String()], ActionDelete) + + if _, ok := a[ActionList.String()]; ok { + t.Errorf("users should not include %s as an action", ActionList.String()) + } +} + +func TestUser_ResourceType(t *testing.T) { + t.Parallel() + cleanup, conn, _ := db.TestSetup(t, "postgres") + defer func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }() + assert := assert.New(t) + defer conn.Close() + org, _ := TestScopes(t, conn) + + u, err := NewUser(org.PublicId) + assert.NoError(err) + ty := u.ResourceType() + assert.Equal(ty, ResourceTypeUser) +} diff --git a/internal/proto/local/controller/storage/iam/store/v1/user.proto b/internal/proto/local/controller/storage/iam/store/v1/user.proto new file mode 100644 index 000000000..88ca78f3d --- /dev/null +++ b/internal/proto/local/controller/storage/iam/store/v1/user.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package controller.storage.iam.store.v1; +option go_package = "github.com/hashicorp/watchtower/internal/iam/store;store"; + +import "controller/storage/iam/store/v1/scope.proto"; + +import "controller/storage/timestamp/v1/timestamp.proto"; + +message User { + // public_id is used to access the User via an API + // @inject_tag: gorm:"primary_key" + string public_id = 1; + + // create_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 2; + + // update_time from the RDBMS + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 3; + + // name is the optional friendly name used to + // access the User via an API + // @inject_tag: `gorm:"default:null"` + string name = 4; + + // description of the user + // @inject_tag: `gorm:"default:null"` + string description = 5; + + // scope id for the user + // @inject_tag: `gorm:"default:null"` + string scope_id = 6; + + // disabled is by default false and allows a User to be marked disabled. + // @inject_tag: `gorm:"default:null"` + bool disabled = 7; +} \ No newline at end of file