diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa409c7..9dcaeded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Require a License Manager resource (placeholder: `vbase-read-write`) in addition to admin role to access Download Requests, Process Unsent and Delete Request APIs. + ## [1.14.2] - 2026-05-05 ### Fixed diff --git a/dotnet/Data/Constants.cs b/dotnet/Data/Constants.cs index b14ccde2..7c9eb5d6 100644 --- a/dotnet/Data/Constants.cs +++ b/dotnet/Data/Constants.cs @@ -31,6 +31,11 @@ public class Constants public const string HTTP_FORWARDED_HEADER = "HTTP_X_FORWARDED_FOR"; public const string API_VERSION_HEADER = "'x-api-version"; + // TODO: Replace with the actual LM product code for this app once the new resource is created in License Manager. + public const string REQUIRED_LM_PRODUCT_CODE = "0"; + // TODO: Replace "vbase-read-write" with the actual LM resource code once the new resource is created in License Manager. + public const string REQUIRED_LM_RESOURCE_CODE = "vbase-read-write"; + public const string BUCKET = "availability-notify"; public const string LOCK = "availability-notify-lock"; public const string UNSENT_CHECK = "check-unsent"; diff --git a/dotnet/Services/VtexAPIService.cs b/dotnet/Services/VtexAPIService.cs index afea66f9..62387cb2 100644 --- a/dotnet/Services/VtexAPIService.cs +++ b/dotnet/Services/VtexAPIService.cs @@ -1211,18 +1211,70 @@ public async Task IsValidAuthUser() return HttpStatusCode.BadRequest; } - bool hasPermission = validatedUser != null && - "Success".Equals(validatedUser.AuthStatus, StringComparison.OrdinalIgnoreCase) && - "admin".Equals(validatedUser.Audience, StringComparison.OrdinalIgnoreCase); + if (validatedUser == null || string.IsNullOrEmpty(validatedUser.User)) + { + _context.Vtex.Logger.Warn("IsValidAuthUser", null, "Could not resolve user from token"); + + return HttpStatusCode.Forbidden; + } - if (!hasPermission) + string account = this._httpContextAccessor.HttpContext.Request.Headers[Constants.VTEX_ACCOUNT_HEADER_NAME].ToString(); + string authToken = this._httpContextAccessor.HttpContext.Request.Headers[Constants.HEADER_VTEX_CREDENTIAL]; + + bool hasResource = await HasLicenseManagerResourceAsync(account, validatedUser.User, authToken, Constants.REQUIRED_LM_PRODUCT_CODE, Constants.REQUIRED_LM_RESOURCE_CODE); + + if (!hasResource) { - _context.Vtex.Logger.Warn("IsValidAuthUser", null, "User Does Not Have Permission"); + _context.Vtex.Logger.Warn("IsValidAuthUser", null, $"User '{validatedUser.User}' does not have required LM resource '{Constants.REQUIRED_LM_RESOURCE_CODE}'"); return HttpStatusCode.Forbidden; } return HttpStatusCode.OK; } + + private async Task HasLicenseManagerResourceAsync(string account, string userEmail, string credentialHeader, string productCode, string resourceCode) + { + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(userEmail)) + { + return false; + } + + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri($"http://{account}.vtexcommercestable.com.br/api/license-manager/pvt/accounts/{account}/products/{productCode}/logins/{Uri.EscapeDataString(userEmail)}/resources/{resourceCode}/granted") + }; + + request.Headers.Add(Constants.USE_HTTPS_HEADER_NAME, "true"); + + if (credentialHeader != null) + { + request.Headers.Add(Constants.AUTHORIZATION_HEADER_NAME, credentialHeader); + request.Headers.Add(Constants.VTEX_ID_HEADER_NAME, credentialHeader); + request.Headers.Add(Constants.PROXY_AUTHORIZATION_HEADER_NAME, credentialHeader); + } + + try + { + var client = _clientFactory.CreateClient(); + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + return false; + } + + string body = await response.Content.ReadAsStringAsync(); + + return bool.TryParse(body.Trim(), out bool granted) && granted; + } + catch (Exception ex) + { + _context.Vtex.Logger.Error("HasLicenseManagerResourceAsync", null, $"Error checking LM resource for user '{userEmail}'", ex); + + return false; + } + } } } diff --git a/messages/en.json b/messages/en.json index 6e374757..c13e06ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -18,5 +18,6 @@ "admin/settings.process-unsent-helptext": "Process all unsent requests to notify and download an XLS file of the results.", "admin/settings.verify-availability-helptext": "Runs a shipping simulation to verify that the item can be shipped to the shopper before sending a notification.", "admin/settings.marketplace-to-notify-helptext": "Allows a seller account to specify a comma separated list of marketplace account names to notify of inventory updates.", - "admin/settings.label": "Settings" + "admin/settings.label": "Settings", + "admin/settings.noPermission.error": "You do not have permission to perform this action." } diff --git a/messages/pt.json b/messages/pt.json index ffd2de9c..694cef94 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -18,5 +18,6 @@ "admin/settings.process-unsent-helptext": "Process all unsent requests to notify and download an XLS file of the results.", "admin/settings.verify-availability-helptext": "Runs a shipping simulation to verify that the item can be shipped to the shopper before sending a notificaiton.", "admin/settings.marketplace-to-notify-helptext": "Allows a seller account to specify a comma separated list of marketplace account names to notify of inventory updates.", - "admin/settings.label": "Settings" + "admin/settings.label": "Settings", + "admin/settings.noPermission.error": "Você não tem permissão para realizar esta ação." } diff --git a/react/NotifyAdmin.tsx b/react/NotifyAdmin.tsx index ad8c16ff..2abd223a 100644 --- a/react/NotifyAdmin.tsx +++ b/react/NotifyAdmin.tsx @@ -60,6 +60,10 @@ const messages = defineMessages({ id: 'admin/settings.saveSettings.button', defaultMessage: 'Save', }, + noPermissionError: { + id: 'admin/settings.noPermission.error', + defaultMessage: 'You do not have permission to perform this action.', + }, settingsLabel: { id: 'admin/settings.label', defaultMessage: 'Settings', @@ -205,20 +209,27 @@ const NotifyAdmin: FC = ({ intl }: Props) => { XLSX.writeFile(wb, exportFileName) } - const getAllRequests = async () => { + const getAllRequests = async (showToast: any) => { setState({ ...state, loading: true }) - const result: any = await fetch( - `/_v/availability-notify/list-requests` - ).then(response => response.json()) + const response = await fetch(`/_v/availability-notify/list-requests`) - const requestArr = result + if (response.status === 401 || response.status === 403) { + showToast({ + message: intl.formatMessage(messages.noPermissionError), + duration: 5000, + }) + setState({ ...state, loading: false }) + return + } + + const requestArr = await response.json() downloadRequests(requestArr) setState({ ...state, loading: false }) } - const processUnsentRequests = async () => { + const processUnsentRequests = async (showToast: any) => { setState({ ...state, processing: true }) try { @@ -228,7 +239,10 @@ const NotifyAdmin: FC = ({ intl }: Props) => { processRequestsResults(requestArr) } catch (error) { - throw new Error(`processUnsentRequests-error: ${error.message}`) + showToast({ + message: intl.formatMessage(messages.noPermissionError), + duration: 5000, + }) } setState({ ...state, processing: false }) @@ -307,7 +321,7 @@ const NotifyAdmin: FC = ({ intl }: Props) => { icon={download} isLoading={loading} onClick={() => { - getAllRequests() + getAllRequests(showToast) }} > {intl.formatMessage(messages.download)} @@ -324,7 +338,7 @@ const NotifyAdmin: FC = ({ intl }: Props) => { icon={download} isLoading={processing} onClick={() => { - processUnsentRequests() + processUnsentRequests(showToast) }} > {intl.formatMessage(messages.processUnsent)}