Compare commits

..

70 Commits

Author SHA1 Message Date
Owen
d47449b082 Add notes about inline policy to api endpoints 2026-06-10 10:24:31 -07:00
Owen
665806dfe8 Add some documentation; pull the override values 2026-06-10 10:03:16 -07:00
Owen
e248571268 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-09 22:02:24 -07:00
miloschwartz
fcf03854ff fix tag input wrapping 2026-06-09 22:01:13 -07:00
Owen
dd1fba4e45 Also clear the roles and users 2026-06-09 21:59:30 -07:00
miloschwartz
a1ab8d8f35 standardize client titles 2026-06-09 21:47:15 -07:00
miloschwartz
c789e967db standardize client titles 2026-06-09 21:36:54 -07:00
Owen
d870b9ff49 Drop the not null on resource columns 2026-06-09 21:36:27 -07:00
miloschwartz
9c09019ddb add protocol filter 2026-06-09 21:33:56 -07:00
Owen
9d88683fc5 Reset resource info when on inline policy 2026-06-09 21:28:25 -07:00
miloschwartz
dd2c9f2a02 check resource policy in verifyResourceAccess middleware 2026-06-09 17:52:31 -07:00
miloschwartz
bdb38db5bc fix form responsiveness 2026-06-09 16:52:18 -07:00
Owen
96a54fc9cc Fix import issue in migrations 2026-06-09 16:51:55 -07:00
Owen
3a485f74f1 Move session migration out of the loop 2026-06-09 16:16:14 -07:00
Owen
92b0340324 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-09 16:10:35 -07:00
miloschwartz
9257ac01c7 add learn more links to auto update 2026-06-09 16:08:07 -07:00
Owen
4d1d0d9fcb Add warning if we cant reach the vnc server 2026-06-09 16:02:52 -07:00
Owen
f186e7e99e Dont allow asn or country without having maxmind 2026-06-09 16:02:52 -07:00
miloschwartz
1aa6e3511f dont show policy on tcp/udp resources 2026-06-09 15:56:24 -07:00
miloschwartz
fb6f5b3953 form layout improvements 2026-06-09 15:42:28 -07:00
Owen
c85a7f6ac5 Migrate unkown openapi response from string to {} 2026-06-09 15:35:08 -07:00
Owen
dd54be523f Dont need to check user exists for the whitelist 2026-06-09 15:26:35 -07:00
Owen
d57f064d4c Fix spelling 2026-06-09 15:26:35 -07:00
miloschwartz
34799b7de2 move maintenance page config to tab 2026-06-09 15:07:55 -07:00
miloschwartz
20a66bba6f fix resource context updating problem 2026-06-09 14:49:57 -07:00
miloschwartz
cdb43d9658 dont set whitelist until click set button in dialog 2026-06-09 14:36:50 -07:00
miloschwartz
6581ccafa3 fix toggle on pin or passcode not working on policy form 2026-06-09 14:34:58 -07:00
miloschwartz
a3a45b4239 add safe read 2026-06-09 14:09:36 -07:00
Owen
d6634b6e8a Add types 2026-06-09 12:16:00 -07:00
Owen
1089cfbacc Update query to be more efficient 2026-06-09 11:54:46 -07:00
Owen
1907a3c93b Link to primary org only when you can see billing 2026-06-09 10:33:42 -07:00
miloschwartz
407ba567a0 various visual changes 2026-06-08 22:07:53 -07:00
Owen
f28571629f Make sure the pamMode is push for host resources 2026-06-08 21:54:06 -07:00
Owen
5a575c916b Handle backward compatability 2026-06-08 21:11:57 -07:00
Owen
9a7e534b10 Ssh session closed card 2026-06-08 17:44:48 -07:00
Owen
42974d1739 Make sure the skip to idp is pulled 2026-06-08 17:41:59 -07:00
Owen
780e8babe4 Perfect toolbar 2026-06-08 17:39:07 -07:00
Owen
2c7b8006cf Add gray bar 2026-06-08 16:07:36 -07:00
Owen
35066c1388 Add pulldown toolbar 2026-06-08 16:00:38 -07:00
miloschwartz
135a5d38af make form grids more consistent 2026-06-08 16:00:30 -07:00
Owen
1b7c1ffa70 Set the target port from the resource 2026-06-08 15:39:26 -07:00
Owen
641f643d2d Prefil the port with the best guess port 2026-06-08 15:39:26 -07:00
Owen
b4ecfceb5e Show more information about error 2026-06-08 15:39:25 -07:00
Owen
08a84d4bb1 Add some connection feedback 2026-06-08 15:39:25 -07:00
Owen
4dbad7ab24 Close the tab when exiting 2026-06-08 15:39:25 -07:00
miloschwartz
859c0c9477 add description text to share link path input 2026-06-08 15:33:12 -07:00
miloschwartz
d294bf8534 support uploading csv or txt to sudo commands and groups 2026-06-08 15:30:03 -07:00
miloschwartz
3c8fea382f improve unix group and sudo commands inputs 2026-06-08 14:36:10 -07:00
Owen
b81bfcfcee Fix type error 2026-06-08 12:21:43 -07:00
Milo Schwartz
56c415ca05 Merge pull request #3219 from Fredkiss3/refactor/standardize-clear-buttons
feat: make clear filter buttons more consistent accross tables
2026-06-08 12:07:55 -07:00
Owen
74fdcceace Reconnect newts when a exit node comes back online 2026-06-08 12:02:12 -07:00
Owen
7dec8ba998 Add exit node if the sites dont have one 2026-06-08 12:02:12 -07:00
miloschwartz
c9dc6affe7 Merge branch 'dev' into resource-policies-restyle 2026-06-08 12:00:08 -07:00
miloschwartz
8fe45ba78c prevent duplicate label names 2026-06-08 11:59:15 -07:00
Fred KISSIE
934886caea Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-08 20:42:11 +02:00
miloschwartz
fae258b145 add labels to user-resources query 2026-06-08 10:55:24 -07:00
miloschwartz
9f224f655f Merge branch 'resource-policies-restyle' into dev 2026-06-08 10:38:13 -07:00
miloschwartz
aea7df7dc2 rename share links 2026-06-08 10:37:46 -07:00
miloschwartz
3b675f7de1 policies and policy on resource structure in a good place 2026-06-07 12:19:33 -07:00
miloschwartz
aa47f522ef move toggle on general page 2026-06-06 15:34:34 -07:00
Fred KISSIE
a994f8ff07 💄 Column filter buttons for log tables 2026-06-05 21:47:08 +02:00
Fred KISSIE
95ce91d94b Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-05 20:21:34 +02:00
Fred KISSIE
a4548fd874 💄 Break all text 2026-06-05 19:59:28 +02:00
Fred KISSIE
eb03fb7060 ♻️ standardize http request log data-tables 2026-06-05 19:28:30 +02:00
Fred KISSIE
33fdc9a94f 🚧 wip: column filter button 2026-06-04 21:04:15 +02:00
Fred KISSIE
c86026c941 ♻️ refactor 2026-06-04 20:09:07 +02:00
Fred KISSIE
db014e3446 ♻️ use the same clear filter text for clearing filters in the column filter buttons 2026-06-04 20:08:27 +02:00
Fred KISSIE
feb8045643 ♻️ refactor 2026-06-04 19:54:43 +02:00
Fred KISSIE
d485a09318 ♻️ use site label filter column 2026-06-04 19:45:54 +02:00
Fred KISSIE
9cff5f66b1 🚧 wip: site label column filter standardized 2026-06-04 19:40:24 +02:00
248 changed files with 6314 additions and 4189 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.

