Skip to content

Conversation

@jrmccannon
Copy link
Contributor

@jrmccannon jrmccannon commented Oct 23, 2025

🎟️ Tracking

PM-26636

📔 Objective

Add the automatic organization user confirmation feature that allows client applications to automatically confirm organization users. The implementation includes:

  • A new API endpoint (POST /organizations/{orgId}/users/{id}/auto-confirm)
  • A command pattern implementation (AutomaticallyConfirmOrganizationUserCommand) that handles the confirmation workflow including validation, key exchange, event logging, email notifications, device registration cleanup, and optional default collection creation
  • Comprehensive validation logic (AutomaticallyConfirmOrganizationUsersValidator) that enforces organizational policies including Single Organization Policy and Two-Factor Authentication requirements
  • Idempotent confirmation operation that safely handles duplicate requests
  • A new OrganizationUser_AutomaticallyConfirmed event type for audit logging
  • Integration with the v2 utilities framework for standardized command/validation patterns
  • Full test coverage including unit tests, integration tests, and controller tests

The feature is protected behind the AutomaticConfirmUsers feature flag and requires the ManageUsers authorization requirement.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@github-actions
Copy link
Contributor

github-actions bot commented Oct 23, 2025

Logo
Checkmarx One – Scan Summary & Detailsdaeb7a81-79d6-4eb3-a9d3-590bbf4d4f10

Great job! No new security vulnerabilities introduced in this pull request

@codecov
Copy link

codecov bot commented Oct 28, 2025

Codecov Report

❌ Patch coverage is 94.31818% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.12%. Comparing base (9b3adf0) to head (3fb9810).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...nConsole/Controllers/BaseAdminConsoleController.cs 66.66% 4 Missing and 1 partial ⚠️
.../AutomaticallyConfirmOrganizationUsersValidator.cs 91.37% 0 Missing and 5 partials ⚠️
...ser/AutomaticallyConfirmOrganizationUserRequest.cs 77.77% 2 Missing ⚠️
...equirements/SingleOrganizationPolicyRequirement.cs 60.00% 2 Missing ⚠️
...nizationUsers/AcceptedOrganizationUserToConfirm.cs 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6488      +/-   ##
==========================================
+ Coverage   53.08%   57.12%   +4.04%     
==========================================
  Files        1912     1921       +9     
  Lines       85152    85490     +338     
  Branches     7622     7645      +23     
==========================================
+ Hits        45203    48839    +3636     
+ Misses      38190    34812    -3378     
- Partials     1759     1839      +80     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@eliykat eliykat self-requested a review October 29, 2025 05:23
Copy link
Member

@eliykat eliykat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

@eliykat
Copy link
Member

eliykat commented Nov 1, 2025

Also... enjoy your merge errors 😎

…m-user-command

# Conflicts:
#	src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
#	src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
#	test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs
@jrmccannon jrmccannon requested review from eliykat and r-tome November 14, 2025 17:01
/// <li>Creating the default collection if applicable.</li>
/// </ul>
///
/// Each of these actions is performed independently of each other and not guaranteed to be performed in any order.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation inaccuracy: The xmldoc states "Each of these actions is performed independently of each other and not guaranteed to be performed in any order." However, this is incorrect based on the actual implementation.

In AutomaticallyConfirmOrganizationUserCommand.cs:54-60, the code explicitly enforces ordering:

  1. CreateDefaultCollectionsAsync executes before the parallel tasks
  2. Then Task.WhenAll runs the remaining operations in parallel

Additionally, within SyncOrganizationKeysAsync (lines 67-71), there's sequential ordering: DeleteDeviceRegistrationAsync must complete before PushSyncOrganizationKeysAsync.

The documentation should accurately reflect that:

  • Default collection creation happens first (to ensure it exists before sync)
  • Device registration deletion happens before push notification
  • Other side effects (logging, email) execute in parallel without ordering guarantees

Suggested fix: Update xmldoc to describe the actual execution order and dependencies.

OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorization inconsistency: The StandardUser is created with await _currentContext.OrganizationOwner(orgId), which only returns true for Owners, not Admins.

However, the endpoint has [Authorize<ManageUsersRequirement>] which allows both Owners and Admins to manage users.

This creates an inconsistency where:

  • Admins can pass the authorization check
  • But PerformedBy will have IsOwner = false for Admins
  • If any downstream logic depends on IsOwner, Admins won't be able to use this feature despite being authorized

Questions:

  1. Is this intentional? Should only Owners be able to auto-confirm users?
  2. If Admins should also be allowed, consider using _currentContext.OrganizationAdmin(orgId) or a different property name that doesn't imply Owner-only access.

Note: This same pattern exists in other endpoints, but for auto-confirm specifically, the validator doesn't seem to check the PerformedBy.IsOwner property, so this may currently work but is semantically confusing.

return new None();
}

