From f69d6b9952499e29f6b1b93469379032a0d93021 Mon Sep 17 00:00:00 2001 From: Todd Knight Date: Tue, 7 Jul 2020 07:44:49 -0700 Subject: [PATCH] Creating Auth Token Repo (#125) --- Makefile | 1 + internal/authtoken/authtoken.go | 68 +++ internal/authtoken/authtoken_test.go | 225 ++++++++ internal/authtoken/doc.go | 17 + internal/authtoken/gorm.go | 16 + internal/authtoken/options.go | 30 ++ internal/authtoken/options_test.go | 19 + internal/authtoken/repository.go | 311 +++++++++++ internal/authtoken/repository_test.go | 482 ++++++++++++++++++ internal/authtoken/store/authtoken.pb.go | 303 +++++++++++ internal/authtoken/testing.go | 76 +++ internal/db/migrations/postgres.gen.go | 151 ++++++ .../db/migrations/postgres/02_oplog.up.sql | 1 + .../postgres/11_auth_token.down.sql | 10 + .../migrations/postgres/11_auth_token.up.sql | 128 +++++ internal/db/testing.go | 11 + .../authtoken/store/v1/authtoken.proto | 56 ++ 17 files changed, 1905 insertions(+) create mode 100644 internal/authtoken/authtoken.go create mode 100644 internal/authtoken/authtoken_test.go create mode 100644 internal/authtoken/doc.go create mode 100644 internal/authtoken/gorm.go create mode 100644 internal/authtoken/options.go create mode 100644 internal/authtoken/options_test.go create mode 100644 internal/authtoken/repository.go create mode 100644 internal/authtoken/repository_test.go create mode 100644 internal/authtoken/store/authtoken.pb.go create mode 100644 internal/authtoken/testing.go create mode 100644 internal/db/migrations/postgres/11_auth_token.down.sql create mode 100644 internal/db/migrations/postgres/11_auth_token.up.sql create mode 100644 internal/proto/local/controller/storage/authtoken/store/v1/authtoken.proto diff --git a/Makefile b/Makefile index d9e68090d..9463831d5 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,7 @@ protobuild: @protoc-go-inject-tag -input=./internal/iam/store/group.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 + @protoc-go-inject-tag -input=./internal/authtoken/store/authtoken.pb.go @protoc-go-inject-tag -input=./internal/iam/store/auth_account.pb.go @rm -R ${TMP_DIR} diff --git a/internal/authtoken/authtoken.go b/internal/authtoken/authtoken.go new file mode 100644 index 000000000..f97d1ee77 --- /dev/null +++ b/internal/authtoken/authtoken.go @@ -0,0 +1,68 @@ +package authtoken + +import ( + "context" + "fmt" + + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/go-kms-wrapping/structwrapping" + "github.com/hashicorp/vault/sdk/helper/base62" + "github.com/hashicorp/watchtower/internal/authtoken/store" + "github.com/hashicorp/watchtower/internal/db" + "google.golang.org/protobuf/proto" +) + +// A AuthToken contains auth tokens. It is owned by a scope. +type AuthToken struct { + *store.AuthToken + tableName string `gorm:"-"` +} + +func (s *AuthToken) clone() *AuthToken { + cp := proto.Clone(s.AuthToken) + return &AuthToken{ + AuthToken: cp.(*store.AuthToken), + } +} + +// encrypt the entry's data using the provided cipher (wrapping.Wrapper) +func (s *AuthToken) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { + // structwrapping doesn't support embedding, so we'll pass in the store.Entry directly + if err := structwrapping.WrapStruct(ctx, cipher, s.AuthToken, nil); err != nil { + return fmt.Errorf("error encrypting auth token: %w", err) + } + return nil +} + +// decrypt will decrypt the auth token's value using the provided cipher (wrapping.Wrapper) +func (s *AuthToken) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { + // structwrapping doesn't support embedding, so we'll pass in the store.Entry directly + if err := structwrapping.UnwrapStruct(ctx, cipher, s.AuthToken, nil); err != nil { + return fmt.Errorf("error decrypting auth token: %w", err) + } + return nil +} + +const ( + AuthTokenPrefix = "t" + // The version prefix is used to differentiate token versions just for future proofing. + TokenValueVersionPrefix = "0" + tokenLength = 24 +) + +func newAuthTokenId() (string, error) { + id, err := db.NewPublicId(AuthTokenPrefix) + if err != nil { + return "", fmt.Errorf("new auth token id: %w", err) + } + return id, err +} + +// newAuthToken generates a token with a version prefix. +func newAuthToken() (string, error) { + token, err := base62.Random(tokenLength) + if err != nil { + return "", fmt.Errorf("unable to generate auth token: %w", err) + } + return fmt.Sprintf("%s%s", TokenValueVersionPrefix, token), nil +} diff --git a/internal/authtoken/authtoken_test.go b/internal/authtoken/authtoken_test.go new file mode 100644 index 000000000..bb2cd23b4 --- /dev/null +++ b/internal/authtoken/authtoken_test.go @@ -0,0 +1,225 @@ +package authtoken + +// This file contains tests for methods defined in authtoken.go as well as tests which exercise the db +// functionality directly without going through the respository. Repository centric tests should be +// placed in repository_test.go + +import ( + "context" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/hashicorp/watchtower/internal/authtoken/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/db/timestamp" + "github.com/hashicorp/watchtower/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestAuthToken_DbUpdate(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + org, _ := iam.TestScopes(t, conn) + u := iam.TestUser(t, conn, org.GetPublicId()) + amId := setupAuthMethod(t, conn, org.GetPublicId()) + acct := setupAuthAccount(t, conn, org.GetPublicId(), amId, u.GetPublicId()) + + newAuthTokId, err := newAuthTokenId() + require.NoError(t, err) + + type args struct { + fieldMask []string + nullMask []string + authTok *store.AuthToken + } + future, err := ptypes.TimestampProto(time.Now().Add(time.Hour)) + require.NoError(t, err) + + var tests = []struct { + name string + args args + want *AuthToken + cnt int + wantErr bool + }{ + { + name: "immutable-authacctid", + args: args{ + fieldMask: []string{"AuthAccountId"}, + authTok: &store.AuthToken{AuthAccountId: acct.GetPublicId()}, + }, + wantErr: true, + }, + { + name: "immutable-publicid", + args: args{ + fieldMask: []string{"PublicId"}, + authTok: &store.AuthToken{PublicId: newAuthTokId}, + }, + wantErr: true, + }, + { + name: "update-last-access-time", + args: args{ + fieldMask: []string{"ApproximateLastAccessTime"}, + authTok: &store.AuthToken{ApproximateLastAccessTime: ×tamp.Timestamp{Timestamp: future}}, + }, + cnt: 1, + }, + { + name: "nullify-last-access-time", + args: args{ + nullMask: []string{"ApproximateLastAccessTime"}, + authTok: &store.AuthToken{}, + }, + cnt: 1, + }, + { + name: "update-expiration", + args: args{ + fieldMask: []string{"ExpirationTime"}, + authTok: &store.AuthToken{ExpirationTime: ×tamp.Timestamp{Timestamp: future}}, + }, + cnt: 1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + w := db.New(conn) + + authTok := testAuthToken(t, conn, wrapper) + proto.Merge(authTok.AuthToken, tt.args.authTok) + + err := authTok.encrypt(context.Background(), wrapper) + require.NoError(t, err) + cnt, err := w.Update(context.Background(), authTok, tt.args.fieldMask, tt.args.nullMask) + if tt.wantErr { + t.Logf("Got error :%v", err) + assert.Error(err) + } else { + assert.NoError(err) + } + assert.Equal(tt.cnt, cnt) + }) + } +} + +func TestAuthToken_DbCreate(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + + org, _ := iam.TestScopes(t, conn) + u := iam.TestUser(t, conn, org.GetPublicId()) + amId := setupAuthMethod(t, conn, org.GetPublicId()) + acct := setupAuthAccount(t, conn, org.GetPublicId(), amId, u.GetPublicId()) + createdAuthToken := testAuthToken(t, conn, wrapper) + + testAuthTokenId := func() string { + id, err := newAuthTokenId() + require.NoError(t, err) + return id + } + + var tests = []struct { + name string + in *store.AuthToken + wantError bool + }{ + { + name: "basic", + in: &store.AuthToken{ + PublicId: testAuthTokenId(), + Token: "anything", + AuthAccountId: acct.GetPublicId(), + }, + }, + { + name: "duplicate-id", + in: &store.AuthToken{ + PublicId: createdAuthToken.GetPublicId(), + Token: "duplicateid_test", + AuthAccountId: acct.GetPublicId(), + }, + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + at := &AuthToken{AuthToken: tt.in} + err := at.encrypt(context.Background(), wrapper) + require.NoError(t, err) + err = db.New(conn).Create(context.Background(), at) + if tt.wantError { + assert.Error(err) + } else { + assert.NoError(err) + } + }) + } +} + +func TestAuthToken_DbDelete(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + testAuthTokenId := func() string { + id, err := newAuthTokenId() + require.NoError(t, err) + return id + } + + wrapper := db.TestWrapper(t) + existingAuthTok := testAuthToken(t, conn, wrapper) + + var tests = []struct { + name string + at *AuthToken + wantError bool + wantCnt int + }{ + { + name: "basic", + at: &AuthToken{AuthToken: &store.AuthToken{PublicId: existingAuthTok.GetPublicId()}}, + wantCnt: 1, + }, + { + name: "delete-nothing", + at: &AuthToken{AuthToken: &store.AuthToken{PublicId: testAuthTokenId()}}, + wantCnt: 0, + }, + { + name: "delete-nil", + at: nil, + wantCnt: 0, + wantError: true, + }, + { + name: "delete-no-public-id", + at: &AuthToken{AuthToken: &store.AuthToken{}}, + wantCnt: 0, + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + cnt, err := db.New(conn).Delete(context.Background(), tt.at) + assert.Equal(tt.wantCnt, cnt) + if tt.wantError { + assert.Error(err) + } else { + assert.NoError(err) + } + }) + } +} diff --git a/internal/authtoken/doc.go b/internal/authtoken/doc.go new file mode 100644 index 000000000..ecae023b9 --- /dev/null +++ b/internal/authtoken/doc.go @@ -0,0 +1,17 @@ +// Package authtoken provides an authtoken with an encrypted value and +// an associated expiration time. It also provides a repository which +// manages the lifetime of the token. +// +// The auth token value is a base62 bit value with a version prefix. This +// value is encrypted at rest and is used to authenticate incoming requests +// to the controller. It is associated with a public id which allows admins +// to operate on it without knowing the token itself. It also has an +// expiration time and a last accessed time which are used to determine if the +// token can still be used. +// +// Repository +// +// A repository provides methods for creating, validating a provided token value, +// and deleting the auth token. At validation time if the token is determined +// to be expired or stale it will be removed from the backing storage by the repo. +package authtoken diff --git a/internal/authtoken/gorm.go b/internal/authtoken/gorm.go new file mode 100644 index 000000000..1f68184b3 --- /dev/null +++ b/internal/authtoken/gorm.go @@ -0,0 +1,16 @@ +package authtoken + +// TableName returns the table name for the auth token. +func (s *AuthToken) TableName() string { + if s.tableName != "" { + return s.tableName + } + return "auth_token" +} + +// SetTableName sets the table name. +func (s *AuthToken) SetTableName(n string) { + if n != "" { + s.tableName = n + } +} diff --git a/internal/authtoken/options.go b/internal/authtoken/options.go new file mode 100644 index 000000000..e1e83ad12 --- /dev/null +++ b/internal/authtoken/options.go @@ -0,0 +1,30 @@ +package authtoken + +// 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 { + withTokenValue bool +} + +func getDefaultOptions() options { + return options{} +} + +// withTokenValue allows the auth token value to be included in the lookup response. +// This is purposefully not exported as it should only be used internally by the auth token repository itself. +func withTokenValue() Option { + return func(o *options) { + o.withTokenValue = true + } +} diff --git a/internal/authtoken/options_test.go b/internal/authtoken/options_test.go new file mode 100644 index 000000000..1a6e8db24 --- /dev/null +++ b/internal/authtoken/options_test.go @@ -0,0 +1,19 @@ +package authtoken + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("withTokenValue", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(withTokenValue()) + testOpts := getDefaultOptions() + testOpts.withTokenValue = true + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/authtoken/repository.go b/internal/authtoken/repository.go new file mode 100644 index 000000000..587fe8ab9 --- /dev/null +++ b/internal/authtoken/repository.go @@ -0,0 +1,311 @@ +package authtoken + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/golang/protobuf/ptypes" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/watchtower/internal/db/timestamp" + "github.com/hashicorp/watchtower/internal/iam" + iamStore "github.com/hashicorp/watchtower/internal/iam/store" + + "github.com/hashicorp/watchtower/internal/authtoken/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" +) + +// TODO (ICU-406): Make these fields configurable. +var ( + lastAccessedUpdateDuration = 10 * time.Minute + maxStaleness = 24 * time.Hour + maxTokenDuration = 7 * 24 * time.Hour +) + +// A Repository stores and retrieves the persistent types in the authtoken +// 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 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: auth token: %w", db.ErrNilParameter) + case w == nil: + return nil, fmt.Errorf("db.Writer: auth token: %w", db.ErrNilParameter) + case wrapper == nil: + return nil, fmt.Errorf("wrapping.Wrapper: auth token: %w", db.ErrNilParameter) + } + + return &Repository{ + reader: r, + writer: w, + wrapper: wrapper, + }, nil +} + +// CreateAuthToken inserts an Auth Token into the repository and returns a new Auth Token. The returned auth token +// contains the auth token value. The provided IAM User ID must be associated to the provided auth account id +// or an error will be returned. All options are ignored. +func (r *Repository) CreateAuthToken(ctx context.Context, withIamUserId, withAuthAccountId string, opt ...Option) (*AuthToken, error) { + if withIamUserId == "" { + return nil, fmt.Errorf("create: auth token: no user id: %w", db.ErrInvalidParameter) + } + if withAuthAccountId == "" { + return nil, fmt.Errorf("create: auth token: no auth account id: %w", db.ErrInvalidParameter) + } + + at := allocAuthToken() + at.AuthAccountId = withAuthAccountId + + id, err := newAuthTokenId() + if err != nil { + return nil, fmt.Errorf("create: auth token id: %w", err) + } + at.PublicId = id + + token, err := newAuthToken() + if err != nil { + return nil, fmt.Errorf("create: auth token value: %w", err) + } + at.Token = token + + // TODO: Allow the caller to specify something different than the default duration. + expiration, err := ptypes.TimestampProto(time.Now().Add(maxTokenDuration)) + if err != nil { + return nil, err + } + at.ExpirationTime = ×tamp.Timestamp{Timestamp: expiration} + + var newAuthToken *AuthToken + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(read db.Reader, w db.Writer) error { + // TODO: Remove this and either rely on either Alloc or a method exposed by the auth repo. + acct := &iam.AuthAccount{AuthAccount: &iamStore.AuthAccount{PublicId: withAuthAccountId}} + if err := read.LookupByPublicId(ctx, acct); err != nil { + return fmt.Errorf("create: auth token: auth account lookup: %w", err) + } + if acct.GetIamUserId() != withIamUserId { + return fmt.Errorf("create: auth token: auth account %q mismatch with iam user %q", withAuthAccountId, withIamUserId) + } + at.ScopeId = acct.GetScopeId() + at.AuthMethodId = acct.GetAuthMethodId() + at.IamUserId = acct.GetIamUserId() + + metadata := newAuthTokenMetadata(at, oplog.OpType_OP_TYPE_CREATE) + + newAuthToken = at.clone() + if err := newAuthToken.encrypt(ctx, r.wrapper); err != nil { + return err + } + if err := w.Create(ctx, newAuthToken, db.WithOplog(r.wrapper, metadata)); err != nil { + return err + } + newAuthToken.CtToken = nil + + return nil + }, + ) + + if err != nil { + return nil, fmt.Errorf("create: auth token: %v: %w", at, err) + } + return newAuthToken, nil +} + +// LookupAuthToken returns the AuthToken for the provided id. Returns nil, nil if no AuthToken is found for id. +// For security reasons, the actual token is not included in the returned AuthToken. +// All exported options are ignored. +func (r *Repository) LookupAuthToken(ctx context.Context, id string, opt ...Option) (*AuthToken, error) { + if id == "" { + return nil, fmt.Errorf("lookup: auth token: missing public id: %w", db.ErrInvalidParameter) + } + opts := getOpts(opt...) + + at := allocAuthToken() + at.PublicId = id + // Aggregate the fields across auth token and auth accounts by using this view instead of issuing 2 db lookups. + at.SetTableName("auth_token_account") + if err := r.reader.LookupByPublicId(ctx, at); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("auth token: lookup: %w", err) + } + if opts.withTokenValue { + if err := at.decrypt(ctx, r.wrapper); err != nil { + return nil, fmt.Errorf("lookup: auth token: cannot decrypt auth token value: %w", err) + } + } + + at.CtToken = nil + return at, nil +} + +// ValidateToken returns a token from storage if the auth token with the provided id and token exists. The +// approximate last accessed time may be updated depending on how long it has been since the last time the token +// was validated. If a token is returned it is guaranteed to be valid. For security reasons, the actual token +// value is not included in the returned AuthToken. If no valid auth token is found nil, nil is returned. +// All options are ignored. +// +// NOTE: Do not log or add the token string to any errors to avoid leaking it as it is a secret. +func (r *Repository) ValidateToken(ctx context.Context, id, token string, opt ...Option) (*AuthToken, error) { + if token == "" { + return nil, fmt.Errorf("validate token: auth token: missing token: %w", db.ErrInvalidParameter) + } + if id == "" { + return nil, fmt.Errorf("validate token: auth token: missing public id: %w", db.ErrInvalidParameter) + } + + retAT, err := r.LookupAuthToken(ctx, id, withTokenValue()) + if err != nil { + retAT = nil + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("validate token: %w", err) + } + if retAT == nil { + return nil, nil + } + + // If the token is too old or stale invalidate it and return nothing. + exp, err := ptypes.Timestamp(retAT.GetExpirationTime().GetTimestamp()) + if err != nil { + return nil, fmt.Errorf("validate token: expiration time : %w", err) + } + lastAccessed, err := ptypes.Timestamp(retAT.GetApproximateLastAccessTime().GetTimestamp()) + if err != nil { + return nil, fmt.Errorf("validate token: last accessed time : %w", err) + } + + now := time.Now() + sinceLastAccessed := now.Sub(lastAccessed) + if now.After(exp) || sinceLastAccessed > maxStaleness { + // If the token has expired or has become too stale, delete it from the DB. + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + metadata := newAuthTokenMetadata(retAT, oplog.OpType_OP_TYPE_DELETE) + delAt := retAT.clone() + if _, err := w.Delete(ctx, delAt, db.WithOplog(r.wrapper, metadata)); err != nil { + return fmt.Errorf("validate token: delete auth token: %w", err) + } + retAT = nil + return nil + }) + return nil, nil + } + + if retAT.GetToken() != token { + return nil, fmt.Errorf("validate token: auth token mismatch: %w", db.ErrInvalidParameter) + } + // retAT.Token set to empty string so the value is not returned as described in the methods' doc. + retAT.Token = "" + + if sinceLastAccessed >= lastAccessedUpdateDuration { + // To save the db from being updated too frequently, we only update the + // LastAccessTime if it hasn't been updated within lastAccessedUpdateDuration. + // TODO: Make this duration configurable. + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + // Setting the ApproximateLastAccessTime to null through using the null mask allows a defined db's + // trigger to set ApproximateLastAccessTime to the commit timestamp. + at := retAT.clone() + metadata := newAuthTokenMetadata(retAT, oplog.OpType_OP_TYPE_UPDATE) + rowsUpdated, err := w.Update( + ctx, + at, + nil, + []string{"ApproximateLastAccessTime"}, + db.WithOplog(r.wrapper, metadata), + ) + if err == nil && rowsUpdated > 1 { + return db.ErrMultipleRecords + } + return err + }, + ) + } + + if err != nil { + return nil, fmt.Errorf("validate token: auth token: %s: %w", id, err) + } + return retAT, nil +} + +// TODO(ICU-344): Add ListAuthTokens + +// DeleteAuthToken deletes the token with the provided id from the repository returning a count of the +// number of records deleted. All options are ignored. +func (r *Repository) DeleteAuthToken(ctx context.Context, id string, opt ...Option) (int, error) { + if id == "" { + return db.NoRowsAffected, fmt.Errorf("delete: auth token: missing public id: %w", db.ErrInvalidParameter) + } + + at, err := r.LookupAuthToken(ctx, id) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return db.NoRowsAffected, nil + } + return db.NoRowsAffected, fmt.Errorf("delete: auth token: lookup %w", err) + } + if at == nil { + return db.NoRowsAffected, nil + } + + var rowsDeleted int + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + metadata := newAuthTokenMetadata(at, oplog.OpType_OP_TYPE_DELETE) + + deleteAT := at.clone() + rowsDeleted, err = w.Delete(ctx, deleteAT, db.WithOplog(r.wrapper, metadata)) + if err == nil && rowsDeleted > 1 { + return db.ErrMultipleRecords + } + return err + }, + ) + + if err != nil { + return db.NoRowsAffected, fmt.Errorf("delete: auth token: %s: %w", id, err) + } + + return rowsDeleted, nil +} + +func allocAuthToken() *AuthToken { + fresh := &AuthToken{ + AuthToken: &store.AuthToken{}, + } + return fresh +} + +func newAuthTokenMetadata(at *AuthToken, op oplog.OpType) oplog.Metadata { + metadata := oplog.Metadata{ + "scope-id": []string{at.GetScopeId()}, + "resource-public-id": []string{at.GetPublicId()}, + "resource-type": []string{"auth token"}, + "op-type": []string{op.String()}, + } + return metadata +} diff --git a/internal/authtoken/repository_test.go b/internal/authtoken/repository_test.go new file mode 100644 index 000000000..8a51cc056 --- /dev/null +++ b/internal/authtoken/repository_test.go @@ -0,0 +1,482 @@ +package authtoken + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/google/go-cmp/cmp" + wrapping "github.com/hashicorp/go-kms-wrapping" + iamStore "github.com/hashicorp/watchtower/internal/iam/store" + "github.com/hashicorp/watchtower/internal/oplog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/hashicorp/watchtower/internal/authtoken/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/iam" +) + +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 := assert.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) + assert.NotNil(got) + assert.Equal(tt.want, got) + }) + } +} + +func TestRepository_CreateAuthToken(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + org1, _ := iam.TestScopes(t, conn) + u1 := iam.TestUser(t, conn, org1.GetPublicId()) + amId1 := setupAuthMethod(t, conn, org1.GetPublicId()) + aAcct := setupAuthAccount(t, conn, org1.GetPublicId(), amId1, u1.GetPublicId()) + + org2, _ := iam.TestScopes(t, conn) + u2 := iam.TestUser(t, conn, org2.GetPublicId()) + + var tests = []struct { + name string + iamUserId string + authAcctId string + want *AuthToken + wantErr bool + }{ + { + name: "valid", + iamUserId: u1.GetPublicId(), + authAcctId: aAcct.GetPublicId(), + want: &AuthToken{ + AuthToken: &store.AuthToken{ + AuthAccountId: aAcct.GetPublicId(), + }, + }, + }, + { + name: "unconnected-authaccount-user", + iamUserId: u2.GetPublicId(), + authAcctId: aAcct.GetPublicId(), + wantErr: true, + }, + { + name: "no-authacctid", + iamUserId: u1.GetPublicId(), + wantErr: true, + }, + { + name: "no-userid", + authAcctId: aAcct.GetPublicId(), + wantErr: true, + }, + { + name: "invalid-authacctid", + iamUserId: u1.GetPublicId(), + authAcctId: "this_is_invalid", + wantErr: true, + }, + { + name: "invalid-userid", + iamUserId: "this_is_invalid", + authAcctId: aAcct.GetPublicId(), + wantErr: true, + }, + } + + for _, tt := range tests { + 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.CreateAuthToken(context.Background(), tt.iamUserId, tt.authAcctId) + if tt.wantErr { + assert.Error(err) + assert.Nil(got) + return + } + require.NoError(err, "Got error for CreateAuthToken(ctx, %v, %v)", tt.iamUserId, tt.authAcctId) + assert.NotNil(got) + db.AssertPublicId(t, AuthTokenPrefix, got.PublicId) + assert.Equal(tt.authAcctId, got.GetAuthAccountId()) + assert.Equal(got.CreateTime, got.UpdateTime) + assert.Equal(got.CreateTime, got.ApproximateLastAccessTime) + assert.NoError(db.TestVerifyOplog(t, rw, got.GetPublicId(), db.WithOperation(oplog.OpType_OP_TYPE_CREATE))) + }) + } +} + +func TestRepository_LookupAuthToken(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + at := testAuthToken(t, conn, wrapper) + at.Token = "" + at.CtToken = nil + + badId, err := newAuthTokenId() + require.NoError(t, err) + require.NotNil(t, badId) + + var tests = []struct { + name string + id string + want *AuthToken + wantErr error + }{ + { + name: "found", + id: at.GetPublicId(), + want: at, + }, + { + name: "not-found", + id: badId, + want: nil, + }, + { + name: "bad-public-id", + id: "", + want: nil, + wantErr: db.ErrInvalidParameter, + }, + } + + 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.LookupAuthToken(context.Background(), tt.id) + if tt.wantErr != nil { + assert.Truef(errors.Is(err, tt.wantErr), "want err: %q got: %q", tt.wantErr, err) + return + } + assert.NoError(err) + if got == nil { + assert.Nil(tt.want) + return + } + require.NotNil(tt.want, "got %v, wanted nil", got) + assert.Empty(cmp.Diff(tt.want.AuthToken, got.AuthToken, protocmp.Transform())) + }) + } +} + +func TestRepository_ValidateToken(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + lastAccessedUpdateDuration = 0 + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(t, err) + require.NotNil(t, repo) + + at := testAuthToken(t, conn, wrapper) + atToken := at.GetToken() + at.Token = "" + at.CtToken = nil + atTime, err := ptypes.Timestamp(at.GetApproximateLastAccessTime().GetTimestamp()) + require.NoError(t, err) + require.NotNil(t, atTime) + + badId, err := newAuthTokenId() + require.NoError(t, err) + require.NotNil(t, badId) + + badToken, err := newAuthToken() + require.NoError(t, err) + require.NotNil(t, badToken) + + var tests = []struct { + name string + id string + token string + want *AuthToken + wantErr error + }{ + { + name: "exists", + id: at.GetPublicId(), + token: atToken, + want: at, + }, + { + name: "doesnt-exist", + id: badId, + token: badToken, + want: nil, + }, + { + name: "empty-token", + id: at.GetPublicId(), + token: "", + want: nil, + wantErr: db.ErrInvalidParameter, + }, + { + name: "mismatched-token", + id: at.GetPublicId(), + token: badToken, + want: nil, + wantErr: db.ErrInvalidParameter, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := repo.ValidateToken(context.Background(), tt.id, tt.token) + if tt.wantErr != nil { + assert.Truef(errors.Is(err, tt.wantErr), "want err: %q got: %q", tt.wantErr, err) + return + } + assert.NoError(err) + if got == nil { + assert.Nil(tt.want) + // No need to compare updated time if we didn't get an initial auth token to compare against. + return + } + require.NotNil(tt.want, "Got %v but wanted nil", got) + assert.Empty(cmp.Diff(tt.want.AuthToken, got.AuthToken, protocmp.Transform())) + // preTime1 should be the value prior to the ValidateToken was called so it should equal creation time + preTime1, err := ptypes.Timestamp(got.GetApproximateLastAccessTime().GetTimestamp()) + require.NoError(err) + assert.True(preTime1.Equal(atTime), "Create time %q doesn't match the time from the first call to MaybeUpdateLastAccesssed: %q.", atTime, preTime1) + + // Enable the duration which limits how frequently a token's approximate last accessed time can be updated + // so the next call doesn't cause the last accessed time to be updated. + lastAccessedUpdateDuration = 1 * time.Hour + + got2, err := repo.ValidateToken(context.Background(), tt.id, tt.token) + assert.NoError(err) + preTime2, err := ptypes.Timestamp(got2.GetApproximateLastAccessTime().GetTimestamp()) + require.NoError(err) + assert.True(preTime2.After(preTime1), "First updated time %q was not after the creation time %q", preTime2, preTime1) + + assert.NoError(db.TestVerifyOplog(t, rw, got.GetPublicId(), db.WithOperation(oplog.OpType_OP_TYPE_UPDATE))) + + got3, err := repo.ValidateToken(context.Background(), tt.id, tt.token) + preTime3, err := ptypes.Timestamp(got3.GetApproximateLastAccessTime().GetTimestamp()) + require.NoError(err) + assert.True(preTime3.Equal(preTime2), "The 3rd timestamp %q was not equal to the second time %q", preTime3, preTime2) + }) + } +} + +func TestRepository_ValidateToken_expired(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(t, err) + require.NotNil(t, repo) + + baseAT := testAuthToken(t, conn, wrapper) + baseAT.GetAuthAccountId() + aAcct := &iam.AuthAccount{AuthAccount: &iamStore.AuthAccount{PublicId: baseAT.GetAuthAccountId()}} + require.NoError(t, rw.LookupByPublicId(context.Background(), aAcct)) + iamUserId := aAcct.GetIamUserId() + + defaultStaleTime := maxStaleness + defaultExpireDuration := maxTokenDuration + + var tests = []struct { + name string + staleDuration time.Duration + expirationDuration time.Duration + wantReturned bool + }{ + { + name: "not-stale-or-expired", + staleDuration: maxStaleness, + expirationDuration: maxTokenDuration, + wantReturned: true, + }, + { + name: "stale", + staleDuration: 0, + expirationDuration: maxTokenDuration, + wantReturned: false, + }, + { + name: "expired", + staleDuration: maxStaleness, + expirationDuration: 0, + wantReturned: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + maxStaleness = tt.staleDuration + maxTokenDuration = tt.expirationDuration + + ctx := context.Background() + at, err := repo.CreateAuthToken(ctx, iamUserId, baseAT.GetAuthAccountId()) + require.NoError(err) + + got, err := repo.ValidateToken(ctx, at.GetPublicId(), at.GetToken()) + require.NoError(err) + + if tt.wantReturned { + assert.NotNil(got) + } else { + assert.NoError(db.TestVerifyOplog(t, rw, at.GetPublicId(), db.WithOperation(oplog.OpType_OP_TYPE_DELETE))) + assert.Nil(got) + } + + // reset the system default params + maxStaleness = defaultStaleTime + maxTokenDuration = defaultExpireDuration + }) + } +} + +func TestRepository_DeleteAuthToken(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + at := testAuthToken(t, conn, wrapper) + badId, err := newAuthTokenId() + require.NoError(t, err) + require.NotNil(t, badId) + + var tests = []struct { + name string + id string + want int + wantErr error + }{ + { + name: "found", + id: at.GetPublicId(), + want: 1, + }, + { + name: "not-found", + id: badId, + want: 0, + }, + { + name: "empty-public-id", + id: "", + want: 0, + wantErr: db.ErrInvalidParameter, + }, + } + + 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.DeleteAuthToken(context.Background(), tt.id) + if tt.wantErr != nil { + assert.Truef(errors.Is(err, tt.wantErr), "want err: %q got: %q", tt.wantErr, err) + return + } + assert.NoError(err) + assert.Equal(tt.want, got, "row count") + if tt.want != 0 { + assert.NoError(db.TestVerifyOplog(t, rw, tt.id, db.WithOperation(oplog.OpType_OP_TYPE_DELETE))) + } + }) + } +} diff --git a/internal/authtoken/store/authtoken.pb.go b/internal/authtoken/store/authtoken.pb.go new file mode 100644 index 000000000..efb5a49af --- /dev/null +++ b/internal/authtoken/store/authtoken.pb.go @@ -0,0 +1,303 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.3 +// source: controller/storage/authtoken/store/v1/authtoken.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 AuthToken struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // public_id is used to access the auth token 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"` + // last_access_time indicates the last time the auth token was used on the watchtower API. + // @inject_tag: `gorm:"default:current_timestamp"` + ApproximateLastAccessTime *timestamp.Timestamp `protobuf:"bytes,4,opt,name=approximate_last_access_time,json=approximateLastAccessTime,proto3" json:"approximate_last_access_time,omitempty" gorm:"default:current_timestamp"` + // expiration_time indicates when this session will expire. + // If null a default duration and create_time is used to calculate expiration. + // @inject_tag: `gorm:"default:null"` + ExpirationTime *timestamp.Timestamp `protobuf:"bytes,5,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty" gorm:"default:null"` + // ciphertext token value stored in the database + // @inject_tag: gorm:"column:token;not_null" wrapping:"ct,authtoken_token" + CtToken []byte `protobuf:"bytes,6,opt,name=ct_token,json=ctToken,proto3" json:"ct_token,omitempty" gorm:"column:token;not_null" wrapping:"ct,authtoken_token"` + // plain text version of the decrypted authtoken value + // we are NOT storing this plain-text entry data in the db + // token is the field stored and used by the client + // @inject_tag: gorm:"-" wrapping:"pt,authtoken_token" + Token string `protobuf:"bytes,7,opt,name=token,proto3" json:"token,omitempty" gorm:"-" wrapping:"pt,authtoken_token"` + // auth_account_id is the public id for the auth account this auth token + // was generated for. + // @inject_tag: `gorm:"default:not_null"` + AuthAccountId string `protobuf:"bytes,10,opt,name=auth_account_id,json=authAccountId,proto3" json:"auth_account_id,omitempty" gorm:"default:not_null"` + // scope_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + ScopeId string `protobuf:"bytes,11,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty" gorm:"-"` + // auth_method_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + AuthMethodId string `protobuf:"bytes,12,opt,name=auth_method_id,json=authMethodId,proto3" json:"auth_method_id,omitempty" gorm:"-"` + // iam_user_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + IamUserId string `protobuf:"bytes,13,opt,name=iam_user_id,json=iamUserId,proto3" json:"iam_user_id,omitempty" gorm:"-"` +} + +func (x *AuthToken) Reset() { + *x = AuthToken{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_authtoken_store_v1_authtoken_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthToken) ProtoMessage() {} + +func (x *AuthToken) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_authtoken_store_v1_authtoken_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 AuthToken.ProtoReflect.Descriptor instead. +func (*AuthToken) Descriptor() ([]byte, []int) { + return file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescGZIP(), []int{0} +} + +func (x *AuthToken) GetPublicId() string { + if x != nil { + return x.PublicId + } + return "" +} + +func (x *AuthToken) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *AuthToken) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *AuthToken) GetApproximateLastAccessTime() *timestamp.Timestamp { + if x != nil { + return x.ApproximateLastAccessTime + } + return nil +} + +func (x *AuthToken) GetExpirationTime() *timestamp.Timestamp { + if x != nil { + return x.ExpirationTime + } + return nil +} + +func (x *AuthToken) GetCtToken() []byte { + if x != nil { + return x.CtToken + } + return nil +} + +func (x *AuthToken) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *AuthToken) GetAuthAccountId() string { + if x != nil { + return x.AuthAccountId + } + return "" +} + +func (x *AuthToken) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *AuthToken) GetAuthMethodId() string { + if x != nil { + return x.AuthMethodId + } + return "" +} + +func (x *AuthToken) GetIamUserId() string { + if x != nil { + return x.IamUserId + } + return "" +} + +var File_controller_storage_authtoken_store_v1_authtoken_proto protoreflect.FileDescriptor + +var file_controller_storage_authtoken_store_v1_authtoken_proto_rawDesc = []byte{ + 0x0a, 0x35, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2f, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x25, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 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, + 0xbe, 0x04, 0x0a, 0x09, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 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, 0x6b, 0x0a, 0x1c, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x78, 0x69, 0x6d, + 0x61, 0x74, 0x65, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 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, 0x19, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x78, 0x69, 0x6d, + 0x61, 0x74, 0x65, 0x4c, 0x61, 0x73, 0x74, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x69, 0x6d, + 0x65, 0x12, 0x53, 0x0a, 0x0f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 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, 0x0e, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x74, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x74, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x0a, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x5f, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x19, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0b, 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, 0x0c, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x64, + 0x12, 0x1e, 0x0a, 0x0b, 0x69, 0x61, 0x6d, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x61, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x42, 0x40, 0x5a, 0x3e, 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, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, + 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescOnce sync.Once + file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescData = file_controller_storage_authtoken_store_v1_authtoken_proto_rawDesc +) + +func file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescGZIP() []byte { + file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescOnce.Do(func() { + file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescData = protoimpl.X.CompressGZIP(file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescData) + }) + return file_controller_storage_authtoken_store_v1_authtoken_proto_rawDescData +} + +var file_controller_storage_authtoken_store_v1_authtoken_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_controller_storage_authtoken_store_v1_authtoken_proto_goTypes = []interface{}{ + (*AuthToken)(nil), // 0: controller.storage.authtoken.store.v1.AuthToken + (*timestamp.Timestamp)(nil), // 1: controller.storage.timestamp.v1.Timestamp +} +var file_controller_storage_authtoken_store_v1_authtoken_proto_depIdxs = []int32{ + 1, // 0: controller.storage.authtoken.store.v1.AuthToken.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 1, // 1: controller.storage.authtoken.store.v1.AuthToken.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 1, // 2: controller.storage.authtoken.store.v1.AuthToken.approximate_last_access_time:type_name -> controller.storage.timestamp.v1.Timestamp + 1, // 3: controller.storage.authtoken.store.v1.AuthToken.expiration_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_authtoken_store_v1_authtoken_proto_init() } +func file_controller_storage_authtoken_store_v1_authtoken_proto_init() { + if File_controller_storage_authtoken_store_v1_authtoken_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_controller_storage_authtoken_store_v1_authtoken_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthToken); 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_authtoken_store_v1_authtoken_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_controller_storage_authtoken_store_v1_authtoken_proto_goTypes, + DependencyIndexes: file_controller_storage_authtoken_store_v1_authtoken_proto_depIdxs, + MessageInfos: file_controller_storage_authtoken_store_v1_authtoken_proto_msgTypes, + }.Build() + File_controller_storage_authtoken_store_v1_authtoken_proto = out.File + file_controller_storage_authtoken_store_v1_authtoken_proto_rawDesc = nil + file_controller_storage_authtoken_store_v1_authtoken_proto_goTypes = nil + file_controller_storage_authtoken_store_v1_authtoken_proto_depIdxs = nil +} diff --git a/internal/authtoken/testing.go b/internal/authtoken/testing.go new file mode 100644 index 000000000..0b9757500 --- /dev/null +++ b/internal/authtoken/testing.go @@ -0,0 +1,76 @@ +package authtoken + +import ( + "context" + "testing" + + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/iam" + iamStore "github.com/hashicorp/watchtower/internal/iam/store" + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/require" +) + +func testAuthToken(t *testing.T, conn *gorm.DB, wrapper wrapping.Wrapper) *AuthToken { + t.Helper() + require := require.New(t) + org, _ := iam.TestScopes(t, conn) + u := iam.TestUser(t, conn, org.GetPublicId()) + amId := setupAuthMethod(t, conn, org.GetPublicId()) + + // auth account is only used to join auth method to user. + // We don't do anything else with the auth account in the test setup. + acct := setupAuthAccount(t, conn, org.GetPublicId(), amId, u.GetPublicId()) + + rw := db.New(conn) + repo, err := NewRepository(rw, rw, wrapper) + require.NoError(err) + + ctx := context.Background() + at, err := repo.CreateAuthToken(ctx, u.GetPublicId(), acct.GetPublicId()) + require.NoError(err) + return at +} + +// Returns auth method id +// TODO: Remove this when the auth method repos are created with the relevant test methods. +func setupAuthMethod(t *testing.T, conn *gorm.DB, scope string) string { + t.Helper() + require := require.New(t) + insert := `insert into auth_method + (public_id, scope_id) + values + ($1, $2);` + amId, err := db.NewPublicId("am") + require.NoError(err) + _, err = conn.DB().Exec(insert, amId, scope) + require.NoError(err) + return amId +} + +// TODO: Remove this when the auth method repos are created with the relevant test methods. +func setupAuthAccount(t *testing.T, conn *gorm.DB, scopeId, authMethodId, userId string) *iam.AuthAccount { + t.Helper() + require := require.New(t) + require.NotEmpty(scopeId) + require.NotEmpty(authMethodId) + require.NotEmpty(userId) + + authAcctId, err := db.NewPublicId("aa") + require.NoError(err) + + acct := &iam.AuthAccount{ + AuthAccount: &iamStore.AuthAccount{ + PublicId: authAcctId, + ScopeId: scopeId, + AuthMethodId: authMethodId, + IamUserId: userId, + }, + } + rw := db.New(conn) + err = rw.Create(context.Background(), acct) + require.NoError(err) + require.NotEmpty(acct.PublicId) + return acct +} diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index 2c23509f2..f8748f743 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -237,6 +237,7 @@ create index if not exists idx_oplog_metatadata_value on oplog_metadata(value); insert into oplog_ticket (name, version) values + ('auth_token', 1), ('default', 1), ('iam_scope', 1), ('iam_user', 1), @@ -893,6 +894,156 @@ begin; commit; +`), + }, + "migrations/11_auth_token.down.sql": { + name: "11_auth_token.down.sql", + bytes: []byte(` +begin; + + drop view auth_token_account cascade; + drop table auth_token cascade; + + drop function update_last_access_time cascade; + drop function immutable_auth_token_columns cascade; + drop function expire_time_not_older_than_token cascade; + +commit; + +`), + }, + "migrations/11_auth_token.up.sql": { + name: "11_auth_token.up.sql", + bytes: []byte(` +begin; + + -- an auth token belongs to 1 and only 1 auth account + -- an auth account can have 0 to many auth tokens + create table auth_token ( + public_id wt_public_id primary key, + token bytea not null unique, + auth_account_id wt_public_id not null + references auth_account(public_id) + on delete cascade + on update cascade, + create_time wt_timestamp, + update_time wt_timestamp, + -- This column is not updated every time this auth token is accessed. + -- It is updated after X minutes from the last time it was updated on + -- a per row basis. + approximate_last_access_time wt_timestamp + check( + approximate_last_access_time <= expiration_time + ), + expiration_time wt_timestamp + check( + create_time <= expiration_time + ) + ); + + create view auth_token_account as + select at.public_id, + at.token, + at.auth_account_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + aa.scope_id, + aa.iam_user_id, + aa.auth_method_id + from auth_token as at + inner join auth_account as aa + on at.auth_account_id = aa.public_id; + + create or replace function + update_last_access_time() + returns trigger + as $$ + begin + if new.approximate_last_access_time is distinct from old.approximate_last_access_time then + new.approximate_last_access_time = now(); + end if; + return new; + end; + $$ language plpgsql; + + comment on function + update_last_access_time() + is + 'function used in before update triggers to properly set last_access_time columns'; + + create or replace function + immutable_auth_token_columns() + returns trigger + as $$ + begin + if new.auth_account_id is distinct from old.auth_account_id then + raise exception 'auth_account_id is read-only'; + end if; + if new.token is distinct from old.token then + raise exception 'token is read-only'; + end if; + return new; + end; + $$ language plpgsql; + + comment on function + immutable_auth_token_columns() + is + 'function used in before update triggers to make specific columns immutable'; + + -- This allows the expiration to be calculated on the server side and still hold the constraint that + -- the expiration time cant be before the creation time of the auth token. + create or replace function + expire_time_not_older_than_token() + returns trigger + as $$ + begin + if new.expiration_time < new.create_time then + new.expiration_time = new.create_time; + end if; + return new; + end; + $$ language plpgsql; + + comment on function + expire_time_not_older_than_token() + is + 'function used in before insert triggers to ensure expiration time is not older than create time'; + + create trigger + default_create_time_column + before insert on auth_token + for each row execute procedure default_create_time(); + + create trigger + expire_time_not_older_than_token + before insert on auth_token + for each row execute procedure expire_time_not_older_than_token(); + + create trigger + update_time_column + before update on auth_token + for each row execute procedure update_time_column(); + + create trigger + update_last_access_time + before update on auth_token + for each row execute procedure update_last_access_time(); + + create trigger + immutable_create_time + before update on auth_token + for each row execute procedure immutable_create_time_func(); + + create trigger + immutable_auth_token_columns + before update on auth_token + for each row execute procedure immutable_auth_token_columns(); + +commit; + `), }, } diff --git a/internal/db/migrations/postgres/02_oplog.up.sql b/internal/db/migrations/postgres/02_oplog.up.sql index dded6bfdb..0838c4018 100644 --- a/internal/db/migrations/postgres/02_oplog.up.sql +++ b/internal/db/migrations/postgres/02_oplog.up.sql @@ -86,6 +86,7 @@ create index if not exists idx_oplog_metatadata_value on oplog_metadata(value); insert into oplog_ticket (name, version) values + ('auth_token', 1), ('default', 1), ('iam_scope', 1), ('iam_user', 1), diff --git a/internal/db/migrations/postgres/11_auth_token.down.sql b/internal/db/migrations/postgres/11_auth_token.down.sql new file mode 100644 index 000000000..b4c9e77ee --- /dev/null +++ b/internal/db/migrations/postgres/11_auth_token.down.sql @@ -0,0 +1,10 @@ +begin; + + drop view auth_token_account cascade; + drop table auth_token cascade; + + drop function update_last_access_time cascade; + drop function immutable_auth_token_columns cascade; + drop function expire_time_not_older_than_token cascade; + +commit; diff --git a/internal/db/migrations/postgres/11_auth_token.up.sql b/internal/db/migrations/postgres/11_auth_token.up.sql new file mode 100644 index 000000000..67b642add --- /dev/null +++ b/internal/db/migrations/postgres/11_auth_token.up.sql @@ -0,0 +1,128 @@ +begin; + + -- an auth token belongs to 1 and only 1 auth account + -- an auth account can have 0 to many auth tokens + create table auth_token ( + public_id wt_public_id primary key, + token bytea not null unique, + auth_account_id wt_public_id not null + references auth_account(public_id) + on delete cascade + on update cascade, + create_time wt_timestamp, + update_time wt_timestamp, + -- This column is not updated every time this auth token is accessed. + -- It is updated after X minutes from the last time it was updated on + -- a per row basis. + approximate_last_access_time wt_timestamp + check( + approximate_last_access_time <= expiration_time + ), + expiration_time wt_timestamp + check( + create_time <= expiration_time + ) + ); + + create view auth_token_account as + select at.public_id, + at.token, + at.auth_account_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + aa.scope_id, + aa.iam_user_id, + aa.auth_method_id + from auth_token as at + inner join auth_account as aa + on at.auth_account_id = aa.public_id; + + create or replace function + update_last_access_time() + returns trigger + as $$ + begin + if new.approximate_last_access_time is distinct from old.approximate_last_access_time then + new.approximate_last_access_time = now(); + end if; + return new; + end; + $$ language plpgsql; + + comment on function + update_last_access_time() + is + 'function used in before update triggers to properly set last_access_time columns'; + + create or replace function + immutable_auth_token_columns() + returns trigger + as $$ + begin + if new.auth_account_id is distinct from old.auth_account_id then + raise exception 'auth_account_id is read-only'; + end if; + if new.token is distinct from old.token then + raise exception 'token is read-only'; + end if; + return new; + end; + $$ language plpgsql; + + comment on function + immutable_auth_token_columns() + is + 'function used in before update triggers to make specific columns immutable'; + + -- This allows the expiration to be calculated on the server side and still hold the constraint that + -- the expiration time cant be before the creation time of the auth token. + create or replace function + expire_time_not_older_than_token() + returns trigger + as $$ + begin + if new.expiration_time < new.create_time then + new.expiration_time = new.create_time; + end if; + return new; + end; + $$ language plpgsql; + + comment on function + expire_time_not_older_than_token() + is + 'function used in before insert triggers to ensure expiration time is not older than create time'; + + create trigger + default_create_time_column + before insert on auth_token + for each row execute procedure default_create_time(); + + create trigger + expire_time_not_older_than_token + before insert on auth_token + for each row execute procedure expire_time_not_older_than_token(); + + create trigger + update_time_column + before update on auth_token + for each row execute procedure update_time_column(); + + create trigger + update_last_access_time + before update on auth_token + for each row execute procedure update_last_access_time(); + + create trigger + immutable_create_time + before update on auth_token + for each row execute procedure immutable_create_time_func(); + + create trigger + immutable_auth_token_columns + before update on auth_token + for each row execute procedure immutable_auth_token_columns(); + +commit; diff --git a/internal/db/testing.go b/internal/db/testing.go index 960edf0ec..7e148fe51 100644 --- a/internal/db/testing.go +++ b/internal/db/testing.go @@ -3,6 +3,7 @@ package db import ( "context" "crypto/rand" + "strings" "testing" "time" @@ -52,6 +53,16 @@ func TestWrapper(t *testing.T) wrapping.Wrapper { return root } +// AssertPublicId is a test helper that asserts that the provided id is in +// the format of a public id. +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) +} + // TestVerifyOplog will verify that there is an oplog entry. An error is // returned if the entry or it's metadata is not found. Returning an error // allows clients to test if an entry was not written, which is a valid use case. diff --git a/internal/proto/local/controller/storage/authtoken/store/v1/authtoken.proto b/internal/proto/local/controller/storage/authtoken/store/v1/authtoken.proto new file mode 100644 index 000000000..3efa5b6f5 --- /dev/null +++ b/internal/proto/local/controller/storage/authtoken/store/v1/authtoken.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package controller.storage.authtoken.store.v1; +option go_package = "github.com/hashicorp/watchtower/internal/authtoken/store;store"; + +import "controller/storage/timestamp/v1/timestamp.proto"; + +message AuthToken { + // public_id is used to access the auth token 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; + + // last_access_time indicates the last time the auth token was used on the watchtower API. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp approximate_last_access_time = 4; + + // expiration_time indicates when this session will expire. + // If null a default duration and create_time is used to calculate expiration. + // @inject_tag: `gorm:"default:null"` + timestamp.v1.Timestamp expiration_time = 5; + + // ciphertext token value stored in the database + // @inject_tag: gorm:"column:token;not_null" wrapping:"ct,authtoken_token" + bytes ct_token = 6; + + // plain text version of the decrypted authtoken value + // we are NOT storing this plain-text entry data in the db + // token is the field stored and used by the client + // @inject_tag: gorm:"-" wrapping:"pt,authtoken_token" + string token = 7; + + // auth_account_id is the public id for the auth account this auth token + // was generated for. + // @inject_tag: `gorm:"default:not_null"` + string auth_account_id = 10; + + // scope_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + string scope_id = 11; + + // auth_method_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + string auth_method_id = 12; + + // iam_user_id is not stored in the backing DB but it derived from the linked to auth account. + // @inject_tag: gorm:"-" + string iam_user_id = 13; +} \ No newline at end of file