View File

@@ -150,16 +150,16 @@
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"siteInfo": "Site Information", "siteInfo": "Site Information",
"status": "Status", "status": "Status",
"shareTitle": "Manage Share Links", "shareTitle": "Manage Shareable Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources", "shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
"shareSearch": "Search share links...", "shareSearch": "Search shareable links...",
"shareCreate": "Create Share Link", "shareCreate": "Create Shareable Link",
"shareErrorDelete": "Failed to delete link", "shareErrorDelete": "Failed to delete link",
"shareErrorDeleteMessage": "An error occurred deleting link", "shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted", "shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted", "shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Share Link", "shareDelete": "Delete Shareable Link",
"shareDeleteConfirm": "Confirm Delete Share Link", "shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?", "shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
@@ -179,6 +179,7 @@
"shareCreateDescription": "Anyone with this link can access the resource", "shareCreateDescription": "Anyone with this link can access the resource",
"shareTitleOptional": "Title (optional)", "shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)", "sharePathOptional": "Path (optional)",
"sharePathDescription": "The link will redirect users to this path after authentication.",
"expireIn": "Expire In", "expireIn": "Expire In",
"neverExpire": "Never expire", "neverExpire": "Never expire",
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
@@ -211,6 +212,8 @@
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
"resourcePoliciesTitle": "Manage Public Resource Policies", "resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)", "resourcePoliciesAttachedResources": "{count} resource(s)",
@@ -277,7 +280,7 @@
"back": "Back", "back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
"resourceConfig": "Configuration Snippets", "resourceConfig": "Configuration Snippets",
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource.",
"resourceAddEntrypoints": "Traefik: Add Entrypoints", "resourceAddEntrypoints": "Traefik: Add Entrypoints",
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
"resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceLearnRaw": "Learn how to configure TCP/UDP resources",
@@ -290,6 +293,8 @@
"labelDelete": "Delete Label", "labelDelete": "Delete Label",
"labelAdd": "Add Label", "labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully", "labelCreateSuccessMessage": "Label Created Successfully",
"labelDuplicateError": "Duplicate Label",
"labelDuplicateErrorDescription": "A label with this name already exists.",
"labelEditSuccessMessage": "Label Modified Successfully", "labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name", "labelNameField": "Label Name",
"labelColorField": "Label Color", "labelColorField": "Label Color",
@@ -722,7 +727,7 @@
"targetSubmit": "Add Target", "targetSubmit": "Add Target",
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
"targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.",
"targetsSubmit": "Save Targets", "targetsSubmit": "Save Settings",
"addTarget": "Add Target", "addTarget": "Add Target",
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.", "proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
"targetErrorInvalidIp": "Invalid IP address", "targetErrorInvalidIp": "Invalid IP address",
@@ -774,6 +779,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules", "rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.", "rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -843,6 +849,10 @@
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name", "policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Expected value", "policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
"policyAccessRulesTitle": "Access Rules", "policyAccessRulesTitle": "Access Rules",
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.", "policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.", "policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
@@ -872,9 +882,9 @@
"resourcesErrorUpdateDescription": "An error occurred while updating the resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access", "access": "Access",
"accessControl": "Access Control", "accessControl": "Access Control",
"shareLink": "{resource} Share Link", "shareLink": "{resource} Shareable Link",
"resourceSelect": "Select resource", "resourceSelect": "Select resource",
"shareLinks": "Share Links", "shareLinks": "Shareable Links",
"share": "Shareable Links", "share": "Shareable Links",
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.", "shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
"shareEasyCreate": "Easy to create and share", "shareEasyCreate": "Easy to create and share",
@@ -964,10 +974,18 @@
"resourceRoleDescription": "Admins can always access this resource.", "resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication", "resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyTypeLabel": "Policy type",
"resourcePolicyLabel": "Resource policy",
"resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy", "resourcePolicyShared": "Shared Resource Policy",
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.", "resourcePolicySharedDescription": "This resource uses a shared policy.",
"sharedPolicy": "Shared Policy",
"sharedPolicyNoneDescription": "This resource has its own policy.",
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.",
"resourceSharedPolicyInheritedDescription": "This resource inherits authentication and access rules controls from <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
"resourceUsersRoles": "Access Controls", "resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls", "resourceUsersRolesSubmit": "Save Access Controls",
@@ -992,7 +1010,14 @@
"resourceVisibilityTitle": "Visibility", "resourceVisibilityTitle": "Visibility",
"resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility",
"resourceGeneral": "General Settings", "resourceGeneral": "General Settings",
"resourceGeneralDescription": "Configure the general settings for this resource", "resourceGeneralDescription": "Configure name, address, and access policy for this resource.",
"resourceGeneralDetailsSubsection": "Resource Details",
"resourceGeneralDetailsSubsectionDescription": "Set the display name, identifier, and publicly accessible domain for this resource.",
"resourceGeneralDetailsSubsectionPortDescription": "Set the display name, identifier, and public port for this resource.",
"resourceGeneralPublicAddressSubsection": "Public Address",
"resourceGeneralPublicAddressSubsectionDescription": "Configure how users reach this resource.",
"resourceGeneralAuthenticationAccessSubsection": "Authentication & Access",
"resourceGeneralAuthenticationAccessSubsectionDescription": "Choose whether this resource uses its own policy or inherits from a shared policy.",
"resourceEnable": "Enable Resource", "resourceEnable": "Enable Resource",
"resourceTransfer": "Transfer Resource", "resourceTransfer": "Transfer Resource",
"resourceTransferDescription": "Transfer this resource to a different site", "resourceTransferDescription": "Transfer this resource to a different site",
@@ -1275,6 +1300,7 @@
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters", "accessLabelFilterClear": "Clear label filters",
"accessFilterClear": "Clear filters",
"selectColor": "Select color", "selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"", "createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
@@ -1511,7 +1537,7 @@
"sidebarResources": "Resources", "sidebarResources": "Resources",
"sidebarProxyResources": "Public", "sidebarProxyResources": "Public",
"sidebarClientResources": "Private", "sidebarClientResources": "Private",
"sidebarPolicies": "Policies", "sidebarPolicies": "Shared Policies",
"sidebarResourcePolicies": "Public Resources", "sidebarResourcePolicies": "Public Resources",
"sidebarAccessControl": "Access Control", "sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarLogsAndAnalytics": "Logs & Analytics",
@@ -1520,7 +1546,7 @@
"sidebarAdmin": "Admin", "sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations", "sidebarInvitations": "Invitations",
"sidebarRoles": "Roles", "sidebarRoles": "Roles",
"sidebarShareableLinks": "Share Links", "sidebarShareableLinks": "Shareable Links",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
@@ -1717,10 +1743,10 @@
"enableDockerSocket": "Enable Docker Blueprint", "enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
"newtAutoUpdate": "Enable Site Auto-Update", "newtAutoUpdate": "Enable Site Auto-Update",
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.", "newtAutoUpdateDescription": "When enabled, site connectors will automatically download the latest version and restart themselves. This can be overridden on a per-site basis.",
"siteAutoUpdate": "Site Auto-Update", "siteAutoUpdate": "Site Auto-Update",
"siteAutoUpdateLabel": "Enable Auto-Update", "siteAutoUpdateLabel": "Enable Auto-Update",
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.", "siteAutoUpdateDescription": "When enabled, this site's connector will automatically download the latest version and restart itself.",
"siteAutoUpdateOrgDefault": "Organization default: {state}", "siteAutoUpdateOrgDefault": "Organization default: {state}",
"siteAutoUpdateOverriding": "Overriding organization setting", "siteAutoUpdateOverriding": "Overriding organization setting",
"siteAutoUpdateResetToOrg": "Reset to Organization Default", "siteAutoUpdateResetToOrg": "Reset to Organization Default",
@@ -1818,9 +1844,9 @@
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
"documentation": "Documentation", "documentation": "Documentation",
"saveAllSettings": "Save All Settings", "saveAllSettings": "Save All Settings",
"saveResourceTargets": "Save Targets", "saveResourceTargets": "Save Settings",
"saveResourceHttp": "Save Proxy Settings", "saveResourceHttp": "Save Settings",
"saveProxyProtocol": "Save Proxy protocol settings", "saveProxyProtocol": "Save Settings",
"settingsUpdated": "Settings updated", "settingsUpdated": "Settings updated",
"settingsUpdatedDescription": "Settings updated successfully", "settingsUpdatedDescription": "Settings updated successfully",
"settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdate": "Failed to update settings",
@@ -2144,10 +2170,25 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.", "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
"roleTextImportTitle": "Import from File",
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
"roleTextImportSkipHeader": "Skip First Row (Header)",
"roleTextImportOverride": "Replace Existing",
"roleTextImportAppend": "Append to Existing",
"roleTextImportMode": "Import Mode",
"roleTextImportPreview": "Preview",
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
"roleTextImportConfirm": "Import",
"roleTextImportInvalidFile": "Unsupported file type",
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
"roleTextImportEmpty": "No items found in file",
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -2931,9 +2972,10 @@
"enableProxyProtocol": "Enable Proxy Protocol", "enableProxyProtocol": "Enable Proxy Protocol",
"proxyProtocolInfo": "Preserve client IP addresses for TCP backends", "proxyProtocolInfo": "Preserve client IP addresses for TCP backends",
"proxyProtocolVersion": "Proxy Protocol Version", "proxyProtocolVersion": "Proxy Protocol Version",
"version1": " Version 1 (Recommended)", "version1": "Version 1 (Recommended)",
"version2": "Version 2", "version2": "Version 2",
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.", "version1Description": "Text-based and widely supported. Make sure servers transport is added to dynamic config.",
"version2Description": "Binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
"warning": "Warning", "warning": "Warning",
"proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", "proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
"restarting": "Restarting...", "restarting": "Restarting...",
@@ -3131,6 +3173,7 @@
"maintenanceModeType": "Maintenance Mode Type", "maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors", "showMaintenancePage": "Show a maintenance page to visitors",
"enableMaintenanceMode": "Enable Maintenance Mode", "enableMaintenanceMode": "Enable Maintenance Mode",
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
"automatic": "Automatic", "automatic": "Automatic",
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
"forced": "Forced", "forced": "Forced",
@@ -3138,6 +3181,8 @@
"warning:": "Warning:", "warning:": "Warning:",
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
"pageTitle": "Page Title", "pageTitle": "Page Title",
"maintenancePageContentSubsection": "Page Content",
"maintenancePageContentSubsectionDescription": "Customize the content displayed on the maintenance page",
"pageTitleDescription": "The main heading displayed on the maintenance page", "pageTitleDescription": "The main heading displayed on the maintenance page",
"maintenancePageMessage": "Maintenance Message", "maintenancePageMessage": "Maintenance Message",
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
@@ -3497,14 +3542,14 @@
"sshConnecting": "Connecting…", "sshConnecting": "Connecting…",
"sshInitializing": "Initializing…", "sshInitializing": "Initializing…",
"sshSignInTitle": "Sign in to SSH", "sshSignInTitle": "Sign in to SSH",
"sshSignInDescription": "Enter your SSH credentials", "sshSignInDescription": "Enter your SSH credentials to connect",
"sshPasswordTab": "Password", "sshPasswordTab": "Password",
"sshPrivateKeyTab": "Private Key", "sshPrivateKeyTab": "Private Key",
"sshPrivateKeyField": "Private Key", "sshPrivateKeyField": "Private Key",
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.", "sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
"sshLearnMore": "Learn more", "sshLearnMore": "Learn more",
"sshPrivateKeyFile": "Private Key File", "sshPrivateKeyFile": "Private Key File",
"sshAuthenticate": "Authenticate", "sshAuthenticate": "Connect",
"sshTerminate": "Terminate", "sshTerminate": "Terminate",
"sshPoweredBy": "Powered by", "sshPoweredBy": "Powered by",
"sshErrorNoTarget": "No target specified", "sshErrorNoTarget": "No target specified",
@@ -3548,5 +3593,7 @@
"rdpFilesReadyToPaste": "Files ready to paste", "rdpFilesReadyToPaste": "Files ready to paste",
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.", "rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
"rdpUploadFailed": "Upload failed", "rdpUploadFailed": "Upload failed",
"rdpUnicodeKeyboardMode": "Unicode keyboard mode" "rdpUnicodeKeyboardMode": "Unicode keyboard mode",
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar"
} }

