feat: user api requests to resource API (#9794)

# Which Problems Are Solved

This pull request addresses a significant gap in the user service v2
API, which currently lacks methods for managing machine users.

# How the Problems Are Solved

This PR adds new API endpoints to the user service v2 to manage machine
users including their secret, keys and personal access tokens.
Additionally, there's now a CreateUser and UpdateUser endpoints which
allow to create either a human or machine user and update them. The
existing `CreateHumanUser` endpoint has been deprecated along the
corresponding management service endpoints. For details check the
additional context section.

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/9349

## More details
- API changes: https://github.com/zitadel/zitadel/pull/9680
- Implementation: https://github.com/zitadel/zitadel/pull/9763
- Tests: https://github.com/zitadel/zitadel/pull/9771

## Follow-ups

- Metadata: support managing user metadata using resource API
https://github.com/zitadel/zitadel/pull/10005
- Machine token type: support managing the machine token type (migrate
to new enum with zero value unspecified?)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Elio Bischof
2025-06-04 09:17:23 +02:00
committed by GitHub
parent e2a61a6002
commit 8fc11a7366
86 changed files with 7033 additions and 536 deletions

View File

@@ -22,7 +22,7 @@ func (c *Commands) AddInstanceMemberCommand(a *instance.Aggregate, userID string
return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-4m0fS", "Errors.IAM.MemberInvalid")
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists {
if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists {
return nil, zerrors.ThrowPreconditionFailed(err, "INSTA-GSXOn", "Errors.User.NotFound")
}
if isMember, err := IsInstanceMember(ctx, filter, a.ID, userID); err != nil || isMember {

View File

@@ -28,7 +28,7 @@ func (c *Commands) AddOrgMemberCommand(a *org.Aggregate, userID string, roles ..
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists {
if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists {
return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound")
}
if isMember, err := IsOrgMember(ctx, filter, a.ID, userID); err != nil || isMember {

View File

@@ -353,21 +353,27 @@ func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner
return writeModel, nil
}
func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string) (exists bool, err error) {
func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string, machineOnly bool) (exists bool, err error) {
eventTypes := []eventstore.EventType{
user.MachineAddedEventType,
user.UserRemovedType,
}
if !machineOnly {
eventTypes = append(eventTypes,
user.HumanRegisteredType,
user.UserV1RegisteredType,
user.HumanAddedType,
user.UserV1AddedType,
)
}
events, err := filter(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(resourceOwner).
OrderAsc().
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(id).
EventTypes(
user.HumanRegisteredType,
user.UserV1RegisteredType,
user.HumanAddedType,
user.UserV1AddedType,
user.MachineAddedEventType,
user.UserRemovedType,
).Builder())
EventTypes(eventTypes...).
Builder())
if err != nil {
return false, err
}

View File

@@ -25,6 +25,7 @@ type Machine struct {
Name string
Description string
AccessTokenType domain.OIDCTokenType
PermissionCheck PermissionCheck
}
func (m *Machine) IsZero() bool {
@@ -33,8 +34,8 @@ func (m *Machine) IsZero() bool {
func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if a.ResourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown2", "Errors.ResourceOwnerMissing")
if a.ResourceOwner == "" && machine.PermissionCheck == nil {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing")
}
if a.ID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p2mi", "Errors.User.UserIDMissing")
@@ -49,7 +50,7 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter)
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck)
if err != nil {
return nil, err
}
@@ -67,7 +68,18 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati
}
}
func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain.ObjectDetails, err error) {
type addMachineOption func(context.Context, *Machine) error
func AddMachineWithUsernameToIDFallback() addMachineOption {
return func(ctx context.Context, m *Machine) error {
if m.Username == "" {
m.Username = m.AggregateID
}
return nil
}
}
func (c *Commands) AddMachine(ctx context.Context, machine *Machine, check PermissionCheck, options ...addMachineOption) (_ *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -80,6 +92,16 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain.
}
agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner)
for _, option := range options {
if err = option(ctx, machine); err != nil {
return nil, err
}
}
if check != nil {
if err = check(machine.ResourceOwner, machine.AggregateID); err != nil {
return nil, err
}
}
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, AddMachineCommand(agg, machine))
if err != nil {
return nil, err
@@ -97,6 +119,7 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain.
}, nil
}
// Deprecated: use ChangeUserMachine instead
func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain.ObjectDetails, error) {
agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, changeMachineCommand(agg, machine))
@@ -118,24 +141,21 @@ func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain
func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if a.ResourceOwner == "" {
if a.ResourceOwner == "" && machine.PermissionCheck == nil {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing")
}
if a.ID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p3mi", "Errors.User.UserIDMissing")
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter)
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck)
if err != nil {
return nil, err
}
if !isUserStateExists(writeModel.UserState) {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-5M0od", "Errors.User.NotFound")
}
changedEvent, hasChanged, err := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType)
if err != nil {
return nil, err
}
changedEvent, hasChanged := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType)
if !hasChanged {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2n8vs", "Errors.User.NotChanged")
}
@@ -147,10 +167,9 @@ func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Valid
}
}
func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer) (_ *MachineWriteModel, err error) {
func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer, permissionCheck PermissionCheck) (_ *MachineWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel := NewMachineWriteModel(userID, resourceOwner)
events, err := filter(ctx, writeModel.Query())
if err != nil {
@@ -161,5 +180,10 @@ func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, fil
}
writeModel.AppendEvents(events...)
err = writeModel.Reduce()
if permissionCheck != nil {
if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil {
return nil, err
}
}
return writeModel, err
}

