feat: add personal access tokens for service users (#2974)

* feat: add machine tokens

* fix test

* rename to pat

* fix merge and tests

* fix scopes

* fix migration version

* fix test

* Update internal/repository/user/personal_access_token.go

Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com>

Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com>
This commit is contained in:
Livio Amstutz
2022-02-08 09:37:28 +01:00
committed by GitHub
parent 3bf9adece5
commit 699fdaf68e
32 changed files with 1838 additions and 30 deletions

View File

@@ -1,6 +1,9 @@
package command
import (
"encoding/base64"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/repository/user"
)
@@ -97,6 +100,18 @@ func keyWriteModelToMachineKey(wm *MachineKeyWriteModel) *domain.MachineKey {
}
}
func personalTokenWriteModelToToken(wm *PersonalAccessTokenWriteModel, algorithm crypto.EncryptionAlgorithm) (*domain.Token, string, error) {
encrypted, err := algorithm.Encrypt([]byte(wm.TokenID + ":" + wm.AggregateID))
if err != nil {
return nil, "", err
}
return &domain.Token{
ObjectRoot: writeModelToObjectRoot(wm.WriteModel),
TokenID: wm.TokenID,
Expiration: wm.ExpirationDate,
}, base64.RawURLEncoding.EncodeToString(encrypted), nil
}
func readModelToU2FTokens(wm *HumanU2FTokensReadModel) []*domain.WebAuthNToken {
tokens := make([]*domain.WebAuthNToken, len(wm.WebAuthNTokens))
for i, token := range wm.WebAuthNTokens {

View File

@@ -16,6 +16,7 @@ type UserWriteModel struct {
UserName string
IDPLinks []*domain.UserIDPLink
UserState domain.UserState
UserType domain.UserType
}
func NewUserWriteModel(userID, resourceOwner string) *UserWriteModel {
@@ -34,9 +35,11 @@ func (wm *UserWriteModel) Reduce() error {
case *user.HumanAddedEvent:
wm.UserName = e.UserName
wm.UserState = domain.UserStateActive
wm.UserType = domain.UserTypeHuman
case *user.HumanRegisteredEvent:
wm.UserName = e.UserName
wm.UserState = domain.UserStateActive
wm.UserType = domain.UserTypeHuman
case *user.HumanInitialCodeAddedEvent:
wm.UserState = domain.UserStateInitial
case *user.HumanInitializedCheckSucceededEvent:
@@ -62,6 +65,7 @@ func (wm *UserWriteModel) Reduce() error {
case *user.MachineAddedEvent:
wm.UserName = e.UserName
wm.UserState = domain.UserStateActive
wm.UserType = domain.UserTypeMachine
case *user.UsernameChangedEvent:
wm.UserName = e.UserName
case *user.UserLockedEvent:

View File

@@ -0,0 +1,92 @@
package command
import (
"context"
"time"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/repository/user"
"github.com/caos/zitadel/internal/telemetry/tracing"
)
func (c *Commands) AddPersonalAccessToken(ctx context.Context, userID, resourceOwner string, expirationDate time.Time, scopes []string, allowedUserType domain.UserType) (*domain.Token, string, error) {
userWriteModel, err := c.userWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, "", err
}
if !isUserStateExists(userWriteModel.UserState) {
return nil, "", errors.ThrowPreconditionFailed(nil, "COMMAND-Dggw2", "Errors.User.NotFound")
}
if allowedUserType != domain.UserTypeUnspecified && userWriteModel.UserType != allowedUserType {
return nil, "", errors.ThrowPreconditionFailed(nil, "COMMAND-Df2f1", "Errors.User.WrongType")
}
tokenID, err := c.idGenerator.Next()
if err != nil {
return nil, "", err
}
tokenWriteModel := NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, tokenWriteModel)
if err != nil {
return nil, "", err
}
expirationDate, err = domain.ValidateExpirationDate(expirationDate)
if err != nil {
return nil, "", err
}
events, err := c.eventstore.Push(ctx,
user.NewPersonalAccessTokenAddedEvent(
ctx,
UserAggregateFromWriteModel(&tokenWriteModel.WriteModel),
tokenID,
expirationDate,
scopes,
),
)
if err != nil {
return nil, "", err
}
err = AppendAndReduce(tokenWriteModel, events...)
if err != nil {
return nil, "", err
}
return personalTokenWriteModelToToken(tokenWriteModel, c.keyAlgorithm)
}
func (c *Commands) RemovePersonalAccessToken(ctx context.Context, userID, tokenID, resourceOwner string) (*domain.ObjectDetails, error) {
tokenWriteModel, err := c.personalAccessTokenWriteModelByID(ctx, userID, tokenID, resourceOwner)
if err != nil {
return nil, err
}
if !tokenWriteModel.Exists() {
return nil, errors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.User.PAT.NotFound")
}
pushedEvents, err := c.eventstore.Push(ctx,
user.NewPersonalAccessTokenRemovedEvent(ctx, UserAggregateFromWriteModel(&tokenWriteModel.WriteModel), tokenID))
if err != nil {
return nil, err
}
err = AppendAndReduce(tokenWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&tokenWriteModel.WriteModel), nil
}
func (c *Commands) personalAccessTokenWriteModelByID(ctx context.Context, userID, tokenID, resourceOwner string) (writeModel *PersonalAccessTokenWriteModel, err error) {
if userID == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-4n8vs", "Errors.User.UserIDMissing")
}
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
return writeModel, nil
}

View File

@@ -0,0 +1,81 @@
package command
import (
"time"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/repository/user"
)
type PersonalAccessTokenWriteModel struct {
eventstore.WriteModel
TokenID string
ExpirationDate time.Time
State domain.PersonalAccessTokenState
}
func NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner string) *PersonalAccessTokenWriteModel {
return &PersonalAccessTokenWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
TokenID: tokenID,
}
}
func (wm *PersonalAccessTokenWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *user.PersonalAccessTokenAddedEvent:
if wm.TokenID != e.TokenID {
continue
}
wm.WriteModel.AppendEvents(e)
case *user.PersonalAccessTokenRemovedEvent:
if wm.TokenID != e.TokenID {
continue
}
wm.WriteModel.AppendEvents(e)
case *user.UserRemovedEvent:
wm.WriteModel.AppendEvents(e)
}
}
}
func (wm *PersonalAccessTokenWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *user.PersonalAccessTokenAddedEvent:
wm.TokenID = e.TokenID
wm.ExpirationDate = e.Expiration
wm.State = domain.PersonalAccessTokenStateActive
case *user.PersonalAccessTokenRemovedEvent:
wm.State = domain.PersonalAccessTokenStateRemoved
case *user.UserRemovedEvent:
wm.State = domain.PersonalAccessTokenStateRemoved
}
}
return wm.WriteModel.Reduce()
}
func (wm *PersonalAccessTokenWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
user.PersonalAccessTokenAddedType,
user.PersonalAccessTokenRemovedType,
user.UserRemovedType).
Builder()
}
func (wm *PersonalAccessTokenWriteModel) Exists() bool {
return wm.State != domain.PersonalAccessTokenStateUnspecified && wm.State != domain.PersonalAccessTokenStateRemoved
}

