diff --git a/README.md b/README.md index d9a50dbe..3b261122 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Designed with developer experience in mind, the CLI makes it easy to integrate * - 🏗️ Automate policy operations in CI/CD with IaC and GitOps - ✨ Generate policies from natural language using AI - 🔐 Manage users, roles, and permissions directly from your terminal +- 🌍 Multi-region support for US and EU deployments > :bulb: The CLI is fully open source and is built with Pastel, using TypeScript and a React-style architecture. Contributions welcome! @@ -146,13 +147,48 @@ The `login` command will take you to the browser to perform user authentication - `--api-key ` - store a Permit API key in your workstation keychain instead of running browser authentication - `--workspace ` - predefined workspace key to skip the workspace selection step +- `--region ` - specify the Permit region to use (`default: us`). The region determines which Permit.io API endpoints the CLI will communicate with. -**Example:** +**Examples:** + +Login with default US region: ```bash $ permit login ``` +Login with EU region: + +```bash +$ permit login --region eu +``` + +Login with API key and EU region: + +```bash +$ permit login --api-key permit_key_abc123 --region eu +``` + +**Region Support:** + +Permit.io operates in multiple regions. When you log in with a specific region, the CLI will: + +- Store your region preference in your system keychain +- Use the appropriate regional endpoints for all subsequent commands +- Generate Terraform configurations with the correct regional API URLs + +Available regions: + +- `us` (default) - United States region (`https://api.permit.io`) +- `eu` - European Union region (`https://api.eu.permit.io`) + +You can also set the region using the `PERMIT_REGION` environment variable: + +```bash +export PERMIT_REGION=eu +permit login +``` + --- #### `permit logout` @@ -387,6 +423,8 @@ Export your Permit environment configuration as a Terraform HCL file. This is useful for users who want to start working with Terraform after configuring their Permit settings through the UI or API. The command exports all environment content (resources, roles, user sets, resource sets, condition sets) in the Permit Terraform provider format. +**Note:** The export includes all roles, including default roles (admin, editor, viewer) with their actual permissions. This allows you to manage role permissions consistently across environments using Infrastructure as Code. The Terraform provider will update existing roles or create them if they don't exist. + **Arguments (Optional)** - `--api-key ` - a Permit API key to authenticate the operation. If not provided, the command will use the AuthProvider to get the API key you logged in with. @@ -412,6 +450,28 @@ Print out the output to the console - $ permit env export terraform ``` +**Region Support:** + +The generated Terraform configuration will automatically use the correct API URL based on your configured region: + +- **US region**: `api_url = "https://api.permit.io"` +- **EU region**: `api_url = "https://api.eu.permit.io"` + +The region is determined by: + +1. The `PERMIT_REGION` environment variable (if set) +2. The region stored from your last `permit login --region ` command +3. Defaults to `us` if no region is specified + +Example for EU region: + +```bash +$ export PERMIT_REGION=eu +$ permit env export terraform --file permit-eu-config.tf +``` + +This ensures that when you run `terraform apply`, the Terraform provider will communicate with the correct regional Permit.io API. + ## Fine-Grained Authorization Configuration Use natural language commands with AI to instantly set up and enforce fine-grained authorization policies. diff --git a/source/commands/env/export/generators/RoleGenerator.ts b/source/commands/env/export/generators/RoleGenerator.ts index 9a1d3e8f..dc9d87eb 100644 --- a/source/commands/env/export/generators/RoleGenerator.ts +++ b/source/commands/env/export/generators/RoleGenerator.ts @@ -8,10 +8,6 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Define default global roles that should be excluded from creation -// These roles already exist in Permit by default -const DEFAULT_GLOBAL_ROLES = ['admin', 'editor', 'viewer']; - interface RoleData { key: string; terraformId: string; @@ -183,10 +179,8 @@ export class RoleGenerator implements HCLGenerator { ): string { let terraformId = roleKey; - const isDefaultRole = DEFAULT_GLOBAL_ROLES.includes(roleKey); - - // For duplicate roles or default roles, use resource__role format - if (isDuplicate || isDefaultRole || this.usedTerraformIds.has(roleKey)) { + // For duplicate roles, use resource__role format + if (isDuplicate || this.usedTerraformIds.has(roleKey)) { terraformId = resourceKey ? `${resourceKey}__${roleKey}` : `global_${roleKey}`; @@ -300,13 +294,6 @@ export class RoleGenerator implements HCLGenerator { const validRoles: RoleData[] = []; for (const role of roles) { - // Skip default global roles that already exist in the system - if (DEFAULT_GLOBAL_ROLES.includes(role.key)) { - // Still add to the ID map for role derivations - this.roleIdMap.set(role.key, role.key); - continue; - } - // Generate Terraform ID const terraformId = this.generateTerraformId(role.key); diff --git a/source/commands/env/export/utils.ts b/source/commands/env/export/utils.ts index b2169660..825823ec 100644 --- a/source/commands/env/export/utils.ts +++ b/source/commands/env/export/utils.ts @@ -1,4 +1,5 @@ import { WarningCollector } from './types.js'; +import { getPermitApiUrl } from '../../../config.js'; export function createSafeId(...parts: string[]): string { return parts @@ -36,7 +37,7 @@ variable "PERMIT_API_KEY" { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = "${getPermitApiUrl()}" api_key = var.PERMIT_API_KEY } `; diff --git a/source/commands/login.tsx b/source/commands/login.tsx index 92c8d4ec..e759f075 100644 --- a/source/commands/login.tsx +++ b/source/commands/login.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Text } from 'ink'; import { type infer as zInfer, object, string } from 'zod'; import { option } from 'pastel'; -import { saveAuthToken } from '../lib/auth.js'; +import { saveAuthToken, saveRegion } from '../lib/auth.js'; +import { setRegion } from '../config.js'; import LoginFlow from '../components/LoginFlow.js'; import EnvironmentSelection, { ActiveState, @@ -25,6 +26,14 @@ export const options = object({ description: 'Use predefined workspace to Login', }), ), + region: string() + .optional() + .describe( + option({ + description: 'Permit region: us or eu (default: us)', + alias: 'r', + }), + ), }); type Props = { @@ -38,9 +47,14 @@ type Props = { }; export default function Login({ - options: { apiKey, workspace }, + options: { apiKey, workspace, region }, loginSuccess, }: Props) { + // Set region IMMEDIATELY before anything else (synchronously) + if (region && (region === 'us' || region === 'eu')) { + setRegion(region as 'us' | 'eu'); + } + const [state, setState] = useState<'login' | 'signup' | 'env' | 'done'>( 'login', ); @@ -51,6 +65,13 @@ export default function Login({ const [organization, setOrganization] = useState(''); const [environment, setEnvironment] = useState(''); + // Save region to keystore after successful login + useEffect(() => { + if (region && (region === 'us' || region === 'eu')) { + saveRegion(region as 'us' | 'eu'); + } + }, [region]); + const onEnvironmentSelectSuccess = useCallback( async ( organisation: ActiveState, diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx index 4d8a03c0..aa06ea06 100644 --- a/source/components/AuthProvider.tsx +++ b/source/components/AuthProvider.tsx @@ -13,7 +13,7 @@ import React, { useState, } from 'react'; import { Text, Newline } from 'ink'; -import { loadAuthToken } from '../lib/auth.js'; +import { loadAuthToken, loadRegion } from '../lib/auth.js'; import Login from '../commands/login.js'; import { ApiKeyCreate, @@ -131,6 +131,11 @@ export function AuthProvider({ redirect_scope: 'organization' | 'project' | 'login', ) => { try { + // Load region from storage BEFORE validating API key + await loadRegion().catch(() => { + // Ignore errors - will default to 'us' + }); + const token = await loadAuthToken(); const { valid, @@ -188,6 +193,11 @@ export function AuthProvider({ useEffect(() => { if (state === 'validate') { (async () => { + // Load region from storage BEFORE validating API key + await loadRegion().catch(() => { + // Ignore errors - will default to 'us' + }); + const { valid, scope: keyScope, diff --git a/source/components/policy/CreateSimpleWizard.tsx b/source/components/policy/CreateSimpleWizard.tsx index 1e0b14cc..c6683740 100644 --- a/source/components/policy/CreateSimpleWizard.tsx +++ b/source/components/policy/CreateSimpleWizard.tsx @@ -30,6 +30,10 @@ export default function CreateSimpleWizard({ const parsedActions = useParseActions(presentActions); const parsedRoles = useParseRoles(presentRoles); + // Track if preset data has been processed + const [hasProcessedPresetData, setHasProcessedPresetData] = + React.useState(false); + // Initialize step based on preset values const getInitialStep = () => { if (presentResources && presentActions && presentRoles) return 'complete'; @@ -109,10 +113,13 @@ export default function CreateSimpleWizard({ }; const handleRolesComplete = useCallback( - async (roles: components['schemas']['RoleCreate'][]) => { + async ( + roles: components['schemas']['RoleCreate'][], + resourcesToCreate?: components['schemas']['ResourceCreate'][], + ) => { setStatus('processing'); try { - await createBulkResources(resources); + await createBulkResources(resourcesToCreate || resources); await createBulkRoles(roles); setStatus('success'); setResources([]); @@ -125,6 +132,9 @@ export default function CreateSimpleWizard({ ); useEffect(() => { + // Only process preset data once + if (hasProcessedPresetData) return; + const processPresetData = async () => { if (presentResources && presentActions && presentRoles) { try { @@ -133,7 +143,8 @@ export default function CreateSimpleWizard({ actions: parsedActions, })); setResources(resourcesWithActions); - await handleRolesComplete(parsedRoles); + setHasProcessedPresetData(true); + await handleRolesComplete(parsedRoles, resourcesWithActions); } catch (err) { handleError((err as Error).message); } @@ -143,10 +154,17 @@ export default function CreateSimpleWizard({ actions: parsedActions, })); setResources(resourcesWithActions); + setHasProcessedPresetData(true); } }; - processPresetData(); + if ( + (presentResources && presentActions && presentRoles) || + (presentResources && presentActions) + ) { + processPresetData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ presentResources, presentActions, @@ -154,8 +172,6 @@ export default function CreateSimpleWizard({ parsedResources, parsedActions, parsedRoles, - handleRolesComplete, - handleError, ]); return ( diff --git a/source/components/policy/create/TerraformGenerator.tsx b/source/components/policy/create/TerraformGenerator.tsx index ea1c9e3d..85b0ba33 100644 --- a/source/components/policy/create/TerraformGenerator.tsx +++ b/source/components/policy/create/TerraformGenerator.tsx @@ -1,4 +1,5 @@ import { PolicyData } from './types.js'; +import { getPermitApiUrl } from '../../../config.js'; interface TerraformGeneratorProps { tableData: PolicyData; @@ -37,7 +38,7 @@ export const generateTerraform = ({ } provider "permitio" { - api_url = "https://api.permit.io" + api_url = "${getPermitApiUrl()}" api_key = "${authToken}" } diff --git a/source/config.ts b/source/config.ts index 8bfc121f..a019f0f9 100644 --- a/source/config.ts +++ b/source/config.ts @@ -1,21 +1,93 @@ export const KEY_FILE_PATH = './permit.key'; export const KEYSTORE_PERMIT_SERVICE_NAME = 'Permit.io'; export const DEFAULT_PERMIT_KEYSTORE_ACCOUNT = 'PERMIT_DEFAULT_ENV'; -export const CLOUD_PDP_URL = 'https://cloudpdp.api.permit.io'; -export const PERMIT_API_URL = 'https://api.permit.io'; -export const PERMIT_API_STATISTICS_URL = - 'https://pdp-statistics.api.permit.io/v2/stats'; -export const API_URL = 'https://api.permit.io/v2/'; -export const FACTS_API_URL = `${API_URL}facts/`; -export const API_PDPS_CONFIG_URL = `${API_URL}pdps/me/config`; -export const PERMIT_ORIGIN_URL = 'https://app.permit.io'; +export const REGION_KEYSTORE_ACCOUNT = 'PERMIT_REGION'; + +// Region type +export type PermitRegion = 'us' | 'eu'; + +// Get region from environment variable or default to 'us' +let currentRegion: PermitRegion = + (process.env['PERMIT_REGION'] as PermitRegion) || 'us'; + +// Function to set the current region +export const setRegion = (region: PermitRegion) => { + currentRegion = region; +}; + +// Function to get the current region +export const getRegion = (): PermitRegion => { + return currentRegion; +}; + +// Function to get region-specific subdomain +const getRegionSubdomain = (region: PermitRegion): string => { + return region === 'eu' ? 'eu.' : ''; +}; + +// Region-aware URL getters +export const getPermitApiUrl = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://api.${subdomain}permit.io`; +}; + +export const getPermitOriginUrl = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://app.${subdomain}permit.io`; +}; + +export const getAuthPermitDomain = (): string => { + const subdomain = getRegionSubdomain(currentRegion); + return `app.${subdomain}permit.io`; +}; + +export const getCloudPdpUrl = (): string => { + if (currentRegion === 'eu') { + return 'https://cloudpdp.api.eu-central-1.permit.io'; + } + return 'https://cloudpdp.api.permit.io'; +}; + +export const getPermitApiStatisticsUrl = (): string => { + if (currentRegion === 'eu') { + return 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats'; + } + return 'https://pdp-statistics.api.permit.io/v2/stats'; +}; + +export const getApiUrl = (): string => { + return `${getPermitApiUrl()}/v2/`; +}; + +export const getFactsApiUrl = (): string => { + return `${getApiUrl()}facts/`; +}; + +export const getApiPdpsConfigUrl = (): string => { + return `${getApiUrl()}pdps/me/config`; +}; + +export const getAuthApiUrl = (): string => { + return `${getPermitApiUrl()}/v1/`; +}; + +// Legacy exports (maintain backwards compatibility) +export const CLOUD_PDP_URL = getCloudPdpUrl(); +export const PERMIT_API_URL = getPermitApiUrl(); +export const PERMIT_API_STATISTICS_URL = getPermitApiStatisticsUrl(); +export const API_URL = getApiUrl(); +export const FACTS_API_URL = getFactsApiUrl(); +export const API_PDPS_CONFIG_URL = getApiPdpsConfigUrl(); +export const PERMIT_ORIGIN_URL = getPermitOriginUrl(); +export const AUTH_PERMIT_DOMAIN = getAuthPermitDomain(); +export const AUTH_API_URL = getAuthApiUrl(); export const AUTH_REDIRECT_HOST = 'localhost'; export const AUTH_REDIRECT_PORT = 62419; export const AUTH_REDIRECT_URI = `http://${AUTH_REDIRECT_HOST}:${AUTH_REDIRECT_PORT}`; -export const AUTH_PERMIT_DOMAIN = 'app.permit.io'; -export const AUTH_API_URL = 'https://api.permit.io/v1/'; +// auth.permit.io is common for both regions export const AUTH_PERMIT_URL = 'https://auth.permit.io'; +export const AUTH0_AUDIENCE = 'https://api.permit.io/v1/'; // Auth0 audience is shared across all regions export const TERRAFORM_PERMIT_URL = 'https://permit-cli-terraform.up.railway.app'; diff --git a/source/hooks/export/PermitSDK.ts b/source/hooks/export/PermitSDK.ts index ec4badc3..3d855d07 100644 --- a/source/hooks/export/PermitSDK.ts +++ b/source/hooks/export/PermitSDK.ts @@ -1,5 +1,6 @@ import { Permit } from 'permitio'; import React from 'react'; +import { getPermitApiUrl } from '../../config.js'; export const usePermitSDK = ( token: string, @@ -10,6 +11,7 @@ export const usePermitSDK = ( new Permit({ token, pdp: pdpUrl, + apiUrl: getPermitApiUrl(), }), [token, pdpUrl], ); diff --git a/source/hooks/useClient.ts b/source/hooks/useClient.ts index 27790a95..9d52299d 100644 --- a/source/hooks/useClient.ts +++ b/source/hooks/useClient.ts @@ -1,5 +1,9 @@ import type { paths } from '../lib/api/v1.js'; -import { CLOUD_PDP_URL, PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js'; +import { + getCloudPdpUrl, + getPermitApiUrl, + getPermitOriginUrl, +} from '../config.js'; import type { paths as PdpPaths } from '../lib/api/pdp-v1.js'; import createClient, { @@ -308,10 +312,10 @@ const useClient = () => { const authenticatedApiClient = useCallback(() => { const client = createClient({ - baseUrl: PERMIT_API_URL, + baseUrl: getPermitApiUrl(), headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), 'Content-Type': 'application/json', Authorization: `Bearer ${globalTokenGetterSetter.tokenGetter()}`, }, @@ -321,7 +325,7 @@ const useClient = () => { const authenticatedPdpClient = useCallback((pdp_url?: string) => { const client = createClient({ - baseUrl: pdp_url ?? CLOUD_PDP_URL, + baseUrl: pdp_url ?? getCloudPdpUrl(), headers: { Accept: '*/*', 'Content-Type': 'application/json', @@ -503,10 +507,10 @@ const useClient = () => { cookie?: string | null, ) => { const client = createClient({ - baseUrl: PERMIT_API_URL, + baseUrl: getPermitApiUrl(), headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, Cookie: cookie, diff --git a/source/hooks/useParseActions.ts b/source/hooks/useParseActions.ts index 85d0ee77..ded8f83c 100644 --- a/source/hooks/useParseActions.ts +++ b/source/hooks/useParseActions.ts @@ -1,54 +1,59 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; export function useParseActions( actionStrings?: string[], ): Record { - if (!actionStrings || actionStrings.length === 0) return {}; - - try { - return actionStrings.reduce( - (acc, action) => { - // Split action definition into main part and attributes part - const [mainPart, attributesPart] = action.split('@').map(s => s.trim()); - - if (!mainPart) { - throw new Error('Invalid action format'); - } - - // Split main part into key and description - const [key, description] = mainPart.split(':').map(s => s.trim()); - - if (!key) { - throw new Error(`Invalid action key in: ${action}`); - } - - // Process attributes if they exist - const attributes = attributesPart - ? attributesPart.split(',').reduce( - (attrAcc, attr) => { - const attrKey = attr.trim(); - if (attrKey) { - attrAcc[attrKey] = {} as never; - } - return attrAcc; - }, - {} as Record, - ) - : undefined; - - acc[key] = { - name: key, - description: description || undefined, - attributes: attributes, - }; - - return acc; - }, - {} as Record, - ); - } catch (err) { - throw new Error( - `Invalid action format. Expected ["key:description@attribute1,attribute2"], got ${JSON.stringify(actionStrings) + err}`, - ); - } + return useMemo(() => { + if (!actionStrings || actionStrings.length === 0) return {}; + + try { + return actionStrings.reduce( + (acc, action) => { + // Split action definition into main part and attributes part + const [mainPart, attributesPart] = action + .split('@') + .map(s => s.trim()); + + if (!mainPart) { + throw new Error('Invalid action format'); + } + + // Split main part into key and description + const [key, description] = mainPart.split(':').map(s => s.trim()); + + if (!key) { + throw new Error(`Invalid action key in: ${action}`); + } + + // Process attributes if they exist + const attributes = attributesPart + ? attributesPart.split(',').reduce( + (attrAcc, attr) => { + const attrKey = attr.trim(); + if (attrKey) { + attrAcc[attrKey] = {} as never; + } + return attrAcc; + }, + {} as Record, + ) + : undefined; + + acc[key] = { + name: key, + description: description || undefined, + attributes: attributes, + }; + + return acc; + }, + {} as Record, + ); + } catch (err) { + throw new Error( + `Invalid action format. Expected ["key:description@attribute1,attribute2"], got ${JSON.stringify(actionStrings) + err}`, + ); + } + }, [actionStrings]); } diff --git a/source/hooks/useParseResources.ts b/source/hooks/useParseResources.ts index 16d33532..d7bdc556 100644 --- a/source/hooks/useParseResources.ts +++ b/source/hooks/useParseResources.ts @@ -1,51 +1,56 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; export function useParseResources( resourceStrings?: string[], ): components['schemas']['ResourceCreate'][] { - if (!resourceStrings || resourceStrings.length === 0) return []; + return useMemo(() => { + if (!resourceStrings || resourceStrings.length === 0) return []; - try { - return resourceStrings.map(resource => { - // Split resource definition into key and attributes - const [mainPart, attributesPart] = resource.split('@').map(s => s.trim()); + try { + return resourceStrings.map(resource => { + // Split resource definition into key and attributes + const [mainPart, attributesPart] = resource + .split('@') + .map(s => s.trim()); - if (!mainPart) { - throw new Error('Invalid resource format'); - } + if (!mainPart) { + throw new Error('Invalid resource format'); + } - // Split main part into key and name/description - const [key, name] = mainPart.split(':').map(s => s.trim()); + // Split main part into key and name/description + const [key, name] = mainPart.split(':').map(s => s.trim()); - if (!key) { - throw new Error(`Invalid resource key in: ${resource}`); - } + if (!key) { + throw new Error(`Invalid resource key in: ${resource}`); + } - // Process attributes if they exist - const attributes = attributesPart - ? attributesPart.split(',').reduce( - (acc, attr) => { - const attrKey = attr.trim(); - if (attrKey) { - acc[attrKey] = {} as never; - } - return acc; - }, - {} as Record, - ) - : undefined; + // Process attributes if they exist + const attributes = attributesPart + ? attributesPart.split(',').reduce( + (acc, attr) => { + const attrKey = attr.trim(); + if (attrKey) { + acc[attrKey] = {} as never; + } + return acc; + }, + {} as Record, + ) + : undefined; - return { - key, - name: name || key, - description: name || undefined, - attributes, - actions: {}, - }; - }); - } catch (err) { - throw new Error( - `Invalid resource format. Expected ["key:name@attribute1,attribute2"], got ${JSON.stringify(resourceStrings) + err}`, - ); - } + return { + key, + name: name || key, + description: name || undefined, + attributes, + actions: {}, + }; + }); + } catch (err) { + throw new Error( + `Invalid resource format. Expected ["key:name@attribute1,attribute2"], got ${JSON.stringify(resourceStrings) + err}`, + ); + } + }, [resourceStrings]); } diff --git a/source/hooks/useParseRoles.ts b/source/hooks/useParseRoles.ts index bddd2c9d..a79f8965 100644 --- a/source/hooks/useParseRoles.ts +++ b/source/hooks/useParseRoles.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { components } from '../lib/api/v1.js'; /** @@ -9,52 +10,56 @@ export function useParseRoles( roleStrings?: string[], availableActions?: string[], ): components['schemas']['RoleCreate'][] { - if (!roleStrings || roleStrings.length === 0) return []; + return useMemo(() => { + if (!roleStrings || roleStrings.length === 0) return []; - try { - return roleStrings.map(roleStr => { - const trimmed = roleStr.trim(); - if (!trimmed) throw new Error('Invalid role format'); + try { + return roleStrings.map(roleStr => { + const trimmed = roleStr.trim(); + if (!trimmed) throw new Error('Invalid role format'); - const [roleKey, ...permParts] = trimmed.split('|').map(s => s.trim()); - if (!roleKey || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(roleKey)) { - throw new Error(`Invalid role key in: ${roleStr}`); - } - if (permParts.length === 0) { - throw new Error( - `Role must have at least one resource or resource:action in: ${roleStr}`, - ); - } + const [roleKey, ...permParts] = trimmed.split('|').map(s => s.trim()); + if (!roleKey || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(roleKey)) { + throw new Error(`Invalid role key in: ${roleStr}`); + } + if (permParts.length === 0) { + throw new Error( + `Role must have at least one resource or resource:action in: ${roleStr}`, + ); + } - const permissions: string[] = []; - for (const perm of permParts) { - if (!perm) continue; - const [resource, action] = perm.split(':').map(s => s.trim()); - if (!resource) - throw new Error(`Invalid resource in permission: ${perm}`); - if (!action) { - // Expand to all actions if availableActions is provided - if (availableActions && availableActions.length > 0) { - permissions.push(...availableActions.map(a => `${resource}:${a}`)); + const permissions: string[] = []; + for (const perm of permParts) { + if (!perm) continue; + const [resource, action] = perm.split(':').map(s => s.trim()); + if (!resource) + throw new Error(`Invalid resource in permission: ${perm}`); + if (!action) { + // Expand to all actions if availableActions is provided + if (availableActions && availableActions.length > 0) { + permissions.push( + ...availableActions.map(a => `${resource}:${a}`), + ); + } else { + permissions.push(resource); // fallback: just resource + } } else { - permissions.push(resource); // fallback: just resource + permissions.push(`${resource}:${action}`); } - } else { - permissions.push(`${resource}:${action}`); } - } - return { - key: roleKey, - name: roleKey, - permissions, - }; - }); - } catch (err) { - throw new Error( - `Invalid role format. Expected ["role|resource:action|resource:action"], got ${JSON.stringify( - roleStrings, - )}. ${err instanceof Error ? err.message : err}`, - ); - } + return { + key: roleKey, + name: roleKey, + permissions, + }; + }); + } catch (err) { + throw new Error( + `Invalid role format. Expected ["role|resource:action|resource:action"], got ${JSON.stringify( + roleStrings, + )}. ${err instanceof Error ? err.message : err}`, + ); + } + }, [roleStrings, availableActions]); } diff --git a/source/lib/api.ts b/source/lib/api.ts index 9f4759f4..b1509447 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,4 +1,4 @@ -import { PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js'; +import { getPermitApiUrl, getPermitOriginUrl } from '../config.js'; type ApiResponse = { headers: Headers; @@ -26,7 +26,7 @@ export const apiCall = async ( method, headers: { Accept: '*/*', - Origin: PERMIT_ORIGIN_URL, + Origin: getPermitOriginUrl(), Authorization: `Bearer ${token}`, Cookie: cookie ?? '', 'Content-Type': 'application/json', @@ -38,7 +38,7 @@ export const apiCall = async ( } try { - const res = await fetch(`${PERMIT_API_URL}/${endpoint}`, options); + const res = await fetch(`${getPermitApiUrl()}/${endpoint}`, options); if (!res.ok) { const errorText = await res.json(); diff --git a/source/lib/auth.ts b/source/lib/auth.ts index f1d352e8..b9b4ebcb 100644 --- a/source/lib/auth.ts +++ b/source/lib/auth.ts @@ -3,14 +3,17 @@ import * as http from 'node:http'; import open from 'open'; import * as pkg from 'keytar'; import { - AUTH_API_URL, - AUTH_PERMIT_DOMAIN, AUTH_REDIRECT_HOST, AUTH_REDIRECT_PORT, AUTH_REDIRECT_URI, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, KEYSTORE_PERMIT_SERVICE_NAME, AUTH_PERMIT_URL, + AUTH0_AUDIENCE, + REGION_KEYSTORE_ACCOUNT, + type PermitRegion, + setRegion, + getAuthPermitDomain, } from '../config.js'; import { URL, URLSearchParams } from 'url'; import { setTimeout } from 'timers'; @@ -74,6 +77,26 @@ export const cleanAuthToken = async () => { KEYSTORE_PERMIT_SERVICE_NAME, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, ); + await deletePassword(KEYSTORE_PERMIT_SERVICE_NAME, REGION_KEYSTORE_ACCOUNT); +}; + +export const saveRegion = async (region: PermitRegion): Promise => { + await setPassword( + KEYSTORE_PERMIT_SERVICE_NAME, + REGION_KEYSTORE_ACCOUNT, + region, + ); + setRegion(region); +}; + +export const loadRegion = async (): Promise => { + const region = await getPassword( + KEYSTORE_PERMIT_SERVICE_NAME, + REGION_KEYSTORE_ACCOUNT, + ); + const permitRegion = (region as PermitRegion) || 'us'; + setRegion(permitRegion); + return permitRegion; }; export const authCallbackServer = async (verifier: string): Promise => { @@ -139,14 +162,15 @@ export const browserAuth = async (): Promise => { } const challenge = base64UrlEncode(sha256(verifier)); + const authPermitDomain = getAuthPermitDomain(); const parameters = new URLSearchParams({ - audience: AUTH_API_URL, - screen_hint: AUTH_PERMIT_DOMAIN, - domain: AUTH_PERMIT_DOMAIN, + audience: AUTH0_AUDIENCE, + screen_hint: authPermitDomain, + domain: authPermitDomain, auth0Client: 'eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMS4xMC4yIn0=', isEAP: 'false', response_type: 'code', - fragment: `domain=${AUTH_PERMIT_DOMAIN}`, + fragment: `domain=${authPermitDomain}`, code_challenge: challenge, code_challenge_method: 'S256', client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1', diff --git a/source/lib/env/template/utils.ts b/source/lib/env/template/utils.ts index a3d3a4a6..72e1977c 100644 --- a/source/lib/env/template/utils.ts +++ b/source/lib/env/template/utils.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { TERRAFORM_PERMIT_URL } from '../../../config.js'; +import { TERRAFORM_PERMIT_URL, getPermitApiUrl } from '../../../config.js'; import { exec } from 'child_process'; import { fileURLToPath } from 'url'; @@ -60,10 +60,9 @@ export async function ApplyTemplateLocally( const tempDirPath = path.join(__dirname, tempDir); try { - const tfContent = getFileContent(fileName).replace( - '{{API_KEY}}', - '"' + apiKey + '"', - ); + const tfContent = getFileContent(fileName) + .replace('{{API_KEY}}', '"' + apiKey + '"') + .replace('{{API_URL}}', '"' + getPermitApiUrl() + '"'); const dirPath = path.dirname(TF_File); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); diff --git a/source/templates/blog-access.tf b/source/templates/blog-access.tf index 3b5e5d8d..3a398eb4 100644 --- a/source/templates/blog-access.tf +++ b/source/templates/blog-access.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/blogging-platform.tf b/source/templates/blogging-platform.tf index 248b90a8..9aced5e6 100644 --- a/source/templates/blogging-platform.tf +++ b/source/templates/blogging-platform.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/fga-tradeoffs.tf b/source/templates/fga-tradeoffs.tf index 9f7f685e..94581bf8 100644 --- a/source/templates/fga-tradeoffs.tf +++ b/source/templates/fga-tradeoffs.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/mesa-verde-banking-demo.tf b/source/templates/mesa-verde-banking-demo.tf index 59822572..ce9b5d3a 100644 --- a/source/templates/mesa-verde-banking-demo.tf +++ b/source/templates/mesa-verde-banking-demo.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/source/templates/orm-data-filtering.tf b/source/templates/orm-data-filtering.tf index 4319da97..494f82c8 100644 --- a/source/templates/orm-data-filtering.tf +++ b/source/templates/orm-data-filtering.tf @@ -8,7 +8,7 @@ terraform { } provider "permitio" { - api_url = "https://api.permit.io" + api_url = {{API_URL}} api_key = {{API_KEY}} } diff --git a/tests/EnvCopy.test.tsx b/tests/EnvCopy.test.tsx index bea4c45a..b2c4eff2 100644 --- a/tests/EnvCopy.test.tsx +++ b/tests/EnvCopy.test.tsx @@ -14,6 +14,7 @@ vi.mock('../source/lib/auth.js', () => ({ browserAuth: vi.fn(), authCallbackServer: vi.fn(), tokenType: vi.fn(), + loadRegion: vi.fn(() => Promise.resolve()), TokenType: { APIToken: 'APIToken', Invalid: 'Invalid', diff --git a/tests/EnvSelect.test.tsx b/tests/EnvSelect.test.tsx index 7808bede..960f6b45 100644 --- a/tests/EnvSelect.test.tsx +++ b/tests/EnvSelect.test.tsx @@ -75,6 +75,7 @@ vi.mock('../source/components/EnvironmentSelection.js', () => ({ vi.mock('../source/lib/auth.js', () => ({ saveAuthToken: vi.fn(), + loadRegion: vi.fn(() => Promise.resolve()), })); beforeEach(() => { diff --git a/tests/export/RoleGenerator.test.tsx b/tests/export/RoleGenerator.test.tsx index f0252130..55f0207b 100644 --- a/tests/export/RoleGenerator.test.tsx +++ b/tests/export/RoleGenerator.test.tsx @@ -107,7 +107,7 @@ describe('RoleGenerator', () => { ); }); - it('filters out default roles', async () => { + it('exports default roles', async () => { const defaultRolesMockPermit = createMockPermit({ resources: [ { @@ -120,9 +120,17 @@ describe('RoleGenerator', () => { }, ], roles: [ - { key: 'admin', name: 'Admin' }, - { key: 'editor', name: 'Editor' }, - { key: 'viewer', name: 'Viewer' }, + { + key: 'admin', + name: 'Admin', + permissions: ['document:read', 'document:write', 'document:delete'], + }, + { + key: 'editor', + name: 'Editor', + permissions: ['document:read', 'document:write'], + }, + { key: 'viewer', name: 'Viewer', permissions: ['document:read'] }, { key: 'custom', name: 'Custom Role' }, ], }); @@ -133,10 +141,15 @@ describe('RoleGenerator', () => { ); const hcl = await generator.generateHCL(); - expect(hcl).not.toContain('resource "permitio_role" "viewer"'); - expect(hcl).not.toContain('resource "permitio_role" "editor"'); - expect(hcl).not.toContain('resource "permitio_role" "admin"'); + // Verify default roles are exported + expect(hcl).toContain('resource "permitio_role" "viewer"'); + expect(hcl).toContain('resource "permitio_role" "editor"'); + expect(hcl).toContain('resource "permitio_role" "admin"'); expect(hcl).toContain('resource "permitio_role" "custom"'); + + // Verify default roles include their actual permissions (they can differ from defaults) + expect(hcl).toContain('document:delete'); // admin has delete permission + expect(hcl).toContain('document:write'); // editor has write permission }); it('handles role dependencies correctly', async () => { diff --git a/tests/lib/auth-oauth-region.test.ts b/tests/lib/auth-oauth-region.test.ts new file mode 100644 index 00000000..440a84d2 --- /dev/null +++ b/tests/lib/auth-oauth-region.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import open from 'open'; + +// Mock dependencies +vi.mock('keytar', () => ({ + default: { + getPassword: vi.fn(), + setPassword: vi.fn(), + deletePassword: vi.fn(), + }, +})); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +vi.mock('node:crypto', () => ({ + randomBytes: vi.fn().mockReturnValue(Buffer.from('mock-verifier')), + createHash: vi.fn().mockImplementation(() => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => Buffer.from('mock-hash')), + })), +})); + +vi.mock('http', () => ({ + createServer: vi.fn().mockReturnValue({ + listen: vi.fn(), + close: vi.fn(), + }), +})); + +import * as auth from '../../source/lib/auth.js'; + +describe('Auth OAuth - Region Support', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('OAuth URL Generation', () => { + it('should open browser with Auth0 URL for US region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('us'); + + await auth.browserAuth(); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining('https://auth.permit.io/authorize'), + ); + }); + + it('should open browser with Auth0 URL for EU region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining('https://auth.permit.io/authorize'), + ); + }); + + it('should use same Auth0 audience for both US and EU regions', async () => { + const { AUTH0_AUDIENCE } = await import('../../source/config.js'); + + // Auth0 audience should be constant + expect(AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); + }); + + it('should include correct domain parameter for US region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('us'); + + await auth.browserAuth(); + + // Check that the URL contains the US domain + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('domain=app.permit.io'); + }); + + it('should include correct domain parameter for EU region', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + // Check that the URL contains the EU domain + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('domain=app.eu.permit.io'); + }); + + it('should include shared Auth0 audience in OAuth parameters', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + // Check that the URL contains the shared audience + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain( + 'audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F', + ); + }); + + it('should include screen_hint with correct region domain', async () => { + const { setRegion } = await import('../../source/config.js'); + setRegion('eu'); + + await auth.browserAuth(); + + const callArgs = (open as any).mock.calls[0][0]; + expect(callArgs).toContain('screen_hint=app.eu.permit.io'); + }); + }); + + describe('OAuth Parameters Consistency', () => { + it('should use consistent parameters across regions except domain', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test US + setRegion('us'); + await auth.browserAuth(); + const usUrl = (open as any).mock.calls[0][0]; + + vi.clearAllMocks(); + + // Test EU + setRegion('eu'); + await auth.browserAuth(); + const euUrl = (open as any).mock.calls[0][0]; + + // Both should have same client_id + expect(usUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); + expect(euUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); + + // Both should have same audience (shared) + expect(usUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + expect(euUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + + // Both should have same redirect_uri + expect(usUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); + expect(euUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); + + // Both should have PKCE parameters + expect(usUrl).toContain('code_challenge_method=S256'); + expect(euUrl).toContain('code_challenge_method=S256'); + }); + + it('should only differ in domain and screen_hint between regions', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test US + setRegion('us'); + await auth.browserAuth(); + const usUrl = (open as any).mock.calls[0][0]; + + vi.clearAllMocks(); + + // Test EU + setRegion('eu'); + await auth.browserAuth(); + const euUrl = (open as any).mock.calls[0][0]; + + // US should have US domain + expect(usUrl).toContain('domain=app.permit.io'); + expect(usUrl).not.toContain('domain=app.eu.permit.io'); + + // EU should have EU domain + expect(euUrl).toContain('domain=app.eu.permit.io'); + expect(euUrl).not.toContain('domain=app.permit.io'); + }); + }); + + describe('Critical Bug Fix Verification', () => { + it('should NOT use region-specific API URL as Auth0 audience (bug fix)', async () => { + const { setRegion } = await import('../../source/config.js'); + + // The bug was using region-specific URL as audience + // Correct behavior: use shared Auth0 audience + setRegion('eu'); + await auth.browserAuth(); + + const url = (open as any).mock.calls[0][0]; + + // Should NOT contain EU-specific API URL as audience + expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); + + // Should contain shared audience + expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + }); + + it('should use shared Auth0 audience even when switching regions', async () => { + const { setRegion } = await import('../../source/config.js'); + + // Test multiple region switches + const regions: Array<'us' | 'eu'> = ['us', 'eu', 'us', 'eu']; + + for (const region of regions) { + setRegion(region); + await auth.browserAuth(); + + const url = (open as any).mock.calls[ + (open as any).mock.calls.length - 1 + ][0]; + + // Always use shared audience + expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + + // Never use region-specific API URL + expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); + } + }); + }); +}); diff --git a/tests/lib/auth.test.ts b/tests/lib/auth.test.ts index 9bc12fe3..eefdcfb4 100644 --- a/tests/lib/auth.test.ts +++ b/tests/lib/auth.test.ts @@ -1,12 +1,10 @@ import { describe, vi, it, expect } from 'vitest'; -import * as auth from '../../source/lib/auth'; import * as http from 'http'; import { KEYSTORE_PERMIT_SERVICE_NAME, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, } from '../../source/config'; import open from 'open'; -import * as pkg from 'keytar'; // Mock dependencies vi.mock('http', () => ({ @@ -37,6 +35,9 @@ vi.mock('keytar', () => { return { ...keytar, default: keytar }; }); +import * as auth from '../../source/lib/auth'; +import * as pkg from 'keytar'; + describe('Token Type', () => { it('Should return correct token type', async () => { const demoToken = 'permit_key_'.concat('a'.repeat(97)); @@ -123,3 +124,36 @@ describe('Browser Auth', () => { expect(open).toHaveBeenCalled(); // Ensure the browser opens }); }); + +describe('Region Support in Auth', () => { + it('Should save region to keystore', async () => { + const { setPassword } = pkg; + await auth.saveRegion('eu'); + expect(setPassword).toHaveBeenCalledWith( + 'Permit.io', + 'PERMIT_REGION', + 'eu', + ); + }); + + it('Should load region from keystore', async () => { + const { getPassword } = pkg; + (getPassword as any).mockResolvedValueOnce('eu'); + const region = await auth.loadRegion(); + expect(region).toBe('eu'); + expect(getPassword).toHaveBeenCalledWith('Permit.io', 'PERMIT_REGION'); + }); + + it('Should default to us region when no region is stored', async () => { + const { getPassword } = pkg; + (getPassword as any).mockResolvedValueOnce(null); + const region = await auth.loadRegion(); + expect(region).toBe('us'); + }); + + it('Should clean region when cleaning auth token', async () => { + const { deletePassword } = pkg; + await auth.cleanAuthToken(); + expect(deletePassword).toHaveBeenCalledWith('Permit.io', 'PERMIT_REGION'); + }); +}); diff --git a/tests/lib/client-region.test.ts b/tests/lib/client-region.test.ts new file mode 100644 index 00000000..b361c1e3 --- /dev/null +++ b/tests/lib/client-region.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +let currentRegion: 'us' | 'eu' = 'us'; + +const getRegionSubdomain = (region: 'us' | 'eu'): string => { + return region === 'eu' ? 'eu.' : ''; +}; + +// Mock the config module +vi.mock('../../source/config.js', async () => { + return { + getPermitApiUrl: vi.fn(() => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://api.${subdomain}permit.io`; + }), + getPermitOriginUrl: vi.fn(() => { + const subdomain = getRegionSubdomain(currentRegion); + return `https://app.${subdomain}permit.io`; + }), + getCloudPdpUrl: vi.fn(() => { + if (currentRegion === 'eu') { + return 'https://cloudpdp.api.eu-central-1.permit.io'; + } + return 'https://cloudpdp.api.permit.io'; + }), + setRegion: vi.fn((region: 'us' | 'eu') => { + currentRegion = region; + }), + getRegion: vi.fn(() => currentRegion), + }; +}); + +// Mock React hooks +vi.mock('react', async () => { + const React = await vi.importActual('react'); + return { + ...React, + useCallback: fn => fn, + useMemo: fn => fn(), + }; +}); + +// Mock openapi-fetch +vi.mock('openapi-fetch', () => ({ + default: vi.fn(config => { + return { + baseUrl: config.baseUrl, + headers: config.headers, + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + }; + }), +})); + +describe('useClient - Region Support', () => { + beforeEach(() => { + vi.clearAllMocks(); + currentRegion = 'us'; + }); + + describe('Region-Aware URL Functions', () => { + it('should use correct API URL for US region', async () => { + const { getPermitApiUrl } = await import('../../source/config.js'); + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + }); + + it('should use correct API URL for EU region', async () => { + const { setRegion, getPermitApiUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + }); + + it('should use correct PDP URL for US region', async () => { + const { getCloudPdpUrl } = await import('../../source/config.js'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should use correct PDP URL for EU region', async () => { + const { setRegion, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should use correct Origin URL for US region', async () => { + const { getPermitOriginUrl } = await import('../../source/config.js'); + expect(getPermitOriginUrl()).toBe('https://app.permit.io'); + }); + + it('should use correct Origin URL for EU region', async () => { + const { setRegion, getPermitOriginUrl } = await import( + '../../source/config.js' + ); + setRegion('eu'); + expect(getPermitOriginUrl()).toBe('https://app.eu.permit.io'); + }); + }); + + describe('Region Switching', () => { + it('should update URLs when switching from US to EU', async () => { + const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + + // Start with US + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + + // Switch to EU + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + expect(getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should update URLs when switching from EU to US', async () => { + const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( + '../../source/config.js' + ); + + // Start with EU + setRegion('eu'); + expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); + + // Switch to US + setRegion('us'); + expect(getPermitApiUrl()).toBe('https://api.permit.io'); + expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + }); +}); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 00000000..1ac42dd5 --- /dev/null +++ b/tests/lib/config.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('Config - Region Support', () => { + // Reset modules before each test to ensure clean state + beforeEach(async () => { + vi.resetModules(); + delete process.env.PERMIT_REGION; + }); + + describe('Region Configuration', () => { + it('should default to US region when no env var is set', async () => { + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('us'); + }); + + it('should use EU region when PERMIT_REGION=eu is set', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('eu'); + }); + + it('should use US region when PERMIT_REGION=us is set', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getRegion()).toBe('us'); + }); + + it('should allow setting region programmatically', async () => { + const config = await import('../../source/config.js'); + config.setRegion('eu'); + expect(config.getRegion()).toBe('eu'); + config.setRegion('us'); + expect(config.getRegion()).toBe('us'); + }); + }); + + describe('US Region URLs', () => { + it('should return correct US API URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + }); + + it('should return correct US origin URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitOriginUrl()).toBe('https://app.permit.io'); + }); + + it('should return correct US auth domain', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getAuthPermitDomain()).toBe('app.permit.io'); + }); + + it('should return correct US PDP URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should return correct US statistics URL', async () => { + process.env.PERMIT_REGION = 'us'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiStatisticsUrl()).toBe( + 'https://pdp-statistics.api.permit.io/v2/stats', + ); + }); + }); + + describe('EU Region URLs', () => { + it('should return correct EU API URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); + }); + + it('should return correct EU origin URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitOriginUrl()).toBe('https://app.eu.permit.io'); + }); + + it('should return correct EU auth domain', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getAuthPermitDomain()).toBe('app.eu.permit.io'); + }); + + it('should return correct EU PDP URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + }); + + it('should return correct EU statistics URL', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getPermitApiStatisticsUrl()).toBe( + 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats', + ); + }); + }); + + describe('Auth0 Configuration', () => { + it('should use same Auth0 audience for all regions', async () => { + // Test US + process.env.PERMIT_REGION = 'us'; + const configUS = await import('../../source/config.js'); + const usAudience = configUS.AUTH0_AUDIENCE; + + vi.resetModules(); + delete process.env.PERMIT_REGION; + + // Test EU + process.env.PERMIT_REGION = 'eu'; + const configEU = await import('../../source/config.js'); + const euAudience = configEU.AUTH0_AUDIENCE; + + expect(usAudience).toBe('https://api.permit.io/v1/'); + expect(euAudience).toBe('https://api.permit.io/v1/'); + expect(usAudience).toBe(euAudience); + }); + + it('should have correct Auth0 audience constant', async () => { + const config = await import('../../source/config.js'); + expect(config.AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); + }); + + it('should have shared auth.permit.io URL', async () => { + const config = await import('../../source/config.js'); + expect(config.AUTH_PERMIT_URL).toBe('https://auth.permit.io'); + }); + }); + + describe('API URL Functions', () => { + it('should return correct API URL for default region', async () => { + const config = await import('../../source/config.js'); + expect(config.getApiUrl()).toBe('https://api.permit.io/v2/'); + }); + + it('should return correct API URL for EU region', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getApiUrl()).toBe('https://api.eu.permit.io/v2/'); + }); + + it('should return correct Facts API URL for US', async () => { + const config = await import('../../source/config.js'); + expect(config.getFactsApiUrl()).toBe('https://api.permit.io/v2/facts/'); + }); + + it('should return correct Facts API URL for EU', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getFactsApiUrl()).toBe( + 'https://api.eu.permit.io/v2/facts/', + ); + }); + + it('should return correct Auth API URL for US', async () => { + const config = await import('../../source/config.js'); + expect(config.getAuthApiUrl()).toBe('https://api.permit.io/v1/'); + }); + + it('should return correct Auth API URL for EU', async () => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(config.getAuthApiUrl()).toBe('https://api.eu.permit.io/v1/'); + }); + }); + + describe('Region Switching', () => { + it('should update URLs when region is changed', async () => { + const config = await import('../../source/config.js'); + + // Start with US + expect(config.getRegion()).toBe('us'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + + // Switch to EU + config.setRegion('eu'); + expect(config.getRegion()).toBe('eu'); + expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); + expect(config.getCloudPdpUrl()).toBe( + 'https://cloudpdp.api.eu-central-1.permit.io', + ); + + // Switch back to US + config.setRegion('us'); + expect(config.getRegion()).toBe('us'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); + expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + }); + + it('should maintain Auth0 audience when switching regions', async () => { + const config = await import('../../source/config.js'); + + const initialAudience = config.AUTH0_AUDIENCE; + config.setRegion('eu'); + const euAudience = config.AUTH0_AUDIENCE; + config.setRegion('us'); + const usAudience = config.AUTH0_AUDIENCE; + + expect(initialAudience).toBe('https://api.permit.io/v1/'); + expect(euAudience).toBe('https://api.permit.io/v1/'); + expect(usAudience).toBe('https://api.permit.io/v1/'); + }); + }); +});