View File

@@ -15,12 +15,14 @@ import (
)
type AddMachineKey struct {
Type domain.AuthNKeyType
ExpirationDate time.Time
Type domain.AuthNKeyType
ExpirationDate time.Time
PermissionCheck PermissionCheck
}
type MachineKey struct {
models.ObjectRoot
PermissionCheck PermissionCheck
KeyID string
Type domain.AuthNKeyType
@@ -64,7 +66,7 @@ func (key *MachineKey) Detail() ([]byte, error) {
}
func (key *MachineKey) content() error {
if key.ResourceOwner == "" {
if key.PermissionCheck == nil && key.ResourceOwner == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-kqpoix", "Errors.ResourceOwnerMissing")
}
if key.AggregateID == "" {
@@ -91,7 +93,7 @@ func (key *MachineKey) valid() (err error) {
}
func (key *MachineKey) checkAggregate(ctx context.Context, filter preparation.FilterToQueryReducer) error {
if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner); err != nil || !exists {
if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner, true); err != nil || !exists {
return zerrors.ThrowPreconditionFailed(err, "COMMAND-bnipwm1", "Errors.User.NotFound")
}
return nil
@@ -142,7 +144,7 @@ func prepareAddUserMachineKey(machineKey *MachineKey, keySize int) preparation.V
return nil, err
}
}
writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner)
writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck)
if err != nil {
return nil, err
}
@@ -186,7 +188,7 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation
return nil, err
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner)
writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck)
if err != nil {
return nil, err
}
@@ -204,16 +206,18 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation
}
}
func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string) (_ *MachineKeyWriteModel, err error) {
func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string, permissionCheck PermissionCheck) (_ *MachineKeyWriteModel, err error) {
writeModel := NewMachineKeyWriteModel(userID, keyID, resourceOwner)
events, err := filter(ctx, writeModel.Query())
if err != nil {
return nil, err
}
if len(events) == 0 {
return writeModel, nil
}
writeModel.AppendEvents(events...)
err = writeModel.Reduce()
if permissionCheck != nil {
if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil {
return nil, err
}
}
return writeModel, err
}

View File