View File

@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];

View File

@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile"; import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build"; import { build } from "@server/build";
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver"; import { db as mainDb } from "./driver";
import { createPool } from "./poolConfig"; import { createPool } from "./poolConfig";
function createLogsDb() { function createLogsDb() {
@@ -63,8 +63,7 @@ function createLogsDb() {
}) })
); );
} else { } else {
const maxReplicaConnections = const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
poolConfig?.max_replica_connections || 20;
for (const conn of replicaConnections) { for (const conn of replicaConnections) {
const replicaPool = createPool( const replicaPool = createPool(
conn.connection_string, conn.connection_string,
@@ -91,4 +90,4 @@ function createLogsDb() {
export const logsDb = createLogsDb(); export const logsDb = createLogsDb();
export default logsDb; export default logsDb;
export const primaryLogsDb = logsDb.$primary; export const primaryLogsDb = logsDb.$primary;

View File

@@ -1,5 +1,4 @@
import { Pool, PoolConfig } from "pg"; import { Pool, PoolConfig } from "pg";
import logger from "@server/logger";
export function createPoolConfig( export function createPoolConfig(
connectionString: string, connectionString: string,
@@ -27,7 +26,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
pool.on("error", (err) => { pool.on("error", (err) => {
// This catches errors on idle clients in the pool. Without this // This catches errors on idle clients in the pool. Without this
// handler an unexpected disconnect would crash the process. // handler an unexpected disconnect would crash the process.
logger.error( console.error(
`Unexpected error on idle ${label} database client: ${err.message}` `Unexpected error on idle ${label} database client: ${err.message}`
); );
}); });
@@ -36,7 +35,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
// Set a statement timeout on every new connection so a single slow // Set a statement timeout on every new connection so a single slow
// query can't block the pool forever // query can't block the pool forever
client.query("SET statement_timeout = '30s'").catch((err: Error) => { client.query("SET statement_timeout = '30s'").catch((err: Error) => {
logger.warn( console.warn(
`Failed to set statement_timeout on ${label} client: ${err.message}` `Failed to set statement_timeout on ${label} client: ${err.message}`
); );
}); });
@@ -60,4 +59,4 @@ export function createPool(
); );
attachPoolErrorHandlers(pool, label); attachPoolErrorHandlers(pool, label);
return pool; return pool;
} }

View File

@@ -147,12 +147,10 @@ export const resources = pgTable("resources", {
}), }),
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false),
sso: boolean("sso").notNull().default(true),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled") sso: boolean("sso"),
.notNull() emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
.default(false), applyRules: boolean("applyRules"),
applyRules: boolean("applyRules").notNull().default(false),
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false), stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"), tlsServerName: varchar("tlsServerName"),

View File

@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
password: ResourcePassword | ResourcePolicyPassword | null; password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean; applyRules: boolean | null;
sso: boolean; sso: boolean | null;
emailWhitelistEnabled: boolean; emailWhitelistEnabled: boolean | null;
org: Org; org: Org;
}; };