await CreateDefaultCollectionsAsync(validatedData.Request);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Excellent work addressing the previous feedback! Creating the default collection before the parallel tasks ensures it exists before the sync notification is sent to clients. This prevents a race condition where clients might sync before the collection is created.

new Permissions(),
OrganizationUserStatusType.Accepted);

var tenRequests = Enumerable.Range(0, 5)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Test concurrency: Good addition of the concurrent requests test! This validates the idempotency check works correctly.

One minor observation: The test creates 5 concurrent requests and verifies only 1 default collection is created. Consider adding a comment explaining that the single collection creation validates the idempotency (helps future maintainers understand the assertion).

return Invalid(request, new UserIsNotUserType());
}

if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Great addition! Validating that the auto-confirm policy is enabled is critical security validation. This addresses @eliykat's feedback perfectly.

return null;
}

var policyRequirement = await policyRequirementQuery
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 Consider caching optimization: This retrieves all policy requirements for the user, which could be expensive if called frequently. Since this validator already checks the auto-confirm policy via IPolicyRepository, you could potentially consolidate these checks.

However, this is likely fine for now given:

  • Auto-confirm is a relatively infrequent operation
  • The policy requirement abstraction provides good separation of concerns
  • Performance optimization can be addressed later if needed

Non-blocking suggestion: Consider measuring if this becomes a bottleneck in production.

);
}

private async Task SyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Sequential operation question: This method enforces sequential execution: delete device registrations first, then push sync keys.

Question: Is there a dependency between these operations? For example:

  • Should the push notification only go to devices that haven't been unregistered?
  • Or is this just a logical grouping?

If there's no dependency, these could be parallelized with the other operations. If there is a dependency, a comment explaining why would be helpful for future maintainers.

Given the method name SyncOrganizationKeysAsync implies they're related, the sequential execution seems intentional, but documentation would help clarify.

{
try
{
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential null reference: The user is fetched but could theoretically be null. While the validator ensures OrganizationUser.UserId is non-null, there's no guarantee the User entity exists in the database.

Suggested fix:

var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
if (user == null)
{
    logger.LogError("User not found when sending confirmation email.");
    return;
}

await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
    user.Email,
    request.OrganizationUser.AccessSecretsManager);

This is caught by the try-catch, but explicit null checking provides clearer error logging.


public async Task InitializeAsync()
{
_ownerEmail = $"org-user-integration-test-{Guid.NewGuid()}@example.com";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Email domain: Per Claude's earlier feedback, test emails should use @example.com domain (which is now correct here), but I notice you're using dynamic GUIDs in the email addresses.

While this works, consider if you need the GUID uniqueness for these integration tests. If you're creating fresh accounts each time and cleaning them up, simpler email addresses like [email protected] might be more readable in test output and logs.

Non-blocking: This is minor and current approach is acceptable.

public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled.");
public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations");
public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it.");
public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Missing error definition: The validator checks that the auto-confirm policy is enabled (line 54-57 in the validator), and uses AutomaticallyConfirmUsersPolicyIsNotEnabled() error, which is defined here on line 13. Good!

However, @eliykat's feedback also mentioned checking if the organization can use auto-confirm via orgAbility cache. I don't see validation for the organization's ability/plan to use this feature.

Question: Should there be validation that the organization's plan supports auto-confirm? Or is this already handled elsewhere (e.g., by the feature flag or policy enablement check)?

/// <returns>The result is a boolean value indicating whether a default collection should be created.</returns>
private async Task<bool> ShouldCreateDefaultCollectionAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
!string.IsNullOrWhiteSpace(request.DefaultUserCollectionName)
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 Null-forgiving operator usage: The null-forgiving operators (!) here are safe because the validator guarantees these values are non-null. However, as mentioned in a previous review thread, this is actually less type-safe than using nullable reference types.

The compiler won't prevent you from accessing AsError or checking these properties without proper validation first, which could cause runtime errors. With nullable types, the compiler enforces null checks.

Non-blocking observation: This is a broader architectural consideration about the tradeoff between FP patterns (OneOf/Result types) and C#'s nullable reference types. The current approach works but requires discipline to ensure validation always precedes usage of these operators.

if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
{
return Invalid(request, new AutomaticallyConfirmUsersPolicyIsNotEnabled());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing organization ability validation: The validator checks if the auto-confirm policy is enabled but does not verify the organization's plan supports this feature via the orgAbility cache (as mentioned in @eliykat's feedback).

Recommendation: Add validation to check request.Organization.UseAutoConfirmUsers before checking the policy. Organizations without proper plan entitlement should not be able to use this feature even if they enable the policy.

/// <li>Creating the default collection if applicable.</li>
/// </ul>
///
/// Each of these actions is performed independently of each other and not guaranteed to be performed in any order.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation inaccuracy: This statement "Each of these actions is performed independently of each other and not guaranteed to be performed in any order" is incorrect.

The actual implementation (AutomaticallyConfirmOrganizationUserCommand.cs:54-60) enforces explicit ordering:

  1. CreateDefaultCollectionsAsync executes before parallel tasks (line 54)
  2. Within SyncOrganizationKeysAsync, DeleteDeviceRegistrationAsync must complete before PushSyncOrganizationKeysAsync (lines 69-70)

Recommendation: Update documentation to describe:

  • Default collection creation happens first (to ensure it exists before sync)
  • Device registration deletion happens before push notification
  • Other side effects (logging, email) execute in parallel without ordering guarantees

OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Authorization semantic question: The StandardUser is created with await _currentContext.OrganizationOwner(orgId), which only returns true for Owners, not Admins.

However, the endpoint has [Authorize<ManageUsersRequirement>] which allows both Owners and Admins to manage users.

Questions:

  1. Is this intentional? Should only Owners be able to auto-confirm users?
  2. If Admins should also be allowed, should IsOwner be true for them, or is this property only for audit purposes?

The validator doesn't check PerformedBy.IsOwner, so this currently works but is semantically confusing. Consider either documenting this is intentional or using a different property name that doesn't imply Owner-only access.

{
try
{
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential null reference: The user is fetched but could theoretically be null. While the validator ensures OrganizationUser.UserId is non-null, there's no guarantee the User entity exists in the database.

Suggested fix:

var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
if (user == null)
{
    logger.LogError("User {UserId} not found when sending confirmation email.", request.OrganizationUser.UserId.Value);
    return;
}

await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
    user.Email,
    request.OrganizationUser.AccessSecretsManager);

This is caught by the try-catch, but explicit null checking provides clearer error logging.

})).ToList();

