mirror of
https://github.com/bitwarden/server
synced 2026-01-03 17:14:00 +00:00
[PM-20348] Add pending auth request endpoint (#5957)
* Feat(pm-20348): * Add migration scripts for Read Pending Auth Requests by UserId stored procedure and new `view` for pending AuthRequest. * View only returns the most recent pending authRequest, or none at all if the most recent is answered. * Implement stored procedure in AuthRequestRepository for both Dapper and Entity Framework. * Update AuthRequestController to query the new View to get a user's most recent pending auth requests response includes the requesting deviceId. * Doc: * Move summary xml comments to interface. * Added comments for the AuthRequestService. * Test: * Added testing for AuthRequestsController. * Added testing for repositories. * Added integration tests for multiple auth requests but only returning the most recent.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Services;
|
||||
@@ -7,6 +8,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -14,31 +16,23 @@ namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("auth-requests")]
|
||||
[Authorize("Application")]
|
||||
public class AuthRequestsController : Controller
|
||||
public class AuthRequestsController(
|
||||
IUserService userService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IAuthRequestService authRequestService) : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService;
|
||||
|
||||
public AuthRequestsController(
|
||||
IUserService userService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IAuthRequestService authRequestService)
|
||||
{
|
||||
_userService = userService;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_authRequestService = authRequestService;
|
||||
}
|
||||
private readonly IUserService _userService = userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings = globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService = authRequestService;
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<AuthRequestResponseModel>> Get()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId);
|
||||
var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList();
|
||||
var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
|
||||
return new ListResponseModel<AuthRequestResponseModel>(responses);
|
||||
}
|
||||
|
||||
@@ -56,6 +50,16 @@ public class AuthRequestsController : Controller
|
||||
return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);
|
||||
}
|
||||
|
||||
[HttpGet("pending")]
|
||||
[RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)]
|
||||
public async Task<ListResponseModel<PendingAuthRequestResponseModel>> GetPendingAuthRequestsAsync()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId);
|
||||
var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
|
||||
return new ListResponseModel<PendingAuthRequestResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/response")]
|
||||
[AllowAnonymous]
|
||||
public async Task<AuthRequestResponseModel> GetResponse(Guid id, [FromQuery] string code)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Response;
|
||||
|
||||
public class PendingAuthRequestResponseModel : AuthRequestResponseModel
|
||||
{
|
||||
public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request")
|
||||
: base(authRequest, vaultUri, obj)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authRequest);
|
||||
RequestDeviceId = authRequest.RequestDeviceId;
|
||||
}
|
||||
|
||||
public Guid? RequestDeviceId { get; set; }
|
||||
}
|
||||
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal file
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public class PendingAuthRequestDetails : AuthRequest
|
||||
{
|
||||
public Guid? RequestDeviceId { get; set; }
|
||||
|
||||
/**
|
||||
* Constructor for EF response.
|
||||
*/
|
||||
public PendingAuthRequestDetails(
|
||||
AuthRequest authRequest,
|
||||
Guid? deviceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authRequest);
|
||||
|
||||
Id = authRequest.Id;
|
||||
UserId = authRequest.UserId;
|
||||
OrganizationId = authRequest.OrganizationId;
|
||||
Type = authRequest.Type;
|
||||
RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier;
|
||||
RequestDeviceType = authRequest.RequestDeviceType;
|
||||
RequestIpAddress = authRequest.RequestIpAddress;
|
||||
RequestCountryName = authRequest.RequestCountryName;
|
||||
ResponseDeviceId = authRequest.ResponseDeviceId;
|
||||
AccessCode = authRequest.AccessCode;
|
||||
PublicKey = authRequest.PublicKey;
|
||||
Key = authRequest.Key;
|
||||
MasterPasswordHash = authRequest.MasterPasswordHash;
|
||||
Approved = authRequest.Approved;
|
||||
CreationDate = authRequest.CreationDate;
|
||||
ResponseDate = authRequest.ResponseDate;
|
||||
AuthenticationDate = authRequest.AuthenticationDate;
|
||||
RequestDeviceId = deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for dapper response.
|
||||
*/
|
||||
public PendingAuthRequestDetails(
|
||||
Guid id,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
short type,
|
||||
string requestDeviceIdentifier,
|
||||
short requestDeviceType,
|
||||
string requestIpAddress,
|
||||
string requestCountryName,
|
||||
Guid? responseDeviceId,
|
||||
string accessCode,
|
||||
string publicKey,
|
||||
string key,
|
||||
string masterPasswordHash,
|
||||
bool? approved,
|
||||
DateTime creationDate,
|
||||
DateTime? responseDate,
|
||||
DateTime? authenticationDate,
|
||||
Guid deviceId)
|
||||
{
|
||||
Id = id;
|
||||
UserId = userId;
|
||||
OrganizationId = organizationId;
|
||||
Type = (AuthRequestType)type;
|
||||
RequestDeviceIdentifier = requestDeviceIdentifier;
|
||||
RequestDeviceType = (DeviceType)requestDeviceType;
|
||||
RequestIpAddress = requestIpAddress;
|
||||
RequestCountryName = requestCountryName;
|
||||
ResponseDeviceId = responseDeviceId;
|
||||
AccessCode = accessCode;
|
||||
PublicKey = publicKey;
|
||||
Key = key;
|
||||
MasterPasswordHash = masterPasswordHash;
|
||||
Approved = approved;
|
||||
CreationDate = creationDate;
|
||||
ResponseDate = responseDate;
|
||||
AuthenticationDate = authenticationDate;
|
||||
RequestDeviceId = deviceId;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
|
||||
{
|
||||
Task<int> DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration);
|
||||
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
|
||||
/// <summary>
|
||||
/// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different
|
||||
/// device. It will be the most current request for the device.
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId of the owner of the AuthRequests</param>
|
||||
/// <returns>a collection Auth request details or empty</returns>
|
||||
Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
|
||||
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Exceptions;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
#nullable enable
|
||||
|
||||
@@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services;
|
||||
|
||||
public interface IAuthRequestService
|
||||
{
|
||||
Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId);
|
||||
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code);
|
||||
/// <summary>
|
||||
/// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches
|
||||
/// userId. Returns null if the user doesn't match or if the AuthRequest is not found.
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">Authrequest Id being fetched</param>
|
||||
/// <param name="userId">user who owns AuthRequest</param>
|
||||
/// <returns>An AuthRequest or null</returns>
|
||||
Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId);
|
||||
/// <summary>
|
||||
/// Fetches the authrequest from the database with the id provided. Then checks
|
||||
/// the accessCode against the AuthRequest.AccessCode from the database. accessCodes
|
||||
/// must match the found authRequest, and the AuthRequest must not be expired. Expiration
|
||||
/// is configured in <see cref="GlobalSettings"/>
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">AuthRequest being acted on</param>
|
||||
/// <param name="accessCode">Access code of the authrequest, must match saved database value</param>
|
||||
/// <returns>A valid AuthRequest or null</returns>
|
||||
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode);
|
||||
/// <summary>
|
||||
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
|
||||
/// </remarks>
|
||||
Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model);
|
||||
/// <summary>
|
||||
/// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves
|
||||
/// or rejects the login request.
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">AuthRequest being acted on.</param>
|
||||
/// <param name="userId">User acting on AuthRequest</param>
|
||||
/// <param name="model">Update context for the AuthRequest</param>
|
||||
/// <returns>retuns an AuthRequest or throws an exception</returns>
|
||||
/// <exception cref="DuplicateAuthRequestException">Thows if the AuthRequest has already been Approved/Rejected</exception>
|
||||
/// <exception cref="NotFoundException">Throws if the AuthRequest as expired or the userId doesn't match</exception>
|
||||
/// <exception cref="BadRequestException">Throws if the device isn't associated with the UserId</exception>
|
||||
Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model);
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId)
|
||||
public async Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId)
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(id);
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || authRequest.UserId != userId)
|
||||
{
|
||||
return null;
|
||||
@@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code)
|
||||
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode)
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(id);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code))
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
|
||||
/// </remarks>
|
||||
public async Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model)
|
||||
{
|
||||
if (!_currentContext.DeviceType.HasValue)
|
||||
|
||||
@@ -14,13 +14,12 @@ namespace Bit.Infrastructure.Dapper.Auth.Repositories;
|
||||
|
||||
public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequestRepository
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
public AuthRequestRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public AuthRequestRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
: base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(
|
||||
TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration)
|
||||
@@ -52,6 +51,18 @@ public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequest
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId)
|
||||
{
|
||||
var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var results = await connection.QueryAsync<PendingAuthRequestDetails>(
|
||||
$"[{Schema}].[AuthRequest_ReadPendingByUserId]",
|
||||
new { UserId = userId, ExpirationMinutes = expirationMinutes },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
||||
@@ -3,6 +3,7 @@ using AutoMapper.QueryableExtensions;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,9 +15,13 @@ namespace Bit.Infrastructure.EntityFramework.Auth.Repositories;
|
||||
|
||||
public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest, AuthRequest, Guid>, IAuthRequestRepository
|
||||
{
|
||||
public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.AuthRequests)
|
||||
{ }
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper, IGlobalSettings globalSettings)
|
||||
: base(serviceScopeFactory, mapper, context => context.AuthRequests)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(
|
||||
TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration)
|
||||
{
|
||||
@@ -57,6 +62,32 @@ public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest,
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId)
|
||||
{
|
||||
var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var mostRecentAuthRequests = await
|
||||
(from authRequest in dbContext.AuthRequests
|
||||
where authRequest.Type == AuthRequestType.AuthenticateAndUnlock
|
||||
|| authRequest.Type == AuthRequestType.Unlock
|
||||
where authRequest.UserId == userId
|
||||
where authRequest.CreationDate.AddMinutes(expirationMinutes) >= DateTime.UtcNow
|
||||
group authRequest by authRequest.RequestDeviceIdentifier into groupedAuthRequests
|
||||
select
|
||||
(from r in groupedAuthRequests
|
||||
join d in dbContext.Devices on new { r.RequestDeviceIdentifier, r.UserId }
|
||||
equals new { RequestDeviceIdentifier = d.Identifier, d.UserId } into deviceJoin
|
||||
from dj in deviceJoin.DefaultIfEmpty() // This creates a left join allowing null for devices
|
||||
orderby r.CreationDate descending
|
||||
select new PendingAuthRequestDetails(r, dj.Id)).First()
|
||||
).ToListAsync();
|
||||
|
||||
mostRecentAuthRequests.RemoveAll(a => a.Approved != null);
|
||||
|
||||
return mostRecentAuthRequests;
|
||||
}
|
||||
|
||||
public async Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||
Guid organizationId,
|
||||
IEnumerable<Guid> ids)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId]
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@ExpirationMinutes INT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT *
|
||||
FROM [dbo].[AuthRequestPendingDetailsView]
|
||||
WHERE [UserId] = @UserId
|
||||
AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE())
|
||||
END
|
||||
38
src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql
Normal file
38
src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
CREATE VIEW [dbo].[AuthRequestPendingDetailsView]
|
||||
AS
|
||||
WITH
|
||||
PendingRequests
|
||||
AS
|
||||
(
|
||||
SELECT
|
||||
[AR].*,
|
||||
[D].[Id] AS [DeviceId],
|
||||
ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn]
|
||||
FROM [dbo].[AuthRequest] [AR]
|
||||
LEFT JOIN [dbo].[Device] [D]
|
||||
ON [AR].[RequestDeviceIdentifier] = [D].[Identifier]
|
||||
AND [D].[UserId] = [AR].[UserId]
|
||||
WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock
|
||||
)
|
||||
SELECT
|
||||
[PR].[Id],
|
||||
[PR].[UserId],
|
||||
[PR].[OrganizationId],
|
||||
[PR].[Type],
|
||||
[PR].[RequestDeviceIdentifier],
|
||||
[PR].[RequestDeviceType],
|
||||
[PR].[RequestIpAddress],
|
||||
[PR].[RequestCountryName],
|
||||
[PR].[ResponseDeviceId],
|
||||
[PR].[AccessCode],
|
||||
[PR].[PublicKey],
|
||||
[PR].[Key],
|
||||
[PR].[MasterPasswordHash],
|
||||
[PR].[Approved],
|
||||
[PR].[CreationDate],
|
||||
[PR].[ResponseDate],
|
||||
[PR].[AuthenticationDate],
|
||||
[PR].[DeviceId]
|
||||
FROM [PendingRequests] [PR]
|
||||
WHERE [PR].[rn] = 1
|
||||
AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null
|
||||
Reference in New Issue
Block a user