View File

@@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", {
blockAccess: integer("blockAccess", { mode: "boolean" }) blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) sso: integer("sso", { mode: "boolean" }),
.notNull() emailWhitelistEnabled: integer("emailWhitelistEnabled", {
.default(false), mode: "boolean"
applyRules: integer("applyRules", { mode: "boolean" }) }),
.notNull() applyRules: integer("applyRules", { mode: "boolean" }),
.default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" }) stickySession: integer("stickySession", { mode: "boolean" })
.notNull() .notNull()

View File

@@ -157,7 +157,9 @@ function getOpenApiDocumentation() {
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z
.record(z.string(), z.any())
.nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -1467,17 +1467,6 @@ async function syncWhitelistUsers(
.where(eq(resourceWhitelist.resourceId, resourceId)); .where(eq(resourceWhitelist.resourceId, resourceId));
for (const email of whitelistUsers) { for (const email of whitelistUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!user) {
throw new Error(`User not found: ${email} in org ${orgId}`);
}
const existingWhitelistEntry = existingWhitelist.find( const existingWhitelistEntry = existingWhitelist.find(
(w) => w.email === email (w) => w.email === email
); );

View File

@@ -1,8 +1,23 @@
import { z } from "zod"; import { z } from "zod";
import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip"; import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas"; import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
const hasMaxmindCountryDb =
typeof maxmindDbPath === "string" &&
maxmindDbPath.length > 0 &&
existsSync(maxmindDbPath);
const hasMaxmindAsnDb =
typeof maxmindAsnPath === "string" &&
maxmindAsnPath.length > 0 &&
existsSync(maxmindAsnPath);
export const SiteSchema = z.object({ export const SiteSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@@ -117,6 +132,9 @@ export const RuleSchema = z
.refine( .refine(
(rule) => { (rule) => {
if (rule.match === "country") { if (rule.match === "country") {
if (!hasMaxmindCountryDb) {
return false;
}
// Check if it's a valid 2-letter country code or "ALL" // Check if it's a valid 2-letter country code or "ALL"
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL"; return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
} }
@@ -125,12 +143,15 @@ export const RuleSchema = z
{ {
path: ["value"], path: ["value"],
message: message:
"Value must be a 2-letter country code or 'ALL' when match is 'country'" "Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
} }
) )
.refine( .refine(
(rule) => { (rule) => {
if (rule.match === "asn") { if (rule.match === "asn") {
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
return false;
}
// Check if it's either AS<number> format or "ALL" // Check if it's either AS<number> format or "ALL"
const asNumberPattern = /^AS\d+$/i; const asNumberPattern = /^AS\d+$/i;
return asNumberPattern.test(rule.value) || rule.value === "ALL"; return asNumberPattern.test(rule.value) || rule.value === "ALL";
@@ -140,7 +161,7 @@ export const RuleSchema = z
{ {
path: ["value"], path: ["value"],
message: message:
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'" "ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
} }
) )
.refine( .refine(

View File

@@ -1,5 +1,7 @@
import z from "zod"; import z from "zod";
import ipaddr from "ipaddr.js"; import ipaddr from "ipaddr.js";
import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
export function isValidCIDR(cidr: string): boolean { export function isValidCIDR(cidr: string): boolean {
return ( return (
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return true; return true;
} }
export const RESOURCE_RULE_MATCH_TYPES = [
"CIDR",
"IP",
"PATH",
"COUNTRY",
"ASN",
"REGION"
] as const;
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
export function getResourceRuleValueValidationError(
match: ResourceRuleMatchType,
value: string
): string | null {
switch (match) {
case "CIDR":
return isValidCIDR(value) ? null : "Invalid CIDR provided";
case "IP":
return isValidIP(value) ? null : "Invalid IP provided";
case "PATH":
return isValidUrlGlobPattern(value)
? null
: "Invalid URL glob pattern provided";
case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY":
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
case "ASN":
return /^AS\d+$/i.test(value.trim())
? null
: "Invalid ASN provided";
default:
return "Invalid rule match type provided";
}
}
export function isUrlValid(url: string | undefined) { export function isUrlValid(url: string | undefined) {
if (!url) return true; // the link is optional in the schema so if it's empty it's valid if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp( var pattern = new RegExp(

View File

@@ -1,11 +1,15 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, Resource } from "@server/db"; import { db, Resource } from "@server/db";
import { resources, userOrgs, userResources, roleResources } from "@server/db"; import { resources, userOrgs } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import {
getRoleResourceAccess,
getUserResourceAccess
} from "@server/db/queries/verifySessionQueries";
export async function verifyResourceAccess( export async function verifyResourceAccess(
req: Request, req: Request,
@@ -116,37 +120,22 @@ export async function verifyResourceAccess(
const roleResourceAccess = const roleResourceAccess =
(req.userOrgRoleIds?.length ?? 0) > 0 (req.userOrgRoleIds?.length ?? 0) > 0
? await db ? await getRoleResourceAccess(
.select() resource.resourceId,
.from(roleResources) req.userOrgRoleIds!
.where( )
and( : null;
eq(roleResources.resourceId, resource.resourceId),
inArray(
roleResources.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess) {
return next(); return next();
} }
const userResourceAccess = await db const userResourceAccess = await getUserResourceAccess(
.select() userId,
.from(userResources) resource.resourceId
.where( );
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resource.resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) { if (userResourceAccess) {
return next(); return next();
} }

View File

@@ -208,7 +208,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -112,4 +112,4 @@ export async function deleteAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -32,7 +32,10 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { GetAlertRuleResponse, WebhookAlertConfig } from "@server/routers/alertRule/types"; import {
GetAlertRuleResponse,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -55,7 +58,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -101,7 +101,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -72,7 +72,9 @@ export async function exportConnectionAuditLogs(
); );
} }
const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params); const parsedParams = queryConnectionAuditLogsParams.safeParse(
req.params
);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
@@ -112,4 +114,4 @@ export async function exportConnectionAuditLogs(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -11,7 +11,14 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { accessAuditLog, logsDb, resources, siteResources, db, primaryDb } from "@server/db"; import {
accessAuditLog,
logsDb,
resources,
siteResources,
db,
primaryDb
} from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -150,21 +157,30 @@ export function queryAccess(data: Q) {
.orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id)); .orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id));
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAccess>>) { async function enrichWithResourceDetails(
logs: Awaited<ReturnType<typeof queryAccess>>
) {
const resourceIds = logs const resourceIds = logs
.map(log => log.resourceId) .map((log) => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
const siteResourceIds = logs const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null) .filter((log) => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId) .map((log) => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) { if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); return logs.map((log) => ({
...log,
resourceName: null,
resourceNiceId: null
}));
} }
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>(); const resourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (resourceIds.length > 0) { if (resourceIds.length > 0) {
const resourceDetails = await primaryDb const resourceDetails = await primaryDb
@@ -181,7 +197,10 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAc
} }
} }
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>(); const siteResourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (siteResourceIds.length > 0) { if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb const siteResourceDetails = await primaryDb
@@ -194,12 +213,15 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAc
.where(inArray(siteResources.siteResourceId, siteResourceIds)); .where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) { for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); siteResourceMap.set(r.siteResourceId, {
name: r.name,
niceId: r.niceId
});
} }
} }
// Enrich logs with resource details // Enrich logs with resource details
return logs.map(log => { return logs.map((log) => {
if (log.resourceId != null) { if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId); const details = resourceMap.get(log.resourceId);
return { return {
@@ -273,11 +295,11 @@ async function queryUniqueFilterAttributes(
// Fetch resource names from main database for the unique resource IDs // Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources const resourceIds = uniqueResources
.map(row => row.id) .map((row) => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources const siteResourceIds = uniqueSiteResources
.map(row => row.id) .map((row) => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = []; let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
@@ -293,7 +315,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [ resourcesWithNames = [
...resourcesWithNames, ...resourcesWithNames,
...resourceDetails.map(r => ({ ...resourceDetails.map((r) => ({
id: r.resourceId, id: r.resourceId,
name: r.name name: r.name
})) }))
@@ -311,7 +333,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [ resourcesWithNames = [
...resourcesWithNames, ...resourcesWithNames,
...siteResourceDetails.map(r => ({ ...siteResourceDetails.map((r) => ({
id: r.siteResourceId, id: r.siteResourceId,
name: r.name name: r.name
})) }))
@@ -344,7 +366,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -171,7 +171,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -459,7 +459,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -17,8 +17,7 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { sessions, sessionTransferToken } from "@server/db"; import { db, safeRead, sessions, sessionTransferToken } from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
@@ -57,15 +56,19 @@ export async function transferSession(
sha256(new TextEncoder().encode(token)) sha256(new TextEncoder().encode(token))
); );
const [existing] = await db const result = await safeRead((db) =>
.select() db
.from(sessionTransferToken) .select()
.where(eq(sessionTransferToken.token, tokenRaw)) .from(sessionTransferToken)
.innerJoin( .where(eq(sessionTransferToken.token, tokenRaw))
sessions, .innerJoin(
eq(sessions.sessionId, sessionTransferToken.sessionId) sessions,
) eq(sessions.sessionId, sessionTransferToken.sessionId)
.limit(1); )
.limit(1)
);
const [existing] = result;
if (!existing) { if (!existing) {
return next( return next(

View File

@@ -45,7 +45,7 @@ const getOrgSchema = z.strictObject({
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -121,7 +121,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -46,7 +46,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -29,7 +29,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({}); const paramsSchema = z.strictObject({});
const querySchema = z.strictObject({ const querySchema = z.strictObject({
subdomain: z.string(), subdomain: z.string()
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
}); });
@@ -48,7 +48,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -33,7 +33,8 @@ const paramsSchema = z
registry.registerPath({ registry.registerPath({
method: "delete", method: "delete",
path: "/org/{orgId}/event-streaming-destination/{destinationId}", path: "/org/{orgId}/event-streaming-destination/{destinationId}",
description: "Delete an event streaming destination for a specific organization.", description:
"Delete an event streaming destination for a specific organization.",
tags: [OpenAPITags.Org], tags: [OpenAPITags.Org],
request: { request: {
params: paramsSchema params: paramsSchema
@@ -44,7 +45,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -115,4 +116,4 @@ export async function deleteEventStreamingDestination(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -47,7 +47,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -74,7 +74,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -79,7 +79,10 @@ import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import {
ResourceSessionValidationResult,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@@ -216,9 +219,9 @@ export type ResourceWithAuth = {
password: ResourcePassword | ResourcePolicyPassword | null; password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean; applyRules: boolean | null;
sso: boolean; sso: boolean | null;
emailWhitelistEnabled: boolean; emailWhitelistEnabled: boolean | null;
org: Org; org: Org;
}; };
@@ -1754,11 +1757,34 @@ hybridRouter.post(
resourceId resourceId
); );
// this is for backward compatibility with nodes that did not have the policy id checking
const modifiedResult: ResourceSessionValidationResult = {
...result,
resourceSession: result.resourceSession
? {
...result.resourceSession,
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
pincodeId:
result.resourceSession.policyPincodeId ??
result.resourceSession.pincodeId ??
null,
passwordId:
result.resourceSession.policyPasswordId ??
result.resourceSession.passwordId ??
null,
whitelistId:
result.resourceSession.policyWhitelistId ??
result.resourceSession.whitelistId ??
null
}
: null
};
return response(res, { return response(res, {
data: result, data: modifiedResult,
success: true, success: true,
error: false, error: false,
message: result.resourceSession message: modifiedResult.resourceSession
? "Resource session token is valid" ? "Resource session token is valid"
: "Resource session token is invalid or expired", : "Resource session token is invalid or expired",
status: HttpCode.OK status: HttpCode.OK

View File

@@ -22,7 +22,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -107,6 +107,26 @@ export async function createOrgLabel(
} }
} }
const [existingLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (existingLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
const label = await db.transaction(async (tx) => { const label = await db.transaction(async (tx) => {
const [label] = await tx const [label] = await tx
.insert(labels) .insert(labels)

View File

@@ -16,7 +16,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm"; import { and, eq, ne, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -74,6 +74,29 @@ export async function updateOrgLabel(
const { name, color } = parsedBody.data; const { name, color } = parsedBody.data;
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
const [duplicateLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
ne(labels.labelId, labelId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (duplicateLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
}
const [label] = await db const [label] = await db
.update(labels) .update(labels)
.set({ .set({

View File

@@ -69,7 +69,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -127,7 +127,8 @@ export async function createOrgOidcIdp(
let { autoProvision } = parsedBody.data; let { autoProvision } = parsedBody.data;
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted if (build == "saas") {
// this is not paywalled with a ee license because this whole endpoint is restricted
const subscribed = await isSubscribed( const subscribed = await isSubscribed(
orgId, orgId,
tierMatrix.deviceApprovals tierMatrix.deviceApprovals

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -62,7 +62,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -78,7 +78,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -33,9 +33,8 @@ import {
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
const niceId = await getUniqueResourcePolicyName(orgId); const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
); );
} }
} }

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -45,7 +45,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -61,7 +61,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -135,7 +135,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -164,7 +164,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -42,7 +42,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -35,7 +35,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -162,7 +162,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -1,4 +1,11 @@
import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import {
logsDb,
requestAuditLog,
resources,
siteResources,
db,
primaryDb
} from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -127,16 +134,16 @@ export function queryRequest(data: Q) {
return logsDb return logsDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId, orgId: requestAuditLog.orgId,
action: requestAuditLog.action, action: requestAuditLog.action,
reason: requestAuditLog.reason, reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType, actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor, actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId, actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId, resourceId: requestAuditLog.resourceId,
siteResourceId: requestAuditLog.siteResourceId, siteResourceId: requestAuditLog.siteResourceId,
ip: requestAuditLog.ip, ip: requestAuditLog.ip,
location: requestAuditLog.location, location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent, userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata, metadata: requestAuditLog.metadata,
@@ -154,21 +161,30 @@ export function queryRequest(data: Q) {
.orderBy(desc(requestAuditLog.timestamp)); .orderBy(desc(requestAuditLog.timestamp));
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) { async function enrichWithResourceDetails(
logs: Awaited<ReturnType<typeof queryRequest>>
) {
const resourceIds = logs const resourceIds = logs
.map(log => log.resourceId) .map((log) => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
const siteResourceIds = logs const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null) .filter((log) => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId) .map((log) => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) { if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); return logs.map((log) => ({
...log,
resourceName: null,
resourceNiceId: null
}));
} }
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>(); const resourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (resourceIds.length > 0) { if (resourceIds.length > 0) {
const resourceDetails = await primaryDb const resourceDetails = await primaryDb
@@ -185,7 +201,10 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
} }
} }
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>(); const siteResourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (siteResourceIds.length > 0) { if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb const siteResourceDetails = await primaryDb
@@ -198,12 +217,15 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
.where(inArray(siteResources.siteResourceId, siteResourceIds)); .where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) { for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); siteResourceMap.set(r.siteResourceId, {
name: r.name,
niceId: r.niceId
});
} }
} }
// Enrich logs with resource details // Enrich logs with resource details
return logs.map(log => { return logs.map((log) => {
if (log.resourceId != null) { if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId); const details = resourceMap.get(log.resourceId);
return { return {
@@ -247,7 +269,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -333,11 +355,11 @@ async function queryUniqueFilterAttributes(
// Fetch resource names from main database for the unique resource IDs // Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources const resourceIds = uniqueResources
.map(row => row.id) .map((row) => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources const siteResourceIds = uniqueSiteResources
.map(row => row.id) .map((row) => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = []; let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
@@ -353,7 +375,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [ resourcesWithNames = [
...resourcesWithNames, ...resourcesWithNames,
...resourceDetails.map(r => ({ ...resourceDetails.map((r) => ({
id: r.resourceId, id: r.resourceId,
name: r.name name: r.name
})) }))
@@ -371,7 +393,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [ resourcesWithNames = [
...resourcesWithNames, ...resourcesWithNames,
...siteResourceDetails.map(r => ({ ...siteResourceDetails.map((r) => ({
id: r.siteResourceId, id: r.siteResourceId,
name: r.name name: r.name
})) }))

View File

@@ -1,14 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { import { users, userOrgs, orgs, idpOrg, idp, idpOidcConfig } from "@server/db";
users,
userOrgs,
orgs,
idpOrg,
idp,
idpOidcConfig
} from "@server/db";
import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm"; import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -57,7 +50,7 @@ export type LookupUserResponse = {
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),
@@ -169,46 +162,54 @@ export async function lookupUser(
); );
// Deduplicate orgs (user might have multiple memberships in same org) // Deduplicate orgs (user might have multiple memberships in same org)
const uniqueOrgs = new Map<string, typeof userOrgMemberships[0]>(); const uniqueOrgs = new Map<
string,
(typeof userOrgMemberships)[0]
>();
for (const membership of userOrgMemberships) { for (const membership of userOrgMemberships) {
if (!uniqueOrgs.has(membership.orgId)) { if (!uniqueOrgs.has(membership.orgId)) {
uniqueOrgs.set(membership.orgId, membership); uniqueOrgs.set(membership.orgId, membership);
} }
} }
const orgsData = Array.from(uniqueOrgs.values()).map((membership) => { const orgsData = Array.from(uniqueOrgs.values()).map(
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP (membership) => {
// Only show IdPs where the user's idpId matches // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
// Internal users don't have an idpId, so they won't see any IdPs // Only show IdPs where the user's idpId matches
const orgIdpsList = orgIdps // Internal users don't have an idpId, so they won't see any IdPs
.filter((idp) => { const orgIdpsList = orgIdps
if (idp.orgId !== membership.orgId) { .filter((idp) => {
if (idp.orgId !== membership.orgId) {
return false;
}
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
// This means user.idpId must match idp.idpId
if (
user.idpId !== null &&
user.idpId === idp.idpId
) {
return true;
}
return false; return false;
} })
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP .map((idp) => ({
// This means user.idpId must match idp.idpId idpId: idp.idpId,
if (user.idpId !== null && user.idpId === idp.idpId) { name: idp.idpName,
return true; variant: idp.variant
} }));
return false;
})
.map((idp) => ({
idpId: idp.idpId,
name: idp.idpName,
variant: idp.variant
}));
// Check if user has internal auth for this org // Check if user has internal auth for this org
// User has internal auth if they have an internal account type // User has internal auth if they have an internal account type
const orgHasInternalAuth = hasInternalAuth; const orgHasInternalAuth = hasInternalAuth;
return { return {
orgId: membership.orgId, orgId: membership.orgId,
orgName: membership.orgName, orgName: membership.orgName,
idps: orgIdpsList, idps: orgIdpsList,
hasInternalAuth: orgHasInternalAuth hasInternalAuth: orgHasInternalAuth
}; };
}); }
);
accounts.push({ accounts.push({
userId: user.userId, userId: user.userId,

View File

@@ -20,7 +20,8 @@ import {
ResourcePolicyPincode, ResourcePolicyPincode,
ResourcePolicyPassword, ResourcePolicyPassword,
ResourcePolicyHeaderAuth, ResourcePolicyHeaderAuth,
ResourceRule ResourceRule,
ResourceSession
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -144,9 +145,9 @@ export async function verifyResourceSession(
| ResourcePolicyHeaderAuth | ResourcePolicyHeaderAuth
| null; | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean; applyRules: boolean | null;
sso: boolean; sso: boolean | null;
emailWhitelistEnabled: boolean; emailWhitelistEnabled: boolean | null;
org: Org; org: Org;
} }
| undefined = localCache.get(resourceCacheKey); | undefined = localCache.get(resourceCacheKey);
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
if (resourceSessionToken) { if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`; const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey); let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) { if (!resourceSession) {
const result = await validateResourceSessionToken( const result = await validateResourceSessionToken(
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
orgId: resource.orgId, orgId: resource.orgId,
location: ipCC, location: ipCC,
apiKey: { apiKey: {
name: resourceSession.accessTokenTitle, name: null,
apiKeyId: resourceSession.accessTokenId apiKeyId: resourceSession.accessTokenId
} }
}, },
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
location: ipCC, location: ipCC,
user: { user: {
username: allowedUserData.username, username: allowedUserData.username,
userId: resourceSession.userId userId: allowedUserData.userId
} }
}, },
parsedBody.data parsedBody.data

View File

@@ -37,7 +37,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -60,7 +60,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -62,7 +62,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -80,7 +80,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -30,7 +30,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -94,7 +94,11 @@ export async function blockClient(
// Send terminate signal if there's an associated OLM and it's connected // Send terminate signal if there's an associated OLM and it's connected
if (client.olmId && client.online) { if (client.olmId && client.online) {
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); await sendTerminateClient(
client.clientId,
OlmErrorCodes.TERMINATED_BLOCKED,
client.olmId
);
} }
}); });

View File

@@ -65,7 +65,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -66,7 +66,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -31,7 +31,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -259,7 +259,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -287,7 +287,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -340,18 +340,18 @@ export async function getClient(
// Build fingerprint data if available // Build fingerprint data if available
const fingerprintData = client.currentFingerprint const fingerprintData = client.currentFingerprint
? { ? {
username: client.currentFingerprint.username || null, username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null, hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null, platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null, osVersion: client.currentFingerprint.osVersion || null,
kernelVersion: kernelVersion:
client.currentFingerprint.kernelVersion || null, client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null, arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null, deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null, serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null, firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null lastSeen: client.currentFingerprint.lastSeen || null
} }
: null; : null;
// Build posture data if available (platform-specific) // Build posture data if available (platform-specific)

View File

@@ -218,7 +218,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -219,7 +219,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -13,7 +13,7 @@ import semver from "semver";
const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
export async function convertTargetsIfNessicary( export async function convertTargetsIfNecessary(
newtId: string, newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) { ) {
@@ -47,7 +47,7 @@ export async function addTargets(
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null version?: string | null
) { ) {
targets = await convertTargetsIfNessicary(newtId, targets); targets = await convertTargetsIfNecessary(newtId, targets);
await sendToClient( await sendToClient(
newtId, newtId,
@@ -64,7 +64,7 @@ export async function removeTargets(
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null version?: string | null
) { ) {
targets = await convertTargetsIfNessicary(newtId, targets); targets = await convertTargetsIfNecessary(newtId, targets);
await sendToClient( await sendToClient(
newtId, newtId,

View File

@@ -28,7 +28,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -42,7 +42,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -43,7 +43,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -666,6 +666,13 @@ authenticated.get(
resource.getResourcePolicies resource.getResourcePolicies
); );
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put( authenticated.put(
"/resource-policy/:resourcePolicyId", "/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess, verifyResourcePolicyAccess,

View File

@@ -31,7 +31,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -29,7 +29,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -44,7 +44,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -152,34 +152,64 @@ export async function buildClientConfigurationForNewtClient(
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
for (const resource of allSiteResources) { if (allSiteResources.length === 0) {
// Get clients associated with this specific resource return {
const resourceClients = await db peers: validPeers,
.select({ targets: targetsToSend
clientId: clients.clientId, };
pubKey: clients.pubKey, }
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
const resourceTargets = await generateSubnetProxyTargetV2( // Batch fetch all client associations for every site resource in one query
resource, // to avoid an N+1 lookup that would issue thousands of queries when a site
resourceClients // has many resources.
const siteResourceIds = allSiteResources.map((r) => r.siteResourceId);
const resourceClientRows = await db
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId,
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(clients.clientId, clientSiteResourcesAssociationsCache.clientId)
)
.where(
inArray(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResourceIds
)
); );
const clientsByResourceId = new Map<
number,
{ clientId: number; pubKey: string | null; subnet: string | null }[]
>();
for (const row of resourceClientRows) {
let list = clientsByResourceId.get(row.siteResourceId);
if (!list) {
list = [];
clientsByResourceId.set(row.siteResourceId, list);
}
list.push({
clientId: row.clientId,
pubKey: row.pubKey,
subnet: row.subnet
});
}
const resourceTargetsArr = await Promise.all(
allSiteResources.map((resource) =>
generateSubnetProxyTargetV2(
resource,
clientsByResourceId.get(resource.siteResourceId) ?? []
)
)
);
for (const resourceTargets of resourceTargetsArr) {
if (resourceTargets) { if (resourceTargets) {
targetsToSend.push(...resourceTargets); targetsToSend.push(...resourceTargets);
} }

View File

@@ -6,7 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets"; import { convertTargetsIfNecessary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -113,7 +113,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
exitNode exitNode
); );
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
return { return {
message: { message: {

View File

@@ -49,7 +49,7 @@ export type CreateOlmResponse = {
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -34,7 +34,7 @@ const paramsSchema = z
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -36,7 +36,7 @@ const querySchema = z.object({
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -47,7 +47,7 @@ const paramsSchema = z
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -49,10 +49,7 @@ async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles) .from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where( .where(
and( and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
); );
const isAdmin = roleRows.some((r) => r.isAdmin); const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -89,7 +86,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -80,7 +80,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -30,7 +30,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -43,7 +43,7 @@ const listOrgsSchema = z.object({
// content: { // content: {
// "application/json": { // "application/json": {
// schema: z.object({ // schema: z.object({
// data: z.unknown().nullable(), // data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(), // success: z.boolean(),
// error: z.boolean(), // error: z.boolean(),
// message: z.string(), // message: z.string(),

View File

@@ -27,7 +27,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -68,7 +68,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
isValidCIDR, getResourceRuleValueValidationError,
isValidIP, RESOURCE_RULE_MATCH_TYPES
isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
enum: ["ACCEPT", "DROP", "PASS"], enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action" description: "rule action"
}), }),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({ match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
type: "string", type: "string",
enum: ["CIDR", "IP", "PATH"], enum: [...RESOURCE_RULE_MATCH_TYPES],
description: "rule match" description: "rule match"
}), }),
value: z.string().min(1), value: z.string().min(1),
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
} }
for (const rule of rules) { for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { const validationError = getResourceRuleValueValidationError(
rule.match,
rule.value
);
if (validationError) {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, validationError)
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
); );
} }
} }

View File

@@ -46,7 +46,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,8 @@ const addRoleToResourceParamsSchema = z
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/resource/{resourceId}/roles/add", path: "/resource/{resourceId}/roles/add",
description: "Add a single role to a resource.", description:
"Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Role], tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
request: { request: {
params: addRoleToResourceParamsSchema, params: addRoleToResourceParamsSchema,
@@ -46,7 +47,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -28,7 +28,8 @@ const addUserToResourceParamsSchema = z
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/resource/{resourceId}/users/add", path: "/resource/{resourceId}/users/add",
description: "Add a single user to a resource.", description:
"Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.User], tags: [OpenAPITags.PublicResource, OpenAPITags.User],
request: { request: {
params: addUserToResourceParamsSchema, params: addUserToResourceParamsSchema,
@@ -46,7 +47,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -168,7 +168,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -49,7 +49,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -37,7 +37,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -29,7 +29,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -1,4 +1,4 @@
import { db, resources } from "@server/db"; import { db, resourcePolicies, resources } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -41,6 +41,15 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) {
} }
} }
async function queryInlinePolicy(resourcePolicyId: number) {
const [res] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
return res;
}
export type GetResourceResponse = Omit< export type GetResourceResponse = Omit<
NonNullable<Awaited<ReturnType<typeof query>>>, NonNullable<Awaited<ReturnType<typeof query>>>,
"headers" "headers"
@@ -66,7 +75,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -94,7 +103,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),
@@ -132,12 +141,31 @@ export async function getResource(
); );
} }
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
let returnData = resource;
if (isInlinePolicy) {
// get the policy
const policy = await queryInlinePolicy(
resource.defaultResourcePolicyId!
);
returnData = {
...returnData,
sso: policy?.sso || null,
emailWhitelistEnabled: policy?.emailWhitelistEnabled || null,
applyRules: policy?.applyRules || null,
skipToIdpId: policy?.idpId || null
};
}
return response<GetResourceResponse>(res, { return response<GetResourceResponse>(res, {
data: { data: {
...resource, ...returnData,
headers: resource.headers headers: returnData.headers
? JSON.parse(resource.headers) ? JSON.parse(returnData.headers)
: resource.headers : returnData.headers
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -225,7 +225,7 @@ export async function getResourceAuthInfo(
wildcard: resource.wildcard ?? false, wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false, whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
skipToIdpId: resource.skipToIdpId, skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
orgId: resource.orgId, orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null postAuthPath: resource.postAuthPath ?? null
}, },

View File

@@ -54,7 +54,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, DB_TYPE } from "@server/db"; import { db, DB_TYPE, type Label } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm"; import { and, asc, eq, or, inArray, sql } from "drizzle-orm";
import { import {
resources, resources,
userResources, userResources,
@@ -20,12 +20,17 @@ import {
userSiteResources, userSiteResources,
roleSiteResources, roleSiteResources,
siteNetworks, siteNetworks,
sites sites,
labels,
resourceLabels,
siteResourceLabels
} from "@server/db"; } from "@server/db";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams"; import { getFirstString } from "@server/lib/requestParams";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export async function getUserResources( export async function getUserResources(
req: Request, req: Request,
@@ -198,9 +203,9 @@ export async function getUserResources(
fullDomain: string | null; fullDomain: string | null;
ssl: boolean; ssl: boolean;
enabled: boolean; enabled: boolean;
sso: boolean; sso: boolean | null;
mode: string; mode: string;
emailWhitelistEnabled: boolean; emailWhitelistEnabled: boolean | null;
policyEmailWhitelistEnabled: boolean | null; policyEmailWhitelistEnabled: boolean | null;
}> = []; }> = [];
if (uniqueResourceIds.length > 0) { if (uniqueResourceIds.length > 0) {
@@ -353,6 +358,73 @@ export async function getUserResources(
}); });
} }
const resourceIdList = resourcesData.map((r) => r.resourceId);
const siteResourceIdList = siteResourcesData.map(
(r) => r.siteResourceId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
[labelsForResources, labelsForSiteResources] = await Promise.all([
resourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
]);
}
// Check for password, pincode, and whitelist protection for each resource // Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all( const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => { resourcesData.map(async (resource) => {
@@ -453,7 +525,10 @@ export async function getUserResources(
sso: resource.sso, sso: resource.sso,
password: hasPassword, password: hasPassword,
pincode: hasPincode, pincode: hasPincode,
whitelist: hasWhitelist whitelist: hasWhitelist,
labels: labelsForResources.filter(
(l) => l.resourceId === resource.resourceId
)
}; };
}) })
); );
@@ -479,7 +554,10 @@ export async function getUserResources(
siteNiceIds: siteResource.siteNiceIds, siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses, siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines, siteOnlines: siteResource.siteOnlines,
type: "site" as const type: "site" as const,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === siteResource.siteResourceId
)
}; };
}); });
@@ -514,6 +592,7 @@ export type GetUserResourcesResponse = {
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
mode: string; mode: string;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>; }>;
siteResources: Array<{ siteResources: Array<{
siteResourceId: number; siteResourceId: number;
@@ -535,6 +614,7 @@ export type GetUserResourcesResponse = {
siteAddresses: (string | null)[]; siteAddresses: (string | null)[];
siteOnlines: boolean[]; siteOnlines: boolean[];
type: "site"; type: "site";
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}>; }>;
}; };
}; };

View File

@@ -45,7 +45,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -58,7 +58,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

View File

@@ -82,7 +82,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
data: z.unknown().nullable(), data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(), success: z.boolean(),
error: z.boolean(), error: z.boolean(),
message: z.string(), message: z.string(),

Some files were not shown because too many files have changed in this diff Show More