View File

@@ -0,0 +1,297 @@
package command
import (
"context"
"encoding/base64"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/caos/zitadel/internal/eventstore/repository"
"github.com/caos/zitadel/internal/eventstore/v1/models"
id_mock "github.com/caos/zitadel/internal/id/mock"
"github.com/caos/zitadel/internal/repository/user"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/id"
)
func TestCommands_AddPersonalAccessToken(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
keyAlgorithm crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
resourceOwner string
expirationDate time.Time
scopes []string
allowedUserType domain.UserType
}
type res struct {
want *domain.Token
token string
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"user does not exist, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(),
),
},
args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
scopes: []string{"openid"},
expirationDate: time.Time{},
},
res{
err: caos_errs.IsPreconditionFailed,
},
},
{
"user type not allowed, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"machine",
"Machine",
"",
true,
),
),
),
),
},
args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
expirationDate: time.Time{},
scopes: []string{"openid"},
allowedUserType: domain.UserTypeHuman,
},
res{
err: caos_errs.IsPreconditionFailed,
},
},
{
"invalid expiration date, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"machine",
"Machine",
"",
true,
),
),
),
expectFilter(),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "token1"),
},
args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
expirationDate: time.Now().Add(-24 * time.Hour),
scopes: []string{"openid"},
},
res{
err: caos_errs.IsErrorInvalidArgument,
},
},
{
"token added",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"machine",
"Machine",
"",
true,
),
),
),
expectFilter(),
expectPush(
[]*repository.Event{
eventFromEventPusher(
user.NewPersonalAccessTokenAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"token1",
time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC),
[]string{"openid"},
),
),
},
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "token1"),
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
expirationDate: time.Time{},
scopes: []string{"openid"},
},
res{
want: &domain.Token{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
},
TokenID: "token1",
Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC),
},
token: base64.RawURLEncoding.EncodeToString([]byte("token1:user1")),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
keyAlgorithm: tt.fields.keyAlgorithm,
}
got, token, err := c.AddPersonalAccessToken(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.expirationDate, tt.args.scopes, tt.args.allowedUserType)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
assert.Equal(t, tt.res.token, token)
}
})
}
}
func TestCommands_RemovePersonalAccessToken(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
ctx context.Context
userID string
tokenID string
resourceOwner string
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"token does not exist, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(),
),
},
args{
ctx: context.Background(),
userID: "user1",
tokenID: "token1",
resourceOwner: "org1",
},
res{
err: caos_errs.IsNotFound,
},
},
{
"remove token, ok",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewPersonalAccessTokenAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"token1",
time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC),
[]string{"openid"},
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
user.NewPersonalAccessTokenRemovedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"token1",
),
),
},
),
),
},
args{
ctx: context.Background(),
userID: "user1",
tokenID: "token1",
resourceOwner: "org1",
},
res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := c.RemovePersonalAccessToken(tt.args.ctx, tt.args.userID, tt.args.tokenID, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}