@@ -2,7 +2,6 @@ package command
import (
"context"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
@@ -106,9 +105,8 @@ func (wm *MachineWriteModel) NewChangedEvent(
name,
description string,
accessTokenType domain.OIDCTokenType,
) (*user.MachineChangedEvent, bool, error) {
) (*user.MachineChangedEvent, bool) {
changes := make([]user.MachineChanges, 0)
var err error
if wm.Name != name {
changes = append(changes, user.ChangeName(name))
@@ -120,11 +118,8 @@ func (wm *MachineWriteModel) NewChangedEvent(
changes = append(changes, user.ChangeAccessTokenType(accessTokenType))
}
if len(changes) == 0 {
return nil, false, nil
return nil, false
}
changeEvent, err := user.NewMachineChangedEvent(ctx, aggregate, changes)
if err != nil {
return nil, false, err
}
return changeEvent, true, nil
changeEvent := user.NewMachineChangedEvent(ctx, aggregate, changes)
return changeEvent, true
}

View File

@@ -11,7 +11,8 @@ import (
)
type GenerateMachineSecret struct {
ClientSecret string
PermissionCheck PermissionCheck
ClientSecret string
}
func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, resourceOwner string, set *GenerateMachineSecret) (*domain.ObjectDetails, error) {
@@ -35,14 +36,14 @@ func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, res
func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *GenerateMachineSecret) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if a.ResourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing")
if a.ResourceOwner == "" && set.PermissionCheck == nil {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing")
}
if a.ID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzoqjs", "Errors.User.UserIDMissing")
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter)
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, set.PermissionCheck)
if err != nil {
return nil, err
}
@@ -62,9 +63,10 @@ func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *Generate
}
}
func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string) (*domain.ObjectDetails, error) {
func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string, permissionCheck PermissionCheck) (*domain.ObjectDetails, error) {
agg := user.NewAggregate(userID, resourceOwner)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg))
//nolint:staticcheck
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg, permissionCheck))
if err != nil {
return nil, err
}
@@ -81,16 +83,16 @@ func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resou
}, nil
}
func prepareRemoveMachineSecret(a *user.Aggregate) preparation.Validation {
func prepareRemoveMachineSecret(a *user.Aggregate, check PermissionCheck) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if a.ResourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing")
if a.ResourceOwner == "" && check == nil {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing")
}
if a.ID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzosjs", "Errors.User.UserIDMissing")
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter)
writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, check)
if err != nil {
return nil, err
}

View File