var results = new List<HttpResponseMessage>(tenRequests.Count);
foreach (var request in tenRequests)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 Test improvement: The current implementation processes requests sequentially rather than concurrently:

foreach (var request in tenRequests)  // ⚠️ Sequential await
{
    results.Add(await request);
}

To truly test concurrent execution and race conditions:

var results = await Task.WhenAll(tenRequests);

The current approach validates idempotency but doesn't test true concurrent request handling.

/// <li>Creating the default collection if applicable.</li>
/// </ul>
///
/// Each of these actions is performed independently of each other and not guaranteed to be performed in any order.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation inaccuracy: This statement "Each of these actions is performed independently of each other and not guaranteed to be performed in any order" is incorrect.

The actual implementation enforces explicit ordering:

  1. CreateDefaultCollectionsAsync executes before parallel tasks (AutomaticallyConfirmOrganizationUserCommand.cs:54)
  2. Within SyncOrganizationKeysAsync, DeleteDeviceRegistrationAsync must complete before PushSyncOrganizationKeysAsync (lines 69-70)

Recommendation: Update documentation to describe:

  • Default collection creation happens first (to ensure it exists before sync)
  • Device registration deletion happens before push notification
  • Other side effects (logging, email) execute in parallel without ordering guarantees

return Invalid(request, new UserIsNotUserType());
}

if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing organization ability validation: The validator checks if the auto-confirm policy is enabled but does not verify the organization's plan supports this feature via the orgAbility cache (as mentioned in @eliykat's feedback).

Recommendation: Add validation to check request.Organization.UseAutoConfirmUsers (or similar ability property) before checking the policy. Organizations without proper plan entitlement should not be able to use this feature even if they enable the policy.

DefaultUserCollectionName = _mockEncryptedString
})).ToList();

var results = await Task.WhenAll(tenRequests);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Excellent use of Task.WhenAll for true concurrent execution! This properly tests race conditions and idempotency.

🎨 Minor suggestion: Consider adding a comment explaining that the single default collection assertion validates the idempotency check worked correctly. Something like:

// Verify idempotency: despite 10 concurrent requests, only 1 default collection should be created
Assert.Single(collections.Where(c => c.Type == CollectionType.DefaultUserCollection));

This helps future maintainers understand the assertion's purpose.

Comment on lines +72 to +75
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId,
PolicyType.AutomaticUserConfirmation) is { Enabled: true };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also check orgAbility.UsePolicies && orgAbility.UseAutomaticUserConfirmation, to ensure that the organization's current plan supports this functionality. (To protect against plan downgrades.)

Example usage.

Comment on lines +56 to +69
await _loginHelper.LoginAsync(_ownerEmail);

var userToConfirmEmail = $"org-user-to-confirm-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(userToConfirmEmail);

await _loginHelper.LoginAsync(userToConfirmEmail);
var organizationUser = await OrganizationTestHelpers.CreateUserAsync(
_factory,
organization.Id,
userToConfirmEmail,
OrganizationUserType.User,
false,
new Permissions(),
OrganizationUserStatusType.Accepted);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setup is a bit confused here:

  • userToConfirm appears to be the user who is doing the confirmation, not the user being confirmed
  • multiple calls to login methods are possibly redundant (e.g. you login as the owner but then immediately login as the user; you use both _factory.LoginWithNewAccount and _loginHelper.LoginAsync).

Other tests in this file have similar issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants