diff --git a/backend/internal/flow/executor/provisioning_executor.go b/backend/internal/flow/executor/provisioning_executor.go index 843b69c556..7a5374d6cc 100644 --- a/backend/internal/flow/executor/provisioning_executor.go +++ b/backend/internal/flow/executor/provisioning_executor.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "slices" "strings" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" @@ -125,7 +127,7 @@ func (p *provisioningExecutor) Execute(ctx *providers.NodeContext) (*providers.E return execResp, nil } - identifyingAttrs, credentialAttrs, err := p.getAttributesForProvisioning(ctx) + identifyingAttrs, credentialAttrs, uniqueAttrs, err := p.getAttributesForProvisioning(ctx) if err != nil { return nil, err } @@ -136,23 +138,9 @@ func (p *provisioningExecutor) Execute(ctx *providers.NodeContext) (*providers.E return execResp, nil } - userID, err := p.IdentifyUser(ctx.Context, identifyingAttrs, execResp) + userID, err := p.identifyExistingUser(ctx, uniqueAttrs, execResp, logger) if err != nil { - logger.Error(ctx.Context, "Failed to identify user", log.Error(err)) - execResp.Status = providers.ExecFailure - execResp.Error = &ErrFailedToIdentifyUser - return execResp, nil - } - if execResp.Status == providers.ExecFailure && - execResp.Error != nil && execResp.Error.Code == ErrAmbiguousUserIdentity.Code && - isCrossOUProvisioningAllowed(ctx) { - resolved, err := p.resolveAmbiguousUserForProvisioning(ctx, identifyingAttrs) - if err != nil { - return nil, err - } - userID = resolved - execResp.Status = "" - execResp.Error = nil + return nil, err } if execResp.Status == providers.ExecFailure && (execResp.Error == nil || execResp.Error.Code != ErrUserNotFound.Code) { @@ -220,6 +208,74 @@ func (p *provisioningExecutor) Execute(ctx *providers.NodeContext) (*providers.E return execResp, nil } +// identifyExistingUser returns the user that would conflict with the one about to be provisioned, +// or nil when there is none. A user this execution already resolved wins over the attribute lookup, +// which resolves on a broader set and can pick a different user or none. +func (p *provisioningExecutor) identifyExistingUser(ctx *providers.NodeContext, + uniqueAttrs map[string]interface{}, execResp *providers.ExecutorResponse, + logger *log.Logger) (*string, error) { + if resolvedID := p.GetUserIDFromContext(ctx, execResp, p.authnProvider); resolvedID != "" { + return &resolvedID, nil + } + + if len(uniqueAttrs) == 0 { + logger.Debug(ctx.Context, "No unique attributes to identify an existing user with") + return nil, nil + } + + // One lookup per attribute: a combined filter is conjunctive, while the store rejects the write + // when any single value is taken. Name order keeps the reported conflict stable. + for _, attr := range slices.Sorted(maps.Keys(uniqueAttrs)) { + filter := map[string]interface{}{attr: uniqueAttrs[attr]} + execResp.Status = "" + execResp.Error = nil + + userID, err := p.IdentifyUser(ctx.Context, filter, execResp) + if err != nil { + logger.Error(ctx.Context, "Failed to identify user", log.Error(err)) + execResp.Status = providers.ExecFailure + execResp.Error = &ErrFailedToIdentifyUser + return nil, nil + } + + if execResp.Status == providers.ExecFailure { + code := "" + if execResp.Error != nil { + code = execResp.Error.Code + } + switch code { + case ErrUserNotFound.Code: + // Free; a later unique attribute can still conflict. + continue + case ErrAmbiguousUserIdentity.Code: + if !isCrossOUProvisioningAllowed(ctx) { + return nil, nil + } + resolved, err := p.resolveAmbiguousUserForProvisioning(ctx, filter) + if err != nil { + return nil, err + } + execResp.Status = "" + execResp.Error = nil + if resolved != nil { + return resolved, nil + } + continue + default: + return nil, nil + } + } + + if userID != nil && *userID != "" { + logger.Debug(ctx.Context, "An existing user already holds a unique attribute value", + log.String("attribute", attr)) + return userID, nil + } + } + + return nil, nil +} + // authenticateProvisionedUser authenticates the newly provisioned user and updates the executor response. func (p *provisioningExecutor) authenticateProvisionedUser(ctx *providers.NodeContext, userID string, execResp *providers.ExecutorResponse) { @@ -579,24 +635,26 @@ func (p *provisioningExecutor) isAttrSatisfied(ctx *providers.NodeContext, attr } // getAttributesForProvisioning collects user attributes from context in a single schema pass, -// returning identifying (non-credential) and credential attributes as separate maps. -// Schema is the whitelist for both maps. +// returning identifying (non-credential), credential, and unique attributes as separate maps. +// Schema is the whitelist for all three maps. uniqueAttrs is the subset of identifyingAttrs the +// schema declares unique, and is the only set that can identify a conflicting user. // Credential values are resolved from non-empty UserInputs then non-empty RuntimeData only. // Non-credential values additionally fall back to AuthenticatedUser.Attributes, and are converted // from the engine's string representation to the type declared by the schema attribute. func (p *provisioningExecutor) getAttributesForProvisioning( ctx *providers.NodeContext, -) (identifyingAttrs map[string]interface{}, credentialAttrs map[string]interface{}, err error) { +) (identifyingAttrs, credentialAttrs, uniqueAttrs map[string]interface{}, err error) { schemaAttrs, fetchErr := p.fetchSchemaAttributes(ctx, true, true) if fetchErr != nil { - return nil, nil, fetchErr + return nil, nil, nil, fetchErr } identifyingAttrs = make(map[string]interface{}) credentialAttrs = make(map[string]interface{}) + uniqueAttrs = make(map[string]interface{}) if len(schemaAttrs) == 0 { - return identifyingAttrs, credentialAttrs, nil + return identifyingAttrs, credentialAttrs, uniqueAttrs, nil } for _, a := range schemaAttrs { @@ -612,10 +670,15 @@ func (p *provisioningExecutor) getAttributesForProvisioning( } else if runtimeValue, exists := ctx.RuntimeData[a.Attribute]; exists && runtimeValue != "" { identifyingAttrs[a.Attribute] = convertToSchemaType(runtimeValue, a.Type) } + if a.Unique { + if value, exists := identifyingAttrs[a.Attribute]; exists { + uniqueAttrs[a.Attribute] = value + } + } } } - return identifyingAttrs, credentialAttrs, nil + return identifyingAttrs, credentialAttrs, uniqueAttrs, nil } // createUserInStore provisions a user through the user management provider. The organization unit diff --git a/backend/internal/flow/executor/provisioning_executor_test.go b/backend/internal/flow/executor/provisioning_executor_test.go index 319e8eb943..bbdffd2dea 100644 --- a/backend/internal/flow/executor/provisioning_executor_test.go +++ b/backend/internal/flow/executor/provisioning_executor_test.go @@ -87,16 +87,29 @@ func (suite *ProvisioningExecutorTestSuite) SetupTest() { // expectSchemaForProvisioning sets up the schema service mocks for Execute tests. // The (true,true) mock covers both HasRequiredInputs and getAttributesForProvisioning. // This version does NOT include credentials - use expectSchemaWithCredentials if needed. +// Every attribute is marked unique so the existing-user lookup filter matches the full attribute +// set, keeping these tests focused on Execute's branching. Scoping of that filter to unique +// attributes is covered separately by TestExecute_NoUniqueAttributes_SkipsIdentify and +// TestExecute_OnlyUniqueAttributesIdentify. func (suite *ProvisioningExecutorTestSuite) expectSchemaForProvisioning() { suite.mockEntityTypeService.On("GetAttributes", mock.Anything, mock.Anything, testUserType, model.AttributeFilter{AllowCredential: true, AllowNonCredential: true}). Return([]model.AttributeInfo{ - {Attribute: "username", Required: false}, - {Attribute: attributeEmail, Required: false}, - {Attribute: "sub", Required: false}, + {Attribute: "username", Required: false, Unique: true}, + {Attribute: attributeEmail, Required: false, Unique: true}, + {Attribute: "sub", Required: false, Unique: true}, }, nil).Maybe() } +// expectNoExistingUserFor mocks the existing-user lookup as finding nothing, one expectation per +// attribute since each is looked up on its own. +func (suite *ProvisioningExecutorTestSuite) expectNoExistingUserFor(attrs map[string]interface{}) { + for attr, value := range attrs { + suite.mockEntityProvider.On("IdentifyEntity", map[string]interface{}{attr: value}). + Return(nil, entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + } +} + func (suite *ProvisioningExecutorTestSuite) createMockIdentifyingExecutor() providers.Executor { mockExec := coremock.NewExecutorInterfaceMock(suite.T()) mockExec.On("GetName").Return(ExecutorNameIdentifying).Maybe() @@ -126,6 +139,24 @@ func (suite *ProvisioningExecutorTestSuite) createMockProvisioningExecutor() pro } return len(execResp.Inputs) == 0 }).Maybe() + // Stands in for the embedded base (internal/flow/core/executor.go), which the mocked base + // intercepts: a pre-resolved user ID in runtime data wins, then the resolved entity reference. + mockExec.On("GetUserIDFromContext", mock.Anything, mock.Anything, mock.Anything).Return( + func(ctx *providers.NodeContext, execResp *providers.ExecutorResponse, + authnProvider providers.AuthnProviderManager) string { + if val, ok := ctx.RuntimeData[userAttributeUserID]; ok && val != "" { + return val + } + if authnProvider == nil || !ctx.AuthUser.IsAuthenticated() { + return "" + } + authUser, entityRef, err := authnProvider.GetEntityReference(ctx.Context, ctx.AuthUser) + execResp.AuthUser = authUser + if err != nil || entityRef == nil { + return "" + } + return entityRef.EntityID + }).Maybe() mockExec.On("GetInputs", mock.Anything).Return([]providers.Input{}).Maybe() mockExec.On(methodGetRequiredInputs, mock.Anything).Return([]providers.Input{}).Maybe() return mockExec @@ -144,6 +175,88 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_NonRegistrationFlow() { assert.Equal(suite.T(), providers.ExecComplete, resp.Status) } +// TestExecute_NoUniqueAttributes_SkipsIdentify verifies that a user type declaring no unique +// attributes provisions without an existing-user lookup. Nothing can conflict, so two users with +// identical attributes are both legal and the store is the only uniqueness authority. +func (suite *ProvisioningExecutorTestSuite) TestExecute_NoUniqueAttributes_SkipsIdentify() { + suite.mockEntityTypeService.On("GetAttributes", mock.Anything, mock.Anything, testUserType, + model.AttributeFilter{AllowCredential: true, AllowNonCredential: true}). + Return([]model.AttributeInfo{ + {Attribute: "firstName", Required: true}, + {Attribute: "lastName", Required: true}, + }, nil).Maybe() + + ctx := &providers.NodeContext{ + ExecutionID: "flow-123", + FlowType: providers.FlowTypeRegistration, + UserInputs: map[string]string{ + "firstName": "John", + "lastName": "Smith", + }, + RuntimeData: map[string]string{ + ouIDKey: testOUID, + userTypeKey: testUserType, + }, + NodeInputs: []providers.Input{ + {Identifier: "firstName", Type: "string", Required: true}, + {Identifier: "lastName", Type: "string", Required: true}, + }, + } + + suite.mockUserMgtProvider.On("CreateUser", mock.Anything, mock.MatchedBy(func(u *providers.User) bool { + return u.OUID == testOUID && u.Type == testUserType + })).Return(&providers.User{ + ID: testNewUserID, OUID: testOUID, Type: testUserType, + }, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) + suite.mockEntityProvider.AssertNotCalled(suite.T(), "IdentifyEntity", mock.Anything) +} + +// TestExecute_OnlyUniqueAttributesIdentify verifies the existing-user lookup filters on the unique +// attribute alone, not on every attribute present. A changed non-unique attribute must not make the +// lookup miss an existing user. +func (suite *ProvisioningExecutorTestSuite) TestExecute_OnlyUniqueAttributesIdentify() { + suite.mockEntityTypeService.On("GetAttributes", mock.Anything, mock.Anything, testUserType, + model.AttributeFilter{AllowCredential: true, AllowNonCredential: true}). + Return([]model.AttributeInfo{ + {Attribute: "username", Required: true}, + {Attribute: attributeEmail, Required: true, Unique: true}, + }, nil).Maybe() + + ctx := &providers.NodeContext{ + ExecutionID: "flow-123", + FlowType: providers.FlowTypeRegistration, + UserInputs: map[string]string{ + "username": "renamed", + attributeEmail: "existing@example.com", + }, + RuntimeData: map[string]string{ + ouIDKey: testOUID, + userTypeKey: testUserType, + }, + NodeInputs: []providers.Input{ + {Identifier: "username", Type: "string", Required: true}, + {Identifier: attributeEmail, Type: "string", Required: true}, + }, + } + + existingUserID := testExistingUser123ID + suite.mockEntityProvider.On("IdentifyEntity", + map[string]interface{}{attributeEmail: "existing@example.com"}). + Return(&existingUserID, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecFailure, resp.Status) + assert.Equal(suite.T(), ErrUserAlreadyExists.Code, resp.Error.Code) + suite.mockUserMgtProvider.AssertNotCalled(suite.T(), "CreateUser") +} + func (suite *ProvisioningExecutorTestSuite) TestExecute_Success() { suite.expectSchemaForProvisioning() attrs := map[string]interface{}{"username": "newuser", attributeEmail: "new@example.com"} @@ -170,10 +283,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Success() { }, } - suite.mockEntityProvider.On("IdentifyEntity", map[string]interface{}{ - "username": "newuser", - attributeEmail: "new@example.com", - }).Return(nil, entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -204,6 +314,109 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Success() { suite.mockRoleAssignmentService.AssertExpectations(suite.T()) } +// TestExecute_ResolvedEntityReference_SkipsIdentify verifies a local user resolved by account +// linking is used as-is, even when a second connection reports a different username. +func (suite *ProvisioningExecutorTestSuite) TestExecute_ResolvedEntityReference_SkipsIdentify() { + suite.expectSchemaForProvisioning() + + ctx := &providers.NodeContext{ + ExecutionID: "flow-123", + FlowType: providers.FlowTypeAuthentication, + AuthUser: newAuthenticatedAuthUser(), + RuntimeData: map[string]string{ + common.RuntimeKeyUserEligibleForProvisioning: dataValueTrue, + userTypeKey: testUserType, + "username": "github-login", + attributeEmail: "existing@example.com", + }, + NodeInputs: []providers.Input{}, + } + + suite.mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(newAuthenticatedAuthUser(), &providers.EntityReference{EntityID: testExistingUser123ID}, + (*tidcommon.ServiceError)(nil)) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) + assert.Nil(suite.T(), resp.Error) + suite.mockEntityProvider.AssertNotCalled(suite.T(), "IdentifyEntity", mock.Anything) + suite.mockUserMgtProvider.AssertNotCalled(suite.T(), "CreateUser") +} + +// TestExecute_ConflictOnSecondUniqueAttribute verifies each unique attribute is looked up on its +// own, so a free value does not mask a taken one. +func (suite *ProvisioningExecutorTestSuite) TestExecute_ConflictOnSecondUniqueAttribute() { + suite.mockEntityTypeService.On("GetAttributes", mock.Anything, mock.Anything, testUserType, + model.AttributeFilter{AllowCredential: true, AllowNonCredential: true}). + Return([]model.AttributeInfo{ + {Attribute: "username", Required: true, Unique: true}, + {Attribute: attributeEmail, Required: true, Unique: true}, + }, nil).Maybe() + + ctx := &providers.NodeContext{ + ExecutionID: "flow-123", + FlowType: providers.FlowTypeRegistration, + UserInputs: map[string]string{ + "username": "existinguser", + attributeEmail: "new@example.com", + }, + RuntimeData: map[string]string{ + ouIDKey: testOUID, + userTypeKey: testUserType, + }, + NodeInputs: []providers.Input{ + {Identifier: "username", Type: "string", Required: true}, + {Identifier: attributeEmail, Type: "string", Required: true}, + }, + } + + suite.mockEntityProvider.On("IdentifyEntity", + map[string]interface{}{attributeEmail: "new@example.com"}). + Return(nil, entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + existingUserID := testExistingUser123ID + suite.mockEntityProvider.On("IdentifyEntity", + map[string]interface{}{"username": "existinguser"}).Return(&existingUserID, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecFailure, resp.Status) + assert.Equal(suite.T(), ErrUserAlreadyExists.Code, resp.Error.Code) + suite.mockEntityProvider.AssertExpectations(suite.T()) + suite.mockUserMgtProvider.AssertNotCalled(suite.T(), "CreateUser") +} + +// TestExecute_PreResolvedUserIDInRuntimeData_SkipsIdentify verifies a user ID an earlier node put +// in runtime data is used without an attribute lookup. +func (suite *ProvisioningExecutorTestSuite) TestExecute_PreResolvedUserIDInRuntimeData_SkipsIdentify() { + suite.expectSchemaForProvisioning() + + ctx := &providers.NodeContext{ + ExecutionID: "flow-123", + FlowType: providers.FlowTypeRegistration, + UserInputs: map[string]string{ + "username": "newuser", + attributeEmail: "new@example.com", + }, + RuntimeData: map[string]string{ + ouIDKey: testOUID, + userTypeKey: testUserType, + userAttributeUserID: testExistingUser123ID, + }, + NodeInputs: []providers.Input{}, + } + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecFailure, resp.Status) + assert.Equal(suite.T(), ErrUserAlreadyExists.Code, resp.Error.Code) + suite.mockEntityProvider.AssertNotCalled(suite.T(), "IdentifyEntity", mock.Anything) + suite.mockUserMgtProvider.AssertNotCalled(suite.T(), "CreateUser") +} + func (suite *ProvisioningExecutorTestSuite) TestExecute_UserAlreadyExists() { suite.expectSchemaForProvisioning() nodeInputs := []providers.Input{{Identifier: "username", Type: "string", Required: true}} @@ -393,7 +606,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Con NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.Equal(suite.T(), true, result["active"]) @@ -417,7 +630,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Unp NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "affirmative", result["active"]) } @@ -431,7 +644,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Sch NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Empty(suite.T(), result) } @@ -453,7 +666,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Sch NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.NotContains(suite.T(), result, "userID") @@ -483,7 +696,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Req NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.Equal(suite.T(), "auth@example.com", result[attributeEmail]) @@ -513,7 +726,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Con NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) // UserInputs is checked first — wins for email. assert.Equal(suite.T(), "userinput@example.com", result[attributeEmail]) @@ -542,7 +755,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_All NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) assert.Equal(suite.T(), "+1234567890", result["phone"], @@ -574,7 +787,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Opt NodeInputs: nodeInputs, } - result, _, _ := exec.getAttributesForProvisioning(ctx) + result, _, _, _ := exec.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) assert.Equal(suite.T(), "+1234567890", result["phone"], @@ -599,7 +812,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Emp NodeInputs: []providers.Input{}, } - _, credentialAttrs, err := suite.executor.getAttributesForProvisioning(ctx) + _, credentialAttrs, _, err := suite.executor.getAttributesForProvisioning(ctx) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "runtime-secret", credentialAttrs[attributePassword]) @@ -623,7 +836,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Cre NodeInputs: []providers.Input{}, } - _, credentialAttrs, err := suite.executor.getAttributesForProvisioning(ctx) + _, credentialAttrs, _, err := suite.executor.getAttributesForProvisioning(ctx) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "input-secret", credentialAttrs[attributePassword]) @@ -674,7 +887,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Fil NodeInputs: nodeInputs, } - result, _, _ := exec.getAttributesForProvisioning(ctx) + result, _, _, _ := exec.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.Equal(suite.T(), "test@example.com", result[attributeEmail]) @@ -708,7 +921,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Fil NodeInputs: nodeInputs, } - result, _, _ := exec.getAttributesForProvisioning(ctx) + result, _, _, _ := exec.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.Equal(suite.T(), "federated@example.com", result[attributeEmail]) @@ -738,7 +951,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Fil NodeInputs: nodeInputs, } - result, _, _ := exec.getAttributesForProvisioning(ctx) + result, _, _, _ := exec.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "userinput@example.com", result[attributeEmail], "UserInputs must win over RuntimeData for the same key") @@ -811,8 +1024,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_NewUser_NoGroupOrRolePro Attributes: attrsJSON, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) suite.mockUserMgtProvider.On("CreateUser", mock.Anything, mock.MatchedBy(func(u *providers.User) bool { return u.OUID == testOUID && u.Type == testUserType })).Return(createdUser, nil) @@ -867,8 +1079,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_UserEligibleForProvision Attributes: attrsJSON, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) suite.mockUserMgtProvider.On("CreateUser", mock.Anything, mock.MatchedBy(func(u *providers.User) bool { return u.OUID == testOUID && u.Type == testUserType })).Return(createdUser, nil) @@ -915,8 +1126,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_UserAutoProvisionedFlag_ Attributes: attrsJSON, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) suite.mockUserMgtProvider.On("CreateUser", mock.Anything, mock.Anything).Return(createdUser, nil) resp, err := suite.executor.Execute(ctx) @@ -1072,8 +1282,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_CreateUserFailures() { attrs := map[string]interface{}{ "username": "newuser", } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) suite.mockUserMgtProvider.On("CreateUser", mock.Anything, mock.Anything). Return(tt.createdUser, tt.createUserError) @@ -1222,8 +1431,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Failure_GroupAssignmentF }, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -1276,8 +1484,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Failure_RoleAssignmentFa }, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -1334,8 +1541,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_GroupWithExistingMembers }, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -1387,8 +1593,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_AuthFlow_AutoProvisionin }, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: "user-provisioned", @@ -1444,10 +1649,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Success_WithGroupAndRole }, } - suite.mockEntityProvider.On("IdentifyEntity", map[string]interface{}{ - "username": "newuser", - attributeEmail: "new@example.com", - }).Return(nil, entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -1501,8 +1703,7 @@ func (suite *ProvisioningExecutorTestSuite) TestExecute_Success_WithMultipleGrou }, } - suite.mockEntityProvider.On("IdentifyEntity", attrs).Return(nil, - entityprovider.NewEntityProviderError(entityprovider.ErrorCodeEntityNotFound, "", "")) + suite.expectNoExistingUserFor(attrs) createdUser := &providers.User{ ID: testNewUserID, @@ -2725,7 +2926,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Sch NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "testuser", result["username"]) assert.Equal(suite.T(), "test@example.com", result[attributeEmail]) @@ -2747,7 +2948,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Opt NodeInputs: []providers.Input{}, } - result, _, _ := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, _ := suite.executor.getAttributesForProvisioning(ctx) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) assert.Equal(suite.T(), "+1234567890", result["phone"], @@ -2765,7 +2966,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Sch NodeInputs: []providers.Input{}, } - result, _, err := suite.executor.getAttributesForProvisioning(ctx) + result, _, _, err := suite.executor.getAttributesForProvisioning(ctx) assert.Nil(suite.T(), result, "schema service error must return nil map") assert.Error(suite.T(), err, "schema service error must propagate as an error") @@ -2788,7 +2989,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Opt NodeInputs: nodeInputs, } - result, _, err := exec.getAttributesForProvisioning(ctx) + result, _, _, err := exec.getAttributesForProvisioning(ctx) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) @@ -3202,7 +3403,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Inc }, } - result, _, err := exec.getAttributesForProvisioning(ctx) + result, _, _, err := exec.getAttributesForProvisioning(ctx) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) @@ -3235,7 +3436,7 @@ func (suite *ProvisioningExecutorTestSuite) TestGetAttributesForProvisioning_Inc NodeProperties: map[string]interface{}{}, } - result, _, err := exec.getAttributesForProvisioning(ctx) + result, _, _, err := exec.getAttributesForProvisioning(ctx) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "user@example.com", result[attributeEmail]) diff --git a/docs/content/guides/flows/advanced-configurations.mdx b/docs/content/guides/flows/advanced-configurations.mdx index f29b8596b6..fb4859d3b1 100644 --- a/docs/content/guides/flows/advanced-configurations.mdx +++ b/docs/content/guides/flows/advanced-configurations.mdx @@ -1127,6 +1127,8 @@ Creates a new user record in the user store. This executor persists the user's i **Prompt batching:** If `maxPerPrompt` is set, the executor forwards only that many missing inputs per prompt cycle, cycling through them across multiple flow iterations. +**How existing users are detected:** Before creating the record, the executor checks whether a conflicting user already exists. It matches only on the attributes that the user type schema marks `unique` and that were collected for the new user, so a changed non-unique attribute does not hide an existing account. Credential attributes are never used for this check. When the user type declares no unique attributes, the executor skips the check and creates the record. The user store validates uniqueness on write in every case. + **Cross-OU provisioning:** When `allowCrossOUProvisioning` is set and the user exists in a different OU, the executor creates the user in the target OU instead of failing. **Executor properties:** diff --git a/docs/content/guides/users/user-type-reference.mdx b/docs/content/guides/users/user-type-reference.mdx index c008fbcfea..3b319ca89d 100644 --- a/docs/content/guides/users/user-type-reference.mdx +++ b/docs/content/guides/users/user-type-reference.mdx @@ -29,7 +29,7 @@ Modifiers add validation and behavior rules to an attribute. You can combine mul | Modifier | Applies To | What It Does | When to Use | |----------|------------|--------------|-------------| | `required` | All types | The attribute must be provided on creation. rejects the request if the value is missing. | Fields essential to the user's identity, such as `email` or `username`. | -| `unique` | `string`, `number` | The value must be unique across all users. rejects creation or update if a duplicate exists. | Natural identifiers like `username`, `email`, or `employeeId`. | +| `unique` | `string`, `number` | The value must be unique across all users. rejects creation or update if a duplicate exists. Provisioning flows also match on these attributes to detect an existing account. | Natural identifiers like `username`, `email`, or `employeeId`. | | `credential` | `string`, `number` | hashes and stores the value securely. Never returned in any API response, even to administrators. | Passwords or other sensitive secrets. | | `enum` | `string`, `number` | Restricts the value to a fixed set of allowed options. rejects any value not in the list. | Controlled vocabularies like a `department` field limited to specific team names. | | `regex` | `string` | Validates the value against a regular expression on creation and update. rejects values that do not match. | Format rules such as email patterns or password complexity requirements. |