From c2aff33779f6d11902b5a1980de9ea1f8cf11912 Mon Sep 17 00:00:00 2001 From: Iago Espinoza Date: Fri, 12 Jun 2026 11:13:39 -0300 Subject: [PATCH 1/4] Add License Manager resource check on top of admin role for protected APIs Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++ dotnet/Data/Constants.cs | 3 ++ dotnet/Models/LmRole.cs | 23 ++++++++++ dotnet/Services/VtexAPIService.cs | 70 +++++++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 dotnet/Models/LmRole.cs 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..6ad97796 100644 --- a/dotnet/Data/Constants.cs +++ b/dotnet/Data/Constants.cs @@ -31,6 +31,9 @@ public class Constants public const string HTTP_FORWARDED_HEADER = "HTTP_X_FORWARDED_FOR"; public const string API_VERSION_HEADER = "'x-api-version"; + // TODO: Replace "vbase-read-write" with the actual LM resource key once the new resource is created in License Manager. + public const string REQUIRED_ADMIN_RESOURCE = "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/Models/LmRole.cs b/dotnet/Models/LmRole.cs new file mode 100644 index 00000000..21c2875e --- /dev/null +++ b/dotnet/Models/LmRole.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace AvailabilityNotify.Models +{ + public class LmRole + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("resources")] + public List Resources { get; set; } + } + + public class LmResource + { + [JsonProperty("key")] + public string Key { get; set; } + } +} diff --git a/dotnet/Services/VtexAPIService.cs b/dotnet/Services/VtexAPIService.cs index afea66f9..7fc38767 100644 --- a/dotnet/Services/VtexAPIService.cs +++ b/dotnet/Services/VtexAPIService.cs @@ -1211,18 +1211,82 @@ public async Task IsValidAuthUser() return HttpStatusCode.BadRequest; } - bool hasPermission = validatedUser != null && - "Success".Equals(validatedUser.AuthStatus, StringComparison.OrdinalIgnoreCase) && + bool isAdmin = validatedUser != null && + "Success".Equals(validatedUser.AuthStatus, StringComparison.OrdinalIgnoreCase) && "admin".Equals(validatedUser.Audience, StringComparison.OrdinalIgnoreCase); - if (!hasPermission) + if (!isAdmin) { _context.Vtex.Logger.Warn("IsValidAuthUser", null, "User Does Not Have Permission"); return HttpStatusCode.Forbidden; } + 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.Id, authToken, Constants.REQUIRED_ADMIN_RESOURCE); + + if (!hasResource) + { + _context.Vtex.Logger.Warn("IsValidAuthUser", null, $"User '{validatedUser.Id}' does not have required LM resource '{Constants.REQUIRED_ADMIN_RESOURCE}'"); + + return HttpStatusCode.Forbidden; + } + return HttpStatusCode.OK; } + + private async Task HasLicenseManagerResourceAsync(string account, string userId, string credentialHeader, string resourceKey) + { + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(userId)) + { + return false; + } + + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri($"http://{account}.vtexcommercestable.com.br/api/license-manager/users/{Uri.EscapeDataString(userId)}/roles") + }; + + 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(); + var roles = JsonConvert.DeserializeObject>(body); + + if (roles == null) + { + return false; + } + + return roles.Any(role => + role.Resources != null && + role.Resources.Any(r => string.Equals(r.Key, resourceKey, StringComparison.OrdinalIgnoreCase))); + } + catch (Exception ex) + { + _context.Vtex.Logger.Error("HasLicenseManagerResourceAsync", null, $"Error checking LM resource for user '{userId}'", ex); + + return false; + } + } } } From 3778cd8bb9b44dde55a0fcff09e121207ad9d6e8 Mon Sep 17 00:00:00 2001 From: Iago Espinoza Date: Fri, 12 Jun 2026 11:23:59 -0300 Subject: [PATCH 2/4] Fix LM resource check to use correct VTEX endpoint with productCode and resourceCode Co-Authored-By: Claude Sonnet 4.6 --- dotnet/Data/Constants.cs | 6 ++++-- dotnet/Models/LmRole.cs | 23 ----------------------- dotnet/Services/VtexAPIService.cs | 22 +++++++--------------- 3 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 dotnet/Models/LmRole.cs diff --git a/dotnet/Data/Constants.cs b/dotnet/Data/Constants.cs index 6ad97796..7c9eb5d6 100644 --- a/dotnet/Data/Constants.cs +++ b/dotnet/Data/Constants.cs @@ -31,8 +31,10 @@ public class Constants public const string HTTP_FORWARDED_HEADER = "HTTP_X_FORWARDED_FOR"; public const string API_VERSION_HEADER = "'x-api-version"; - // TODO: Replace "vbase-read-write" with the actual LM resource key once the new resource is created in License Manager. - public const string REQUIRED_ADMIN_RESOURCE = "vbase-read-write"; + // 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"; diff --git a/dotnet/Models/LmRole.cs b/dotnet/Models/LmRole.cs deleted file mode 100644 index 21c2875e..00000000 --- a/dotnet/Models/LmRole.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace AvailabilityNotify.Models -{ - public class LmRole - { - [JsonProperty("id")] - public string Id { get; set; } - - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("resources")] - public List Resources { get; set; } - } - - public class LmResource - { - [JsonProperty("key")] - public string Key { get; set; } - } -} diff --git a/dotnet/Services/VtexAPIService.cs b/dotnet/Services/VtexAPIService.cs index 7fc38767..39ff4f63 100644 --- a/dotnet/Services/VtexAPIService.cs +++ b/dotnet/Services/VtexAPIService.cs @@ -1225,11 +1225,11 @@ public async Task IsValidAuthUser() 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.Id, authToken, Constants.REQUIRED_ADMIN_RESOURCE); + 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 '{validatedUser.Id}' does not have required LM resource '{Constants.REQUIRED_ADMIN_RESOURCE}'"); + _context.Vtex.Logger.Warn("IsValidAuthUser", null, $"User '{validatedUser.User}' does not have required LM resource '{Constants.REQUIRED_LM_RESOURCE_CODE}'"); return HttpStatusCode.Forbidden; } @@ -1237,9 +1237,9 @@ public async Task IsValidAuthUser() return HttpStatusCode.OK; } - private async Task HasLicenseManagerResourceAsync(string account, string userId, string credentialHeader, string resourceKey) + private async Task HasLicenseManagerResourceAsync(string account, string userEmail, string credentialHeader, string productCode, string resourceCode) { - if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(userId)) + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(userEmail)) { return false; } @@ -1247,7 +1247,7 @@ private async Task HasLicenseManagerResourceAsync(string account, string u var request = new HttpRequestMessage { Method = HttpMethod.Get, - RequestUri = new Uri($"http://{account}.vtexcommercestable.com.br/api/license-manager/users/{Uri.EscapeDataString(userId)}/roles") + 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"); @@ -1270,20 +1270,12 @@ private async Task HasLicenseManagerResourceAsync(string account, string u } string body = await response.Content.ReadAsStringAsync(); - var roles = JsonConvert.DeserializeObject>(body); - - if (roles == null) - { - return false; - } - return roles.Any(role => - role.Resources != null && - role.Resources.Any(r => string.Equals(r.Key, resourceKey, StringComparison.OrdinalIgnoreCase))); + return bool.TryParse(body.Trim(), out bool granted) && granted; } catch (Exception ex) { - _context.Vtex.Logger.Error("HasLicenseManagerResourceAsync", null, $"Error checking LM resource for user '{userId}'", ex); + _context.Vtex.Logger.Error("HasLicenseManagerResourceAsync", null, $"Error checking LM resource for user '{userEmail}'", ex); return false; } From 4f357ff409d803e2a3245f0e5cdae7d5a8f845a5 Mon Sep 17 00:00:00 2001 From: Iago Espinoza Date: Fri, 12 Jun 2026 14:44:30 -0300 Subject: [PATCH 3/4] Remove redundant admin audience check, rely solely on LM resource grant Co-Authored-By: Claude Sonnet 4.6 --- dotnet/Services/VtexAPIService.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dotnet/Services/VtexAPIService.cs b/dotnet/Services/VtexAPIService.cs index 39ff4f63..62387cb2 100644 --- a/dotnet/Services/VtexAPIService.cs +++ b/dotnet/Services/VtexAPIService.cs @@ -1211,13 +1211,9 @@ public async Task IsValidAuthUser() return HttpStatusCode.BadRequest; } - bool isAdmin = validatedUser != null && - "Success".Equals(validatedUser.AuthStatus, StringComparison.OrdinalIgnoreCase) && - "admin".Equals(validatedUser.Audience, StringComparison.OrdinalIgnoreCase); - - if (!isAdmin) + if (validatedUser == null || string.IsNullOrEmpty(validatedUser.User)) { - _context.Vtex.Logger.Warn("IsValidAuthUser", null, "User Does Not Have Permission"); + _context.Vtex.Logger.Warn("IsValidAuthUser", null, "Could not resolve user from token"); return HttpStatusCode.Forbidden; } From e8acc14a7a05d65a0a4b259affd92d354c486e47 Mon Sep 17 00:00:00 2001 From: Iago Espinoza Date: Fri, 12 Jun 2026 15:39:14 -0300 Subject: [PATCH 4/4] Show permission error toast on 401/403 for Download Requests and Process Unsent Co-Authored-By: Claude Sonnet 4.6 --- messages/en.json | 3 ++- messages/pt.json | 3 ++- react/NotifyAdmin.tsx | 32 +++++++++++++++++++++++--------- 3 files changed, 27 insertions(+), 11 deletions(-) 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)}