@@ -44,7 +44,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) {
ctx: context.Background(),
userID: "",
resourceOwner: "org1",
set: nil,
set: new(GenerateMachineSecret),
},
res: res{
err: zerrors.IsErrorInvalidArgument,
@@ -59,7 +59,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) {
ctx: context.Background(),
userID: "user1",
resourceOwner: "",
set: nil,
set: new(GenerateMachineSecret),
},
res: res{
err: zerrors.IsErrorInvalidArgument,
@@ -76,7 +76,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) {
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
set: nil,
set: new(GenerateMachineSecret),
},
res: res{
err: zerrors.IsPreconditionFailed,
@@ -289,7 +289,7 @@ func TestCommandSide_RemoveMachineSecret(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, nil)
if tt.res.err == nil {
assert.NoError(t, err)
}

View File

@@ -24,6 +24,8 @@ func TestCommandSide_AddMachine(t *testing.T) {
type args struct {
ctx context.Context
machine *Machine
check PermissionCheck
options func(*Commands) []addMachineOption
}
type res struct {
want *domain.ObjectDetails
@@ -194,14 +196,242 @@ func TestCommandSide_AddMachine(t *testing.T) {
},
},
},
{
name: "with username fallback to given username",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"),
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
true,
true,
true,
),
),
),
expectPush(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
"username",
"name",
"",
true,
domain.OIDCTokenTypeBearer,
),
),
),
},
args: args{
ctx: context.Background(),
machine: &Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "name",
Username: "username",
},
options: func(commands *Commands) []addMachineOption {
return []addMachineOption{
AddMachineWithUsernameToIDFallback(),
}
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "with username fallback to generated id",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"),
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
true,
true,
true,
),
),
),
expectPush(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
"aggregateID",
"name",
"",
true,
domain.OIDCTokenTypeBearer,
),
),
),
},
args: args{
ctx: context.Background(),
machine: &Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "name",
},
options: func(commands *Commands) []addMachineOption {
return []addMachineOption{
AddMachineWithUsernameToIDFallback(),
}
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "with username fallback to given id",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
true,
true,
true,
),
),
),
expectPush(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("aggregateID", "org1").Aggregate,
"aggregateID",
"name",
"",
true,
domain.OIDCTokenTypeBearer,
),
),
),
},
args: args{
ctx: context.Background(),
machine: &Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
AggregateID: "aggregateID",
},
Name: "name",
},
options: func(commands *Commands) []addMachineOption {
return []addMachineOption{
AddMachineWithUsernameToIDFallback(),
}
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "with succeeding permission check, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
true,
true,
true,
),
),
),
expectPush(
user.NewMachineAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"name",
"description",
true,
domain.OIDCTokenTypeBearer,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
},
args: args{
ctx: context.Background(),
machine: &Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Description: "description",
Name: "name",
Username: "username",
},
check: func(resourceOwner, aggregateID string) error {
return nil
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "with failing permission check, error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
},
args: args{
ctx: context.Background(),
machine: &Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Description: "description",
Name: "name",
Username: "username",
},
check: func(resourceOwner, aggregateID string) error {
return zerrors.ThrowPermissionDenied(nil, "", "")
},
},
res: res{
err: zerrors.IsPermissionDenied,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
checkPermission: newMockPermissionCheckAllowed(),
}
got, err := r.AddMachine(tt.args.ctx, tt.args.machine)
var options []addMachineOption
if tt.args.options != nil {
options = tt.args.options(r)
}
got, err := r.AddMachine(tt.args.ctx, tt.args.machine, tt.args.check, options...)
if tt.res.err == nil {
assert.NoError(t, err)
}
@@ -391,7 +621,7 @@ func TestCommandSide_ChangeMachine(t *testing.T) {
}
func newMachineChangedEvent(ctx context.Context, userID, resourceOwner, name, description string) *user.MachineChangedEvent {
event, _ := user.NewMachineChangedEvent(ctx,
event := user.NewMachineChangedEvent(ctx,
&user.NewAggregate(userID, resourceOwner).Aggregate,
[]user.MachineChanges{
user.ChangeName(name),

View File

@@ -21,6 +21,7 @@ type AddPat struct {
type PersonalAccessToken struct {
models.ObjectRoot
PermissionCheck PermissionCheck
ExpirationDate time.Time
Scopes []string
@@ -43,7 +44,7 @@ func NewPersonalAccessToken(resourceOwner string, userID string, expirationDate
}
func (pat *PersonalAccessToken) content() error {
if pat.ResourceOwner == "" {
if pat.ResourceOwner == "" && pat.PermissionCheck == nil {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-xs0k2n", "Errors.ResourceOwnerMissing")
}
if pat.AggregateID == "" {
@@ -109,11 +110,10 @@ func prepareAddPersonalAccessToken(pat *PersonalAccessToken, algorithm crypto.En
if err := pat.checkAggregate(ctx, filter); err != nil {
return nil, err
}
writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner)
writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck)
if err != nil {
return nil, err
}
pat.Token, err = createToken(algorithm, writeModel.TokenID, writeModel.AggregateID)
if err != nil {
return nil, err
@@ -155,7 +155,7 @@ func prepareRemovePersonalAccessToken(pat *PersonalAccessToken) preparation.Vali
return nil, err
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) (_ []eventstore.Command, err error) {
writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner)
writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck)
if err != nil {
return nil, err
}
@@ -181,16 +181,18 @@ func createToken(algorithm crypto.EncryptionAlgorithm, tokenID, userID string) (
return base64.RawURLEncoding.EncodeToString(encrypted), nil
}
func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string) (_ *PersonalAccessTokenWriteModel, err error) {
func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string, check PermissionCheck) (_ *PersonalAccessTokenWriteModel, err error) {
writeModel := NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner)
events, err := filter(ctx, writeModel.Query())
if err != nil {
return nil, err
}
if len(events) == 0 {
return writeModel, nil
}
writeModel.AppendEvents(events...)
err = writeModel.Reduce()
if err = writeModel.Reduce(); err != nil {
return nil, err
}
if check != nil {
err = check(writeModel.ResourceOwner, writeModel.AggregateID)
}
return writeModel, err
}

View File

@@ -1813,7 +1813,7 @@ func TestExistsUser(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner)
gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner, false)
if (err != nil) != tt.wantErr {
t.Errorf("ExistsUser() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -132,7 +132,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing")
}
existingUser, err := c.userRemoveWriteModel(ctx, userID, resourceOwner)
if err != nil {
return nil, err
@@ -143,7 +142,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin
if err := c.checkPermissionDeleteUser(ctx, existingUser.ResourceOwner, existingUser.AggregateID); err != nil {
return nil, err
}
domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner)
if err != nil {
return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-l40ykb3xh2", "Errors.Org.DomainPolicy.NotExisting")

View File

@@ -5,6 +5,7 @@ import (
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
@@ -121,7 +122,10 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
if resourceOwner == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMA-095xh8fll1", "Errors.Internal")
}
if human.Details == nil {
human.Details = &domain.ObjectDetails{}
}
human.Details.ResourceOwner = resourceOwner
if err := human.Validate(c.userPasswordHasher); err != nil {
return err
}
@@ -132,7 +136,12 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
return err
}
}
// check for permission to create user on resourceOwner
if !human.Register {
if err := c.checkPermissionUpdateUser(ctx, resourceOwner, human.ID); err != nil {
return err
}
}
// only check if user is already existing
existingHuman, err := c.userExistsWriteModel(
ctx,
@@ -144,12 +153,6 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
if isUserStateExists(existingHuman.UserState) {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting")
}
// check for permission to create user on resourceOwner
if !human.Register {
if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil {
return err
}
}
// add resourceowner for the events with the aggregate
existingHuman.ResourceOwner = resourceOwner
@@ -161,6 +164,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
if err = c.userValidateDomain(ctx, resourceOwner, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil {
return err
}
var createCmd humanCreationCommand
if human.Register {
createCmd = user.NewHumanRegisteredEvent(
@@ -203,17 +207,33 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
return err
}
cmds := make([]eventstore.Command, 0, 3)
cmds = append(cmds, createCmd)
cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail)
cmds, err := c.addUserHumanCommands(ctx, filter, existingHuman, human, allowInitMail, alg, createCmd)
if err != nil {
return err
}
if len(cmds) == 0 {
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil
}
err = c.pushAppendAndReduce(ctx, existingHuman, cmds...)
if err != nil {
return err
}
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil
}
func (c *Commands) addUserHumanCommands(ctx context.Context, filter preparation.FilterToQueryReducer, existingHuman *UserV2WriteModel, human *AddHuman, allowInitMail bool, alg crypto.EncryptionAlgorithm, addUserCommand eventstore.Command) ([]eventstore.Command, error) {
cmds := []eventstore.Command{addUserCommand}
var err error
cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail)
if err != nil {
return nil, err
}
cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, existingHuman.Aggregate(), human, alg)
if err != nil {
return err
return nil, err
}
for _, metadataEntry := range human.Metadata {
@@ -227,7 +247,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
for _, link := range human.Links {
cmd, err := addLink(ctx, filter, existingHuman.Aggregate(), link)
if err != nil {
return err
return nil, err
}
cmds = append(cmds, cmd)
}
@@ -235,7 +255,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
if human.TOTPSecret != "" {
encryptedSecret, err := crypto.Encrypt([]byte(human.TOTPSecret), c.multifactors.OTP.CryptoMFA)
if err != nil {
return err
return nil, err
}
cmds = append(cmds,
user.NewHumanOTPAddedEvent(ctx, &existingHuman.Aggregate().Aggregate, encryptedSecret),
@@ -246,18 +266,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human
if human.SetInactive {
cmds = append(cmds, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate))
}
if len(cmds) == 0 {
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil
}
err = c.pushAppendAndReduce(ctx, existingHuman, cmds...)
if err != nil {
return err
}
human.Details = writeModelToObjectDetails(&existingHuman.WriteModel)
return nil
return cmds, nil
}
func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg crypto.EncryptionAlgorithm) (err error) {
@@ -341,7 +350,6 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg
if human.State != nil {
// only allow toggling between active and inactive
// any other target state is not supported
// the existing human's state has to be the
switch {
case isUserStateActive(*human.State):
if isUserStateActive(existingHuman.UserState) {

View File

@@ -302,9 +302,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) {
{
name: "add human (with initial code), no permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckNotAllowed(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
newCode: mockEncryptedCode("userinit", time.Hour),
@@ -326,9 +324,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) {
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
res: res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
},
err: zerrors.IsPermissionDenied,
},
},
{

View File

@@ -352,6 +352,7 @@ func TestCommands_ResendInviteCode(t *testing.T) {
"user does not exist",
fields{
eventstore: expectEventstore(
// The write model doesn't query any events
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),

View File

@@ -0,0 +1,94 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type ChangeMachine struct {
ID string
ResourceOwner string
Username *string
Name *string
Description *string
// Details are set after a successful execution of the command
Details *domain.ObjectDetails
}
func (h *ChangeMachine) Changed() bool {
if h.Username != nil {
return true
}
if h.Name != nil {
return true
}
if h.Description != nil {
return true
}
return false
}
func (c *Commands) ChangeUserMachine(ctx context.Context, machine *ChangeMachine) (err error) {
existingMachine, err := c.UserMachineWriteModel(
ctx,
machine.ID,
machine.ResourceOwner,
false,
)
if err != nil {
return err
}
if machine.Changed() {
if err := c.checkPermissionUpdateUser(ctx, existingMachine.ResourceOwner, existingMachine.AggregateID); err != nil {
return err
}
}
cmds := make([]eventstore.Command, 0)
if machine.Username != nil {
cmds, err = c.changeUsername(ctx, cmds, existingMachine, *machine.Username)
if err != nil {
return err
}
}
var machineChanges []user.MachineChanges
if machine.Name != nil && *machine.Name != existingMachine.Name {
machineChanges = append(machineChanges, user.ChangeName(*machine.Name))
}
if machine.Description != nil && *machine.Description != existingMachine.Description {
machineChanges = append(machineChanges, user.ChangeDescription(*machine.Description))
}
if len(machineChanges) > 0 {
cmds = append(cmds, user.NewMachineChangedEvent(ctx, &existingMachine.Aggregate().Aggregate, machineChanges))
}
if len(cmds) == 0 {
machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel)
return nil
}
err = c.pushAppendAndReduce(ctx, existingMachine, cmds...)
if err != nil {
return err
}
machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel)
return nil
}
func (c *Commands) UserMachineWriteModel(ctx context.Context, userID, resourceOwner string, metadataWM bool) (writeModel *UserV2WriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewUserMachineWriteModel(userID, resourceOwner, metadataWM)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
if !isUserStateExists(writeModel.UserState) {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")
}
return writeModel, nil
}

View File

@@ -0,0 +1,260 @@
package command
import (
"context"
"errors"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommandSide_ChangeUserMachine(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
orgID string
machine *ChangeMachine
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
userAgg := user.NewAggregate("user1", "org1")
userAddedEvent := user.NewMachineAddedEvent(context.Background(),
&userAgg.Aggregate,
"username",
"name",
"description",
true,
domain.OIDCTokenTypeBearer,
)
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "change machine username, no permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Username: gu.Ptr("changed"),
},
},
res: res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
},
},
},
{
name: "change machine username, not found",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Username: gu.Ptr("changed"),
},
},
res: res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound"))
},
},
},
{
name: "change machine username, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&userAgg.Aggregate,
true,
true,
true,
),
),
),
expectPush(
user.NewUsernameChangedEvent(context.Background(),
&userAgg.Aggregate,
"username",
"changed",
true,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Username: gu.Ptr("changed"),
},
},
res: res{
want: &domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
},
},
{
name: "change machine username, no change",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Username: gu.Ptr("username"),
},
},
res: res{
want: &domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
},
},
{
name: "change machine description, no permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Description: gu.Ptr("changed"),
},
},
res: res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
},
},
},
{
name: "change machine description, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
expectPush(
user.NewMachineChangedEvent(context.Background(),
&userAgg.Aggregate,
[]user.MachineChanges{
user.ChangeDescription("changed"),
},
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Description: gu.Ptr("changed"),
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "change machine description, no change",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(userAddedEvent),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
orgID: "org1",
machine: &ChangeMachine{
Description: gu.Ptr("description"),
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: tt.fields.checkPermission,
}
err := r.ChangeUserMachine(tt.args.ctx, tt.args.machine)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
}
} else if !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
return
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, tt.args.machine.Details)
}
})
}
}

View File

@@ -118,6 +118,14 @@ func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, ph
return newUserV2WriteModel(userID, resourceOwner, opts...)
}
func NewUserMachineWriteModel(userID, resourceOwner string, metadataListWM bool) *UserV2WriteModel {
opts := []UserV2WMOption{WithMachine(), WithState()}
if metadataListWM {
opts = append(opts, WithMetadata())
}
return newUserV2WriteModel(userID, resourceOwner, opts...)
}
func newUserV2WriteModel(userID, resourceOwner string, opts ...UserV2WMOption) *UserV2WriteModel {
wm := &UserV2WriteModel{
WriteModel: eventstore.WriteModel{