├── .gitattributes ├── .prettierignore ├── terraform_server ├── Readme.md ├── Dockerfile ├── package.json └── messages.js ├── tests ├── trino │ ├── trino_config │ │ ├── node.properties │ │ ├── catalog │ │ │ ├── mysql.properties │ │ │ └── postgresql.properties │ │ ├── config.properties │ │ ├── jvm.config │ │ └── access-control.properties │ └── docker-compose.yml ├── export │ ├── mocks │ │ ├── hooks.tsx │ │ └── permit.ts │ ├── utils.test.tsx │ ├── ExportStatus.test.tsx │ ├── ExportContent.test.tsx │ └── ConditionSetGenerator.test.ts ├── utils.ts ├── cli.test.tsx ├── lib │ ├── api.test.ts │ └── gitops_utils.test.ts ├── login.test.tsx ├── PDPRun.test.tsx ├── graph.test.tsx ├── logout.test.tsx ├── components │ ├── TemplateListComponent.test.ts │ ├── HtmlGraphSaver.test.tsx │ └── env │ │ └── openapi │ │ ├── OpenapiForm.test.tsx │ │ └── OpenapiResults.test.tsx ├── auditTest │ └── views │ │ ├── ErrorView.test.tsx │ │ └── LoadingView.test.tsx ├── env │ └── apply │ │ └── trino.test.tsx ├── hooks │ ├── useResourcesAPI.test.tsx │ ├── useUsersAPI.test.tsx │ ├── useRolesAPI.test.tsx │ ├── useMemberAPI.test.tsx │ ├── usePolicyGitReposApi.test.tsx │ └── useProjectAPI.test.tsx ├── EnvTemplateApply.test.tsx ├── EnvDelete.test.tsx ├── EnvTemplateList.test.tsx ├── utils │ └── openapiUtils.test.ts └── EnvCreate.test.tsx ├── typedoc.json ├── source ├── cli.tsx ├── lib │ ├── env │ │ └── copy │ │ │ └── utils.ts │ ├── gitops │ │ └── utils.ts │ └── api.ts ├── commands │ ├── env │ │ ├── export │ │ │ ├── templates │ │ │ │ ├── user-attribute.hcl │ │ │ │ ├── relation.hcl │ │ │ │ ├── resource-set.hcl │ │ │ │ ├── condition-set.hcl │ │ │ │ ├── user-set.hcl │ │ │ │ ├── role-derivation.hcl │ │ │ │ ├── role.hcl │ │ │ │ └── resource.hcl │ │ │ ├── types.ts │ │ │ └── index.tsx │ │ ├── select.tsx │ │ ├── template │ │ │ ├── list.tsx │ │ │ └── apply.tsx │ │ ├── apply │ │ │ ├── openapi.tsx │ │ │ └── trino.tsx │ │ ├── delete.tsx │ │ ├── copy.tsx │ │ ├── create.tsx │ │ └── member.tsx │ ├── logout.tsx │ ├── graph.tsx │ ├── policy │ │ └── create │ │ │ ├── ai.tsx │ │ │ └── simple.tsx │ ├── init.tsx │ ├── opa │ │ └── policy.tsx │ ├── gitops │ │ ├── create │ │ │ └── github.tsx │ │ └── env │ │ │ └── clone.tsx │ ├── index.tsx │ ├── api │ │ ├── users │ │ │ ├── assign.tsx │ │ │ ├── unassign.tsx │ │ │ └── list.tsx │ │ └── list │ │ │ └── proxy.tsx │ ├── pdp │ │ ├── stats.tsx │ │ ├── run.tsx │ │ ├── check-url.tsx │ │ └── check.tsx │ └── test │ │ └── generate │ │ ├── code-sample.tsx │ │ └── e2e.tsx ├── hooks │ ├── openapi │ │ ├── process │ │ │ ├── openapiProcessorExports.ts │ │ │ ├── apiTypes.ts │ │ │ ├── openapiConstants.ts │ │ │ └── urlMappingProcessor.ts │ │ ├── useOpenapiApi.ts │ │ └── usePermitResourceApi.ts │ ├── export │ │ └── PermitSDK.ts │ ├── useUserApi.ts │ ├── useAuthApi.ts │ ├── useTenantApi.ts │ ├── useMemberApi.ts │ ├── useExecCommand.ts │ ├── useProjectAPI.ts │ ├── useParseResources.ts │ ├── trino │ │ └── useTrinoProcessor.ts │ ├── useParseActions.ts │ ├── usePolicyGitReposApi.ts │ ├── useGitopsCloneApi.ts │ ├── useCheckPdpApi.ts │ ├── useOrganisationApi.ts │ └── useParseRoles.ts ├── components │ ├── policy │ │ ├── create │ │ │ ├── types.ts │ │ │ ├── PolicyTables.tsx │ │ │ └── TerraformGenerator.tsx │ │ └── ResourceInput.tsx │ ├── test │ │ ├── views │ │ │ ├── ErrorView.tsx │ │ │ ├── ErrorResultView.tsx │ │ │ ├── LoadingView.tsx │ │ │ ├── DifferencesView.tsx │ │ │ ├── DifferenceResultView.tsx │ │ │ └── ResultsView.tsx │ │ ├── GeneratePolicySnapshot.tsx │ │ └── code-samples │ │ │ └── CodeSampleComponent.tsx │ ├── env │ │ ├── template │ │ │ └── ListComponent.tsx │ │ ├── trino │ │ │ └── types.ts │ │ ├── openapi │ │ │ ├── OpenapiForm.tsx │ │ │ ├── OpenapiResults.tsx │ │ │ └── OpenapiComponent.tsx │ │ └── SelectComponent.tsx │ ├── gitops │ │ ├── BranchName.tsx │ │ └── SSHKey.tsx │ ├── export │ │ └── ExportStatus.tsx │ ├── HtmlGraphSaver.ts │ ├── ui │ │ └── Table.tsx │ ├── SelectProject.tsx │ ├── LoginFlow.tsx │ ├── init │ │ ├── EnforceComponent.tsx │ │ └── PolicyStepComponent.tsx │ ├── GraphCommands.tsx │ ├── SelectEnvironment.tsx │ └── api │ │ ├── PermitUsersUnassignComponent.tsx │ │ └── PermitUsersAssignComponent.tsx ├── utils │ ├── fileSaver.ts │ ├── api │ │ └── user │ │ │ └── utils.ts │ ├── attributes.ts │ ├── init │ │ └── utils.ts │ └── fetchUtil.ts ├── implement │ ├── example.rb │ ├── example.py │ ├── example.js │ └── example.go └── templates │ └── orm-data-filtering.tf ├── .prettierrc ├── tsconfig.module.json ├── .editorconfig ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── .github ├── workflows │ ├── node.js.yml │ └── npm-publish.yml └── pull_request_template.md └── eslint.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | source/lib/api/ 3 | -------------------------------------------------------------------------------- /terraform_server/Readme.md: -------------------------------------------------------------------------------- 1 | # This is a folder which contains the server details of the terraform template details. 2 | -------------------------------------------------------------------------------- /tests/trino/trino_config/node.properties: -------------------------------------------------------------------------------- 1 | node.environment=test 2 | node.data-dir=/tmp/trino/data 3 | node.id=testnode1 -------------------------------------------------------------------------------- /tests/export/mocks/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const mockUseAuth = vi.fn(() => ({ 4 | authToken: 'mock-auth-token', 5 | })); 6 | -------------------------------------------------------------------------------- /tests/trino/trino_config/catalog/mysql.properties: -------------------------------------------------------------------------------- 1 | connector.name=mysql 2 | connection-url=jdbc:mysql://mysql:3306 3 | connection-user=testuser 4 | connection-password=testpass -------------------------------------------------------------------------------- /tests/trino/trino_config/config.properties: -------------------------------------------------------------------------------- 1 | coordinator=true 2 | node-scheduler.include-coordinator=true 3 | http-server.http.port=8080 4 | discovery.uri=http://localhost:8080 -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "exclude": ["**/*.spec.ts"], 4 | "out": "docs", 5 | "excludePrivate": true, 6 | "excludeProtected": true 7 | } 8 | -------------------------------------------------------------------------------- /source/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import Pastel from 'pastel'; 3 | 4 | const app = new Pastel({ 5 | importMeta: import.meta, 6 | name: 'permit', 7 | }); 8 | 9 | await app.run(); 10 | -------------------------------------------------------------------------------- /tests/trino/trino_config/catalog/postgresql.properties: -------------------------------------------------------------------------------- 1 | connector.name=postgresql 2 | connection-url=jdbc:postgresql://postgres:5432/testdb 3 | connection-user=testuser 4 | connection-password=testpass -------------------------------------------------------------------------------- /tests/trino/trino_config/jvm.config: -------------------------------------------------------------------------------- 1 | -server 2 | -Xmx4G 3 | -XX:+UseG1GC 4 | -XX:G1HeapRegionSize=32M 5 | -XX:+ExplicitGCInvokesConcurrent 6 | -XX:+HeapDumpOnOutOfMemoryError 7 | -XX:+ExitOnOutOfMemoryError -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "semi": true, 4 | "singleQuote": true, 5 | "quoteProps": "as-needed", 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /source/lib/env/copy/utils.ts: -------------------------------------------------------------------------------- 1 | export function cleanKey(key: string): string { 2 | // Replace spaces with underscores and remove special characters 3 | return key.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": ["node_modules/**"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/trino/trino_config/access-control.properties: -------------------------------------------------------------------------------- 1 | # access-control.name=allow-all 2 | access-control.name=opa 3 | opa.policy.uri=http://host.docker.internal:7766/trino/allowed 4 | opa.log-requests=true 5 | opa.log-responses=true 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /source/lib/gitops/utils.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'micro-key-producer/utils.js'; 2 | import ssh from 'micro-key-producer/ssh.js'; 3 | 4 | export function generateSSHKey() { 5 | const seed = randomBytes(32); 6 | return ssh(seed, 'help@permit.io'); 7 | } 8 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/user-attribute.hcl: -------------------------------------------------------------------------------- 1 | {{#each attributes}} 2 | resource "permitio_user_attribute" "{{resourceKey}}" { 3 | key = "{{key}}" 4 | type = "{{type}}" 5 | description = "{{formatDescription description}}" 6 | } 7 | {{/each}} -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | export const getMockFetchResponse = () => { 2 | return { 3 | ok: true, 4 | status: 200, 5 | statusText: 'OK', 6 | headers: new Headers({ 'Content-Type': 'application/json' }), 7 | json: async () => ({}), 8 | text: async () => JSON.stringify({}), 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /source/hooks/openapi/process/openapiProcessorExports.ts: -------------------------------------------------------------------------------- 1 | export * from './openapiConstants.js'; 2 | export * from './resourceProcessor.js'; 3 | export * from './roleProcessor.js'; 4 | export * from './relationProcessor.js'; 5 | export * from './urlMappingProcessor.js'; 6 | export * from './apiTypes.js'; 7 | -------------------------------------------------------------------------------- /terraform_server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM hashicorp/terraform:1.10 2 | 3 | RUN apk add --no-cache nodejs npm 4 | RUN npm install -g pnpm 5 | WORKDIR /app 6 | 7 | COPY package.json package-lock.json ./ 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 3000 13 | 14 | ENTRYPOINT ["sh","-c","pnpm start"] 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Permit CLI Community Code of Conduct 2 | 3 | Permit CLI follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 4 | 5 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting 6 | the maintainers via . 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "moduleResolution": "node16", 6 | "module": "Node16", 7 | "outDir": "dist", 8 | "noUncheckedIndexedAccess": true, 9 | "strict": true 10 | }, 11 | "include": ["source"], 12 | "exclude": ["node_modules", "source/lib/api"] 13 | } 14 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/relation.hcl: -------------------------------------------------------------------------------- 1 | {{#each relations}} 2 | resource "permitio_relation" "{{relation_id}}" { 3 | key = "{{key}}" 4 | name = "{{{name}}}" 5 | subject_resource = {{subject_resource_ref}} 6 | object_resource = {{object_resource_ref}} 7 | depends_on = [ 8 | {{#each dependencies}} 9 | {{this}}, 10 | {{/each}} 11 | ] 12 | } 13 | {{/each}} -------------------------------------------------------------------------------- /source/components/policy/create/types.ts: -------------------------------------------------------------------------------- 1 | export interface Resource { 2 | name: string; 3 | actions: string[]; 4 | } 5 | 6 | export interface Permission { 7 | resource: string; 8 | actions: string[]; 9 | } 10 | 11 | export interface Role { 12 | name: string; 13 | permissions: Permission[]; 14 | } 15 | 16 | export interface PolicyData { 17 | resources: Resource[]; 18 | roles: Role[]; 19 | } 20 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/resource-set.hcl: -------------------------------------------------------------------------------- 1 | {{#each sets}} 2 | resource "permitio_resource_set" "{{key}}" { 3 | name = "{{name}}" 4 | key = "{{key}}" 5 | resource = permitio_resource.{{resource}}.key 6 | conditions = {{{conditions}}} 7 | depends_on = [ 8 | {{#each depends_on}} 9 | {{this}}{{#unless @last}},{{/unless}} 10 | {{/each}} 11 | ] 12 | } 13 | {{/each}} -------------------------------------------------------------------------------- /source/hooks/export/PermitSDK.ts: -------------------------------------------------------------------------------- 1 | import { Permit } from 'permitio'; 2 | import React from 'react'; 3 | import { getPermitApiUrl } from '../../config.js'; 4 | 5 | export const usePermitSDK = ( 6 | token: string, 7 | pdpUrl: string = 'http://localhost:7766', 8 | ) => { 9 | return React.useMemo( 10 | () => 11 | new Permit({ 12 | token, 13 | pdp: pdpUrl, 14 | apiUrl: getPermitApiUrl(), 15 | }), 16 | [token, pdpUrl], 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/condition-set.hcl: -------------------------------------------------------------------------------- 1 | {{#each rules}} 2 | {{#unless isAutogenerated}} 3 | resource "permitio_condition_set_rule" "{{key}}" { 4 | user_set = permitio_user_set.{{userSet}}.key 5 | permission = "{{permission}}" 6 | resource_set = permitio_resource_set.{{resourceSet}}.key 7 | depends_on = [ 8 | permitio_resource_set.{{resourceSet}}, 9 | permitio_user_set.{{userSet}} 10 | ] 11 | } 12 | {{/unless}} 13 | {{/each}} -------------------------------------------------------------------------------- /source/commands/env/export/templates/user-set.hcl: -------------------------------------------------------------------------------- 1 | {{#each sets}} 2 | resource "permitio_user_set" "{{key}}" { 3 | key = "{{key}}" 4 | name = "{{name}}" 5 | conditions = jsonencode({{{formatConditions conditions}}}) 6 | {{#if resource}} 7 | resource = "{{resource}}" 8 | {{/if}} 9 | {{#if depends_on.length}} 10 | depends_on = [ 11 | {{#each depends_on}} 12 | {{this}}{{#unless @last}},{{/unless}} 13 | {{/each}} 14 | ] 15 | {{/if}} 16 | } 17 | {{/each}} -------------------------------------------------------------------------------- /source/components/test/views/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | 4 | interface ErrorViewProps { 5 | error: string; 6 | } 7 | 8 | /** 9 | * Displays the specific error that occurred 10 | */ 11 | const ErrorView: React.FC = ({ error }) => { 12 | return ( 13 | 14 | Error: {error} 15 | 16 | ); 17 | }; 18 | 19 | export default ErrorView; 20 | -------------------------------------------------------------------------------- /source/commands/env/export/types.ts: -------------------------------------------------------------------------------- 1 | export interface ExportState { 2 | status: string; 3 | isComplete: boolean; 4 | error: string | null; 5 | warnings: string[]; 6 | } 7 | 8 | export interface ExportOptions { 9 | key?: string; 10 | file?: string; 11 | } 12 | 13 | export interface HCLGenerator { 14 | generateHCL(): Promise; 15 | name: string; 16 | } 17 | 18 | export interface WarningCollector { 19 | addWarning(warning: string): void; 20 | getWarnings(): string[]; 21 | } 22 | -------------------------------------------------------------------------------- /source/utils/fileSaver.ts: -------------------------------------------------------------------------------- 1 | import pathModule from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | 4 | export const saveFile = async (path: string, data: string) => { 5 | try { 6 | const dir = pathModule.dirname(path ?? ''); 7 | // Ensure the directory exists 8 | await fs.mkdir(dir, { recursive: true }); 9 | 10 | await fs.writeFile(path, data, 'utf8'); 11 | return { error: null }; 12 | } catch (err) { 13 | return { error: err instanceof Error ? err.message : '' }; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/role-derivation.hcl: -------------------------------------------------------------------------------- 1 | {{#each derivations}} 2 | resource "permitio_role_derivation" "{{id}}" { 3 | role = permitio_role.{{role}}.key 4 | on_resource = permitio_resource.{{on_resource}}.key 5 | to_role = permitio_role.{{to_role}}.key 6 | resource = permitio_resource.{{resource}}.key 7 | linked_by = permitio_relation.{{linked_by}}.key 8 | depends_on = [ 9 | {{#each dependencies}} 10 | {{this}}{{#unless @last}},{{/unless}} 11 | {{/each}} 12 | ] 13 | } 14 | {{/each}} -------------------------------------------------------------------------------- /source/components/env/template/ListComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getFiles } from '../../../lib/env/template/utils.js'; 3 | 4 | import { Text, Box } from 'ink'; 5 | 6 | export default function ListComponent() { 7 | const files = getFiles() || []; 8 | 9 | return ( 10 | 11 | 12 | Templates List 13 | 14 | {files.length > 0 ? ( 15 | files.map((file, index) => • {file}) 16 | ) : ( 17 | No Templates found. 18 | )} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /tests/cli.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, vi, expect, it } from 'vitest'; 2 | 3 | vi.mock('pastel', () => ({ 4 | default: vi.fn().mockImplementation(() => ({ 5 | run: vi.fn(() => Promise.resolve()), 6 | })), 7 | option: vi.fn(config => config), 8 | })); 9 | 10 | import Pastel from 'pastel'; 11 | 12 | describe('Cli script', () => { 13 | it('Should run the pastel app', async () => { 14 | await import('../source/cli.js'); 15 | expect(Pastel).toHaveBeenCalled(); 16 | const pastelInstance = (Pastel as any).mock.results[0].value; 17 | expect(pastelInstance.run).toHaveBeenCalled(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /source/components/env/trino/types.ts: -------------------------------------------------------------------------------- 1 | export type TrinoOptions = { 2 | apiKey?: string; 3 | url: string; 4 | user: string; 5 | password?: string; 6 | catalog?: string; 7 | schema?: string; 8 | createColumnResources?: boolean; 9 | }; 10 | 11 | export interface PermitResource { 12 | key: string; 13 | name: string; 14 | description?: string; 15 | actions: string[]; 16 | attributes?: { 17 | [key: string]: { 18 | type: 19 | | 'string' 20 | | 'number' 21 | | 'object' 22 | | 'json' 23 | | 'time' 24 | | 'bool' 25 | | 'array' 26 | | 'object_array'; 27 | description?: string; 28 | }; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /source/commands/logout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Spinner from 'ink-spinner'; 3 | import { Text } from 'ink'; 4 | import { cleanAuthToken } from '../lib/auth.js'; 5 | 6 | export default function Logout() { 7 | const [loading, setLoading] = useState(true); 8 | useEffect(() => { 9 | const clearSession = async () => { 10 | await cleanAuthToken(); 11 | setLoading(false); 12 | process.exit(0); 13 | }; 14 | 15 | clearSession(); 16 | }, []); 17 | return loading ? ( 18 | 19 | 20 | Cleaning session... 21 | 22 | ) : ( 23 | Logged Out 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tests/export/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest'; 2 | import { 3 | createSafeId, 4 | createWarningCollector, 5 | generateProviderBlock, 6 | } from '../../source/commands/env/export/utils.js'; 7 | 8 | describe('Export Utils', () => { 9 | describe('createSafeId', () => { 10 | it('creates safe identifier', () => { 11 | expect(createSafeId('test-id')).toBe('test_id'); 12 | }); 13 | }); 14 | 15 | describe('createWarningCollector', () => { 16 | it('collects warnings', () => { 17 | const collector = createWarningCollector(); 18 | collector.addWarning('test warning'); 19 | expect(collector.getWarnings()).toContain('test warning'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /source/hooks/useUserApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import useClient from './useClient.js'; 3 | import { components } from '../lib/api/v1.js'; 4 | 5 | export type CreateUserBody = components['schemas']['UserCreate']; 6 | 7 | export const useUserApi = () => { 8 | const { authenticatedApiClient } = useClient(); 9 | 10 | const createUser = useCallback( 11 | async (body: CreateUserBody) => { 12 | return await authenticatedApiClient().POST( 13 | '/v2/facts/{proj_id}/{env_id}/users', 14 | undefined, 15 | body, 16 | ); 17 | }, 18 | [authenticatedApiClient], 19 | ); 20 | return useMemo( 21 | () => ({ 22 | createUser, 23 | }), 24 | [createUser], 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/role.hcl: -------------------------------------------------------------------------------- 1 | {{#each roles}} 2 | resource "permitio_role" "{{terraformId}}" { 3 | key = "{{key}}" 4 | name = "{{name}}" 5 | {{#if resource}} 6 | resource = permitio_resource.{{resource}}.key 7 | {{/if}} 8 | {{#if permissions.length}} 9 | permissions = [{{#each permissions}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}] 10 | {{/if}} 11 | {{#if description}} 12 | description = "{{description}}" 13 | {{/if}} 14 | {{#if extends.length}} 15 | extends = {{json extends}} 16 | {{/if}} 17 | {{attributes attributes}} 18 | {{#if dependencies.length}} 19 | depends_on = [{{#each dependencies}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}] 20 | {{/if}} 21 | } 22 | {{/each}} -------------------------------------------------------------------------------- /terraform_server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terraform-server", 3 | "version": "1.0.0", 4 | "description": "A minimal server to proxy TF apply request and eliminate the need to have it installed locally", 5 | "main": "server.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node server.js" 9 | }, 10 | "author": "info@permit.io", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@ai-sdk/openai": "^1.3.9", 14 | "ai": "^4.3.9", 15 | "body-parser": "^2.2.0", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.4.7", 18 | "express": "^4.21.2", 19 | "openai": "^4.95.1", 20 | "zod": "^3.24.3" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.14.0", 24 | "tsx": "^4.19.3", 25 | "typescript": "^5.8.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /source/commands/env/export/templates/resource.hcl: -------------------------------------------------------------------------------- 1 | {{#each resources}} 2 | resource "permitio_resource" "{{key}}" { 3 | name = "{{name}}" 4 | description = "{{description}}" 5 | key = "{{key}}" 6 | 7 | actions = { 8 | {{#each actions}} 9 | "{{@key}}" = { 10 | name = "{{name}}"{{#if description}} 11 | description = "{{description}}"{{/if}} 12 | }{{#unless @last}},{{/unless}} 13 | {{/each}} 14 | } 15 | {{#if attributes}} 16 | attributes = { 17 | {{#each attributes}} 18 | "{{@key}}" = { 19 | name = "{{name}}" 20 | type = "{{type}}"{{#if required}} 21 | required = {{required}}{{/if}} 22 | }{{#unless @last}},{{/unless}} 23 | {{/each}} 24 | } 25 | {{/if}} 26 | } 27 | {{/each}} -------------------------------------------------------------------------------- /source/components/test/views/ErrorResultView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from 'ink'; 3 | import { ComparisonResult } from '../auditTypes.js'; 4 | 5 | interface ErrorResultViewProps { 6 | result: ComparisonResult; 7 | } 8 | 9 | const ErrorResultView: React.FC = ({ result }) => ( 10 | <> 11 | User: {result.auditLog.user_key || result.auditLog.user_id} 12 | 13 | Resource: {result.auditLog.resource} (type:{' '} 14 | {result.auditLog.resource_type}) 15 | 16 | Action: {result.auditLog.action} 17 | Tenant: {result.auditLog.tenant} 18 | Error: {result.error} 19 | 20 | ); 21 | 22 | export default ErrorResultView; 23 | -------------------------------------------------------------------------------- /source/commands/graph.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../components/AuthProvider.js'; 3 | import Graph from '../components/GraphCommands.js'; 4 | import { type infer as zInfer, object, string } from 'zod'; 5 | import { option } from 'pastel'; 6 | 7 | export const options = object({ 8 | apiKey: string() 9 | .optional() 10 | .describe( 11 | option({ 12 | description: 'The API key for the Permit env, project or Workspace', 13 | }), 14 | ), 15 | }); 16 | 17 | type Props = { 18 | options: zInfer; 19 | }; 20 | 21 | export default function graph({ options }: Props) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /source/implement/example.rb: -------------------------------------------------------------------------------- 1 | require 'webrick' 2 | require 'permit' 3 | 4 | permit = Permit.new("{{API_KEY}}", "http://localhost:7766") 5 | 6 | server = WEBrick::HTTPServer.new(Port: 4000) 7 | 8 | server.mount_proc '/' do |*, res| 9 | res['Content-Type'] = 'application/json' 10 | 11 | permitted = permit.check("{{USER_ID}}", "{{ACTIONS}}", "{{RESOURCES}}") 12 | if permitted 13 | res.status = 200 14 | res.body = { result: "{{FIRST_NAME}} {{LAST_NAME}} is PERMITTED to {{ACTIONS}} {{RESOURCES}}!" }.to_json 15 | next 16 | end 17 | res.status = 403 18 | res.body = { result: "{{FIRST_NAME}} {{LAST_NAME}} is NOT PERMITTED to {{ACTIONS}} {{RESOURCES}}!" }.to_json 19 | 20 | end 21 | 22 | trap 'INT' do server.shutdown end 23 | 24 | server.start -------------------------------------------------------------------------------- /source/hooks/useAuthApi.ts: -------------------------------------------------------------------------------- 1 | import { apiCall } from '../lib/api.js'; 2 | import { useMemo } from 'react'; 3 | 4 | // NO_DEPRECATION: This endpoints below are not present in openapi-spec 5 | export const useAuthApi = () => { 6 | const authSwitchOrgs = async ( 7 | workspaceId: string, 8 | accessToken?: string | null, 9 | cookie?: string | null, 10 | ) => { 11 | return await apiCall( 12 | `v2/auth/switch_org/${workspaceId}`, 13 | accessToken ?? '', 14 | cookie ?? '', 15 | 'POST', 16 | ); 17 | }; 18 | 19 | const getLogin = async (token: string | null) => { 20 | return await apiCall('v2/auth/login', token ?? '', '', 'POST'); 21 | }; 22 | 23 | return useMemo( 24 | () => ({ 25 | authSwitchOrgs, 26 | getLogin, 27 | }), 28 | [], 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /tests/lib/api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi, it, expect } from 'vitest'; 2 | import * as api from '../../source/lib/api'; 3 | global.fetch = vi.fn(); 4 | describe('API', () => { 5 | it('should call the apiCall', async () => { 6 | (fetch as any).mockResolvedValueOnce({ 7 | headers: {}, 8 | ok: true, 9 | status: 200, 10 | json: async () => ({ id: 'testId', name: 'testName' }), 11 | }); 12 | const response = await api.apiCall<{ id: string; name: string }>( 13 | 'testEndpoint', 14 | 'testToken', 15 | 'testCookie', 16 | 'GET', 17 | 'testBody', 18 | ); 19 | expect(response.status).toBe(200); 20 | expect(response.response.id).toBe('testId'); 21 | expect(response.response.name).toBe('testName'); 22 | expect(response.headers).toEqual({}); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /source/commands/policy/create/ai.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | 4 | import { type infer as zInfer, object, string } from 'zod'; 5 | import { option } from 'pastel'; 6 | import { TFChatComponent } from '../../../components/policy/create/TFChatComponent.js'; 7 | 8 | export const options = object({ 9 | apiKey: string() 10 | .optional() 11 | .describe( 12 | option({ 13 | description: 'Your Permit.io API key', 14 | }), 15 | ), 16 | }); 17 | 18 | type Props = { 19 | options: zInfer; 20 | }; 21 | 22 | export default function AI({ options }: Props) { 23 | return ( 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /source/hooks/useTenantApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import useClient from './useClient.js'; 3 | import { components } from '../lib/api/v1.js'; 4 | 5 | export type CreateTenantBody = components['schemas']['TenantCreate']; 6 | export type CreateUserBody = components['schemas']['UserCreate']; 7 | 8 | export const useTenantApi = () => { 9 | const { authenticatedApiClient } = useClient(); 10 | const createTenant = useCallback( 11 | async (body: CreateTenantBody) => { 12 | return await authenticatedApiClient().POST( 13 | '/v2/facts/{proj_id}/{env_id}/tenants', 14 | undefined, 15 | body, 16 | ); 17 | }, 18 | [authenticatedApiClient], 19 | ); 20 | 21 | return useMemo( 22 | () => ({ 23 | createTenant, 24 | }), 25 | [createTenant], 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /tests/login.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { vi, expect, it, describe } from 'vitest'; 3 | import { render } from 'ink-testing-library'; 4 | import Login from '../source/commands/login'; 5 | import delay from 'delay'; 6 | import * as keytar from 'keytar'; 7 | 8 | vi.mock('keytar', () => { 9 | const keytar = { 10 | setPassword: vi.fn(), 11 | getPassword: vi.fn(), // Mocked return value 12 | deletePassword: vi.fn(), 13 | }; 14 | return { ...keytar, default: keytar }; 15 | }); 16 | 17 | describe('Login Component', () => { 18 | it('Should render the login component', async () => { 19 | const { lastFrame } = render( 20 | , 21 | ); 22 | expect(lastFrame()?.toString()).toMatch('Logging in'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/PDPRun.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { vi, describe, expect, it } from 'vitest'; 3 | import { render } from 'ink-testing-library'; 4 | import Run from '../source/commands/pdp/run'; 5 | import * as keytar from 'keytar'; 6 | 7 | vi.mock('keytar', () => { 8 | const keytar = { 9 | setPassword: vi.fn(), 10 | getPassword: vi.fn(), // Mocked return value 11 | deletePassword: vi.fn(), 12 | }; 13 | return { ...keytar, default: keytar }; 14 | }); 15 | 16 | describe('PDP Run', () => { 17 | it('Should render the PDP Run command', () => { 18 | const { getPassword } = keytar; 19 | (getPassword as any).mockResolvedValueOnce( 20 | 'permit_key_'.concat('a'.repeat(97)), 21 | ); 22 | const { lastFrame } = render(); 23 | expect(lastFrame()?.toString()).toMatch(/Loading Token/); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /source/hooks/useMemberApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import useClient from './useClient.js'; 3 | import { components } from '../lib/api/v1.js'; 4 | 5 | export type OrgMemberCreate = components['schemas']['OrgMemberCreate']; 6 | 7 | export const useMemberApi = () => { 8 | const { authenticatedApiClient } = useClient(); 9 | const inviteNewMember = useCallback( 10 | async ( 11 | body: OrgMemberCreate, 12 | inviter_name: string, 13 | inviter_email: string, 14 | ) => { 15 | return await authenticatedApiClient().POST( 16 | `/v2/members`, 17 | undefined, 18 | body, 19 | { 20 | inviter_email, 21 | inviter_name, 22 | }, 23 | ); 24 | }, 25 | [authenticatedApiClient], 26 | ); 27 | 28 | return useMemo( 29 | () => ({ 30 | inviteNewMember, 31 | }), 32 | [inviteNewMember], 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /source/commands/init.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../components/AuthProvider.js'; 3 | import { option } from 'pastel'; 4 | import zod, { type infer as zInfer } from 'zod'; 5 | import InitWizardComponent from '../components/init/InitWizardComponent.js'; 6 | 7 | export const description = 8 | 'A wizard to take users through all the steps, from configuring policy to enforcing it.'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe(option({ description: 'Your Permit.io API key' })), 15 | }); 16 | 17 | type Props = { 18 | options: zInfer; 19 | }; 20 | 21 | export default function Init({ options }: Props) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /source/components/env/openapi/OpenapiForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import TextInput from 'ink-text-input'; 4 | 5 | interface OpenapiFormProps { 6 | inputPath: string; 7 | setInputPath: (path: string) => void; 8 | onSubmit: () => void; 9 | } 10 | 11 | /** 12 | * Form component for entering the OpenAPI spec file path 13 | */ 14 | export default function OpenapiForm({ 15 | inputPath, 16 | setInputPath, 17 | onSubmit, 18 | }: OpenapiFormProps): React.ReactElement { 19 | return ( 20 | 21 | Enter the path to your OpenAPI spec file: 22 | 23 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /source/components/test/views/LoadingView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | import { ProcessPhase, ProgressState } from '../auditTypes.js'; 5 | 6 | interface LoadingViewProps { 7 | phase: ProcessPhase; 8 | progress: ProgressState; 9 | } 10 | 11 | const LoadingView: React.FC = ({ phase, progress }) => ( 12 | 13 | 14 | 15 | 16 | 17 | {' '} 18 | {phase === 'fetching' && 'Fetching audit logs...'} 19 | {phase === 'processing' && 20 | `Processing audit logs (${progress.current}/${progress.total})...`} 21 | {phase === 'checking' && 22 | `Checking against PDP (${progress.current}/${progress.total})...`} 23 | 24 | 25 | 26 | ); 27 | 28 | export default LoadingView; 29 | -------------------------------------------------------------------------------- /source/components/test/views/DifferencesView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { ComparisonResult } from '../auditTypes.js'; 4 | import ErrorResultView from './ErrorResultView.js'; 5 | import DifferenceResultView from './DifferenceResultView.js'; 6 | 7 | interface DifferencesViewProps { 8 | results: ComparisonResult[]; 9 | } 10 | 11 | const DifferencesView: React.FC = ({ results }) => ( 12 | 13 | 14 | Differences found: 15 | 16 | {results.map((result, i) => ( 17 | 18 | {result.error ? ( 19 | 20 | ) : ( 21 | 22 | )} 23 | 24 | ))} 25 | 26 | ); 27 | 28 | export default DifferencesView; 29 | -------------------------------------------------------------------------------- /source/hooks/useExecCommand.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { exec as execCallback } from 'child_process'; 3 | import { promisify } from 'util'; 4 | 5 | const execPromise = promisify(execCallback); 6 | 7 | type ExecOptions = { 8 | timeout?: number; 9 | }; 10 | 11 | export default function useExecCommand() { 12 | const [error, setError] = useState(null); 13 | 14 | const exec = useCallback( 15 | async (command: string, options: ExecOptions = {}) => { 16 | const { timeout } = options; 17 | 18 | try { 19 | setError(null); 20 | const { stdout, stderr } = await execPromise(command, { timeout }); 21 | // Return both stdout and stderr 22 | return { stdout, stderr }; 23 | } catch (err) { 24 | setError(err instanceof Error ? err.message : String(err)); 25 | throw err; 26 | } 27 | }, 28 | [], 29 | ); 30 | 31 | return { error, exec }; 32 | } 33 | -------------------------------------------------------------------------------- /tests/export/ExportStatus.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import { ExportStatus } from '../../source/components/export/ExportStatus.js'; 5 | 6 | describe('ExportStatus', () => { 7 | it('shows loading state', () => { 8 | const { lastFrame } = render( 9 | , 17 | ); 18 | expect(lastFrame()).toContain('Loading...'); 19 | }); 20 | 21 | it('shows error state', () => { 22 | const { lastFrame } = render( 23 | , 31 | ); 32 | expect(lastFrame()).toContain('Failed to export'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /source/components/gitops/BranchName.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import TextInput from 'ink-text-input'; 4 | 5 | type Props = { 6 | onBranchSubmit: (branchName: string) => void; 7 | onError: (error: string) => void; 8 | }; 9 | 10 | const BranchName: React.FC = ({ onBranchSubmit, onError }) => { 11 | const [branchName, setBranchName] = React.useState(''); 12 | const handleBranchSubmit = () => { 13 | if (branchName.length <= 1) { 14 | onError('Please enter a valid branch name'); 15 | return; 16 | } 17 | onBranchSubmit(branchName); 18 | }; 19 | 20 | return ( 21 | <> 22 | 23 | Enter the Branch Name: 24 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default BranchName; 35 | -------------------------------------------------------------------------------- /source/commands/env/select.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { option } from 'pastel'; 4 | 5 | import zod from 'zod'; 6 | import { type infer as zInfer } from 'zod'; 7 | import { AuthProvider } from '../../components/AuthProvider.js'; 8 | import SelectComponent from '../../components/env/SelectComponent.js'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'Optional: API Key to be used for the environment selection. In case not provided, CLI will redirect you to the Login.', 18 | }), 19 | ), 20 | }); 21 | 22 | export type EnvSelectProps = { 23 | readonly options: zInfer; 24 | }; 25 | 26 | export default function Select({ options }: EnvSelectProps) { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /source/hooks/useProjectAPI.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import useClient from './useClient.js'; 3 | 4 | export const useProjectAPI = () => { 5 | const { authenticatedApiClient, unAuthenticatedApiClient } = useClient(); 6 | const getProjects = useCallback( 7 | async (accessToken?: string, cookie?: string | null) => { 8 | return accessToken || cookie 9 | ? await unAuthenticatedApiClient(accessToken, cookie).GET( 10 | '/v2/projects', 11 | ) 12 | : await authenticatedApiClient().GET('/v2/projects'); 13 | }, 14 | [authenticatedApiClient, unAuthenticatedApiClient], 15 | ); 16 | 17 | const getProject = useCallback( 18 | async (proj_id: string) => { 19 | return await authenticatedApiClient().GET('/v2/projects/{proj_id}', { 20 | proj_id: proj_id, 21 | }); 22 | }, 23 | [authenticatedApiClient], 24 | ); 25 | 26 | return useMemo( 27 | () => ({ 28 | getProjects, 29 | getProject, 30 | }), 31 | [getProjects, getProject], 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /source/commands/env/template/list.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../../components/AuthProvider.js'; 6 | import ListComponent from '../../../components/env/template/ListComponent.js'; 7 | 8 | export const description = 9 | 'A simple command which lists all the terraform templates available.'; 10 | 11 | export const options = zod.object({ 12 | apiKey: zod 13 | .string() 14 | .optional() 15 | .describe( 16 | option({ 17 | description: 18 | 'Optional: API Key to be used for the environment to apply the terraform template.', 19 | }), 20 | ), 21 | }); 22 | 23 | type Props = { 24 | readonly options: zInfer; 25 | }; 26 | 27 | export default function List({ options: { apiKey } }: Props) { 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /source/commands/opa/policy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import zod from 'zod'; 3 | import { option } from 'pastel'; 4 | import { AuthProvider } from '../../components/AuthProvider.js'; 5 | import OPAPolicyComponent from '../../components/opa/OPAPolicyComponent.js'; 6 | 7 | export const options = zod.object({ 8 | serverUrl: zod 9 | .string() 10 | .default('http://localhost:8181') 11 | .describe( 12 | option({ 13 | description: 'The OPA server URL', 14 | alias: 's', 15 | }), 16 | ), 17 | apiKey: zod 18 | .string() 19 | .optional() 20 | .describe( 21 | option({ 22 | description: 23 | 'The API key for the OPA Server and Permit env, project or Workspace', 24 | }), 25 | ), 26 | }); 27 | 28 | export type OpaPolicyProps = { 29 | options: zod.infer; 30 | }; 31 | 32 | export default function Policy({ options }: OpaPolicyProps) { 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /source/commands/env/export/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { AuthProvider } from '../../../components/AuthProvider.js'; 5 | import { ExportContent } from '../../../components/export/ExportContent.js'; 6 | 7 | export const options = zod.object({ 8 | apiKey: zod 9 | .string() 10 | .optional() 11 | .describe( 12 | option({ 13 | description: 'API Key to be used for the environment export', 14 | alias: 'k', 15 | }), 16 | ), 17 | file: zod 18 | .string() 19 | .optional() 20 | .describe( 21 | option({ 22 | description: 'File path to save the exported HCL content', 23 | alias: 'f', 24 | }), 25 | ), 26 | }); 27 | 28 | type Props = { 29 | readonly options: zod.infer; 30 | }; 31 | 32 | export default function Export({ options: { apiKey, file } }: Props) { 33 | return ( 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /source/implement/example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from permit import Permit 4 | from fastapi import FastAPI, status, HTTPException 5 | from fastapi.responses import JSONResponse 6 | 7 | app = FastAPI() 8 | 9 | permit = Permit( 10 | pdp="http://localhost:7766", 11 | token="{{API_KEY}}", 12 | ) 13 | 14 | user = { 15 | "id": "{{USER_ID}}", 16 | "firstName": "{{FIRST_NAME}}", 17 | "lastName": "{{LAST_NAME}}", 18 | "email": "{{EMAIL}}", 19 | } 20 | 21 | @app.get("/") 22 | async def check_permissions(): 23 | permitted = await permit.check(user["id"], "{{ACTIONS}}", "{{RESOURCES}}") 24 | if not permitted: 25 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={ 26 | "result": f"{user.get('firstName')} {user.get('lastName')} is NOT PERMITTED to {{ACTIONS}} {{RESOURCES}}!" 27 | }) 28 | 29 | return JSONResponse(status_code=status.HTTP_200_OK, content={ 30 | "result": f"{user.get('firstName')} {user.get('lastName')} is PERMITTED to {{ACTIONS}} {{RESOURCES}}!" 31 | }) -------------------------------------------------------------------------------- /source/implement/example.js: -------------------------------------------------------------------------------- 1 | const { Permit } = require('permitio'); 2 | 3 | const express = require('express'); 4 | const app = express(); 5 | const port = 4000; 6 | 7 | const permit = new Permit({ 8 | pdp: 'http://localhost:7766', 9 | token: '{{API_KEY}}', 10 | }); 11 | 12 | app.get('/', async (req, res) => { 13 | const user = { 14 | id: '{{USER_ID}}', 15 | firstName: '{{FIRST_NAME}}', 16 | lastName: '{{LAST_NAME}}', 17 | email: '{{EMAIL}}', 18 | }; 19 | const permitted = await permit.check( 20 | '{{USER_ID}}', 21 | '{{ACTIONS}}', 22 | '{{RESOURCES}}', 23 | ); 24 | if (permitted) { 25 | res 26 | .status(200) 27 | .send( 28 | `${user.firstName} ${user.lastName} is PERMITTED to '{{ACTIONS}}' '{{RESOURCES}}' !`, 29 | ); 30 | } else { 31 | res 32 | .status(403) 33 | .send( 34 | `${user.firstName} ${user.lastName} is NOT PERMITTED to '{{ACTIONS}}' '{{RESOURCES}}' !`, 35 | ); 36 | } 37 | }); 38 | 39 | app.listen(port, () => { 40 | console.log('Example app listening at http://localhost:' + port); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/graph.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { describe, vi, expect, it } from 'vitest'; 4 | import Graph from '../source/commands/graph'; 5 | import delay from 'delay'; 6 | import * as keytar from 'keytar'; 7 | 8 | vi.mock('keytar', () => { 9 | const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); 10 | 11 | const keytarMock = { 12 | setPassword: vi.fn().mockResolvedValue(() => demoPermitKey), 13 | getPassword: vi.fn().mockResolvedValue(() => demoPermitKey), 14 | deletePassword: vi.fn().mockResolvedValue(demoPermitKey), 15 | }; 16 | 17 | return { ...keytarMock, default: keytarMock }; 18 | }); 19 | 20 | describe('graph command', () => { 21 | it('should render the Graph component inside AuthProvider', async () => { 22 | const options = { apiKey: 'test-api-key' }; 23 | const { lastFrame } = render(); 24 | 25 | await delay(100); // Ensures async rendering completes before assertions 26 | expect(lastFrame()).not.toBeNull(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['main'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm run lint 31 | - run: npm run test 32 | -------------------------------------------------------------------------------- /source/components/test/views/DifferenceResultView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from 'ink'; 3 | import { ComparisonResult } from '../auditTypes.js'; 4 | 5 | interface DifferenceResultViewProps { 6 | result: ComparisonResult; 7 | } 8 | 9 | const DifferenceResultView: React.FC = ({ 10 | result, 11 | }) => ( 12 | <> 13 | User: {result.auditLog.user_key || result.auditLog.user_id} 14 | 15 | Resource: {result.auditLog.resource} (type:{' '} 16 | {result.auditLog.resource_type}) 17 | 18 | Action: {result.auditLog.action} 19 | Tenant: {result.auditLog.tenant} 20 | 21 | Original:{' '} 22 | 23 | {result.originalDecision ? 'ALLOW' : 'DENY'} 24 | 25 | , New:{' '} 26 | 27 | {result.newDecision ? 'ALLOW' : 'DENY'} 28 | 29 | 30 | 31 | ); 32 | 33 | export default DifferenceResultView; 34 | -------------------------------------------------------------------------------- /tests/logout.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import Logout from '../source/commands/logout'; 5 | import delay from 'delay'; 6 | import * as keytar from 'keytar'; 7 | 8 | vi.mock('keytar', () => { 9 | const keytar = { 10 | setPassword: vi.fn(), 11 | getPassword: vi.fn(), // Mocked return value 12 | deletePassword: vi.fn(), 13 | }; 14 | return { ...keytar, default: keytar }; 15 | }); 16 | describe('Logout', () => { 17 | beforeEach(() => { 18 | vi.spyOn(process, 'exit').mockImplementation(code => { 19 | console.warn(`Mocked process.exit(${code})`); 20 | }); 21 | }); 22 | 23 | it('should render the logout component and call process.exit', async () => { 24 | const { lastFrame } = render(); 25 | // Ensure initial loading text is displayed 26 | expect(lastFrame()).toMatch(/Cleaning session.../); 27 | await delay(50); 28 | // Ensure process.exit was called with 0 29 | expect(process.exit).toHaveBeenCalledWith(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/lib/gitops_utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi, it, expect } from 'vitest'; 2 | import * as utils from '../../source/lib/gitops/utils.js'; 3 | import ssh from 'micro-key-producer/ssh.js'; 4 | import { randomBytes } from 'micro-key-producer/utils.js'; 5 | vi.mock('../../source/lib/api', () => ({ 6 | apiCall: vi.fn(), 7 | })); 8 | vi.mock('micro-key-producer/ssh.js', () => ({ 9 | default: vi.fn(), 10 | })); 11 | vi.mock('micro-key-producer/utils.js', () => ({ 12 | randomBytes: vi.fn(), 13 | })); 14 | 15 | describe('generateSSHKey', () => { 16 | it('should generate an SSH key', () => { 17 | (randomBytes as any).mockReturnValueOnce(new Uint8Array(32)); 18 | (ssh as any).mockReturnValueOnce({ 19 | publicKeyBytes: new Uint8Array(8), 20 | publicKey: 'publicKey', 21 | privateKey: 'privateKey', 22 | fingerprint: 'testFingerprint', 23 | }); 24 | const key = utils.generateSSHKey(); 25 | expect(key).toStrictEqual({ 26 | publicKeyBytes: new Uint8Array(8), 27 | publicKey: 'publicKey', 28 | privateKey: 'privateKey', 29 | fingerprint: 'testFingerprint', 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/components/TemplateListComponent.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, vi, it, expect, beforeEach } from 'vitest'; 3 | import ListComponent from '../../source/components/env/template/ListComponent'; 4 | import { getFiles } from '../../source/lib/env/template/utils'; 5 | import { render } from 'ink-testing-library'; 6 | 7 | vi.mock('../../source/lib/env/template/utils', () => ({ 8 | getFiles: vi.fn(), 9 | })); 10 | 11 | describe('List Component', () => { 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | }); 15 | 16 | it('renders the files', async () => { 17 | (getFiles as vi.Mock).mockReturnValue(['template1', 'template2']); 18 | const { lastFrame } = render(ListComponent()); 19 | expect(lastFrame()).toContain('Templates List'); 20 | expect(lastFrame()).toContain(' • template1'); 21 | expect(lastFrame()).toContain(' • template2'); 22 | }); 23 | 24 | it('displays empty file message', async () => { 25 | (getFiles as vi.Mock).mockReturnValue(null); 26 | const { lastFrame } = render(ListComponent()); 27 | expect(lastFrame()).toContain('No Templates found.'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/auditTest/views/ErrorView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { describe, it, expect } from 'vitest'; 4 | import ErrorView from '../../../source/components/test/views/ErrorView.js'; 5 | 6 | describe('ErrorView', () => { 7 | it('should render error message correctly', () => { 8 | const { lastFrame } = render(); 9 | expect(lastFrame()).toContain('Error: Test error message'); 10 | }); 11 | 12 | it('should handle error messages with special characters', () => { 13 | const { lastFrame } = render( 14 | , 15 | ); 16 | expect(lastFrame()).toContain( 17 | "Error: Connection failed: couldn't reach server", 18 | ); 19 | }); 20 | 21 | it('should handle long error messages', () => { 22 | const longError = 23 | 'This is a very long error message that contains detailed information'; 24 | const { lastFrame } = render(); 25 | // Check for the prefix and the beginning of the error message 26 | expect(lastFrame()).toContain('Error: ' + longError); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /source/commands/gitops/create/github.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import zod from 'zod'; 3 | import { option } from 'pastel'; 4 | import { AuthProvider } from '../../../components/AuthProvider.js'; 5 | import GitHubComponent from '../../../components/gitops/GitHubComponent.js'; 6 | 7 | export const description = 8 | 'Connect a GitHub repository to a permit Environment'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'The API key for the permit Environment Organization or Project', 18 | alias: 'k', 19 | }), 20 | ), 21 | inactive: zod 22 | .boolean() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Do not activate the repository When Validated', 27 | alias: 'i', 28 | }), 29 | ), 30 | }); 31 | 32 | type Props = { 33 | options: zod.infer; 34 | }; 35 | 36 | export default function GitHub({ options }: Props) { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /source/commands/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Gradient from 'ink-gradient'; 3 | import { Text, Box, Newline } from 'ink'; 4 | import { AuthProvider } from '../components/AuthProvider.js'; 5 | import EnvironmentInfo from '../components/EnvironmentInfo.js'; 6 | 7 | export default function Index() { 8 | return ( 9 | 10 | 11 | {'21.3.0'.localeCompare(process.versions.node, undefined, { 12 | numeric: true, 13 | sensitivity: 'base', 14 | }) > 0 && ( 15 | 16 | 🚀 Permit CLI is best supported on Node.js v22+, upgrade your Node 17 | version to get the best experience of the tool: 18 | https://nodejs.org/en/download 19 | 20 | 21 | )} 22 | 23 | Permit CLI is a 24 | developer swiss army knife for fine-grained authorization 25 | 26 | Run this command with --help for more information 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /tests/env/apply/trino.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { Text } from 'ink'; 4 | import { describe, it, expect, vi } from 'vitest'; 5 | import Trino from '../../../source/commands/env/apply/trino.js'; 6 | 7 | vi.mock('../../../source/components/AuthProvider.js', () => { 8 | return { 9 | __esModule: true, 10 | AuthProvider: function AuthProvider({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return <>{children}; 16 | }, 17 | }; 18 | }); 19 | 20 | vi.mock('../../../source/components/env/trino/TrinoComponent.js', () => ({ 21 | __esModule: true, 22 | default: ({ url, user }: { url: string; user: string }) => ( 23 | 24 | TrinoComponentMock url={url} user={user} 25 | 26 | ), 27 | })); 28 | 29 | describe('permit env apply trino CLI command', () => { 30 | it('renders the TrinoComponent and passes props', async () => { 31 | const { lastFrame } = render( 32 | , 38 | ); 39 | expect(lastFrame()).toContain('TrinoComponentMock'); 40 | expect(lastFrame()).toContain('testuser'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /source/commands/env/apply/openapi.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../../components/AuthProvider.js'; 6 | import OpenapiComponent from '../../../components/env/openapi/OpenapiComponent.js'; 7 | 8 | export const description = 9 | 'Apply permissions policy from an OpenAPI spec using the `-x-permit` extensions.'; 10 | 11 | export const options = zod.object({ 12 | apiKey: zod 13 | .string() 14 | .optional() 15 | .describe( 16 | option({ 17 | description: 'API key for Permit authentication', 18 | alias: 'k', 19 | }), 20 | ), 21 | specFile: zod 22 | .string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 27 | 'Path to the OpenAPI file to read from. It could be a local path or an HTTP endpoint.', 28 | alias: 'f', 29 | }), 30 | ), 31 | }); 32 | 33 | type Props = { 34 | readonly options: zInfer; 35 | }; 36 | 37 | export default function Openapi({ options: { apiKey, specFile } }: Props) { 38 | return ( 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /tests/components/HtmlGraphSaver.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { resolve } from 'path'; 3 | import open from 'open'; 4 | import { saveHTMLGraph } from '../../source/components/HtmlGraphSaver'; 5 | import * as fs from 'fs'; 6 | 7 | vi.mock('open', () => ({ 8 | default: vi.fn(() => Promise.resolve()), 9 | })); 10 | 11 | vi.mock('fs', () => ({ 12 | writeFileSync: vi.fn(), 13 | readFileSync: vi.fn(() => ''), 14 | })); 15 | 16 | describe('saveHTMLGraph', () => { 17 | let consoleLogSpy: ReturnType; 18 | 19 | beforeEach(() => { 20 | consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.restoreAllMocks(); 25 | }); 26 | 27 | it('should write the HTML file and open it', () => { 28 | const dummyGraphData = { nodes: [], edges: [] }; 29 | saveHTMLGraph(dummyGraphData); 30 | 31 | const expectedPath = resolve(process.cwd(), 'permit-graph.html'); 32 | 33 | expect(fs.writeFileSync).toHaveBeenCalledWith( 34 | expectedPath, 35 | expect.stringContaining(''), 36 | 'utf8', 37 | ); 38 | 39 | expect(consoleLogSpy).toHaveBeenCalledWith( 40 | `Graph saved as: ${expectedPath}`, 41 | ); 42 | 43 | expect(open).toHaveBeenCalledWith(expectedPath); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/hooks/useResourcesAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import { Text } from 'ink'; 5 | import { getMockFetchResponse } from '../utils.js'; 6 | import { useResourcesApi } from '../../source/hooks/useResourcesApi.js'; 7 | 8 | global.fetch = vi.fn(); 9 | 10 | describe('useResourcesApi', () => { 11 | beforeEach(() => { 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | it('should get resources', async () => { 16 | (fetch as any).mockResolvedValueOnce({ 17 | ...getMockFetchResponse(), 18 | json: async () => [ 19 | { id: 'res-1', name: 'Database' }, 20 | { id: 'res-2', name: 'Cache' }, 21 | ], 22 | }); 23 | 24 | const TestComponent = () => { 25 | const { getResources } = useResourcesApi(); 26 | const [result, setResult] = React.useState(null); 27 | 28 | React.useEffect(() => { 29 | const get = async () => { 30 | const { data } = await getResources(); 31 | // @ts-ignore 32 | setResult(data.map((r: any) => r.name).join(', ')); 33 | }; 34 | get(); 35 | }, []); 36 | 37 | return {result}; 38 | }; 39 | 40 | const { lastFrame } = render(); 41 | await vi.waitFor(() => { 42 | expect(lastFrame()).toBe('Database, Cache'); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /source/commands/env/delete.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../components/AuthProvider.js'; 6 | import DeleteComponent from '../../components/env/DeleteComponent.js'; 7 | 8 | export const description = 'Delete a Permit environment'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'Optional: API Key to be used for the environment deletion', 18 | alias: 'k', 19 | }), 20 | ), 21 | envId: zod 22 | .string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Environment ID to delete', 27 | alias: 'e', 28 | }), 29 | ), 30 | force: zod 31 | .boolean() 32 | .optional() 33 | .default(false) 34 | .describe( 35 | option({ 36 | description: 'Skip confirmation prompts and force deletion', 37 | alias: 'f', 38 | }), 39 | ), 40 | }); 41 | 42 | type Props = { 43 | readonly options: zInfer; 44 | }; 45 | 46 | export default function Delete({ options: { apiKey, envId, force } }: Props) { 47 | return ( 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /terraform_server/messages.js: -------------------------------------------------------------------------------- 1 | export const systemMessage = { 2 | role: 'system', 3 | content: `You are an intelligent assistant that converts natural language into structured Role-Based Access Control (RBAC) policies. 4 | 5 | Always reply with this JSON format: 6 | 7 | { 8 | resources: [{ 9 | name: string, 10 | actions: string[] 11 | }], 12 | roles: [{ 13 | name: string, 14 | permissions: [{ 15 | resource: string, 16 | actions: string[] 17 | }] 18 | }] 19 | } 20 | 21 | Your task: 22 | - Understand the user's intent, even if they don't mention "policy", "roles", or "permissions". 23 | - If the user describes a product, app, feature, organization, or use case — infer the implied access control. 24 | - Extract or invent logical roles, resources, actions, and permissions based on what the system would realistically need. 25 | - Use common sense and real-world examples (e.g., fintech = users, admins, money transfers, approvals). 26 | - Avoid asking follow-up questions unless absolutely necessary. 27 | - Don't use the keys "admin", "viewer", "editor" for roles. 28 | - In the terraform file, add field of "attributes" with empty object {} for each resource. 29 | - return only the table output without any other text above it. 30 | - Always output valid JSON and Terraform file using the permit.io terraform provider. 31 | - When using tools, make sure to include the formattedOutput in your response.`, 32 | }; 33 | -------------------------------------------------------------------------------- /tests/export/mocks/permit.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | export const mockResources = [ 3 | { 4 | key: 'document', 5 | name: 'Document', 6 | actions: { read: { name: 'Read' } }, 7 | }, 8 | ]; 9 | export const mockRoles = [ 10 | { 11 | key: 'admin', 12 | name: 'Administrator', 13 | permissions: ['document:read'], 14 | }, 15 | ]; 16 | export const mockUserAttributes = [ 17 | { 18 | key: 'department', 19 | type: 'string', 20 | description: 'User department', 21 | }, 22 | ]; 23 | export const getMockPermit = () => ({ 24 | api: { 25 | resources: { 26 | list: vi.fn().mockResolvedValue(mockResources), 27 | }, 28 | roles: { 29 | list: vi.fn().mockResolvedValue(mockRoles), 30 | }, 31 | resourceAttributes: { 32 | list: vi.fn().mockImplementation(({ resourceKey }) => { 33 | if (resourceKey === '__user') { 34 | return Promise.resolve(mockUserAttributes); 35 | } 36 | return Promise.resolve([]); 37 | }), 38 | }, 39 | 40 | users: { 41 | list: vi.fn().mockResolvedValue([]), 42 | }, 43 | conditionSets: { 44 | list: vi.fn().mockResolvedValue([]), 45 | }, 46 | relationshipTuples: { 47 | list: vi.fn().mockResolvedValue([]), 48 | }, 49 | }, 50 | }); 51 | 52 | export const mockValidateApiKeyScope = vi.fn().mockResolvedValue({ 53 | valid: true, 54 | scope: { 55 | organization_id: 'org-123', 56 | project_id: 'proj-123', 57 | environment_id: 'env-123', 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /source/commands/gitops/env/clone.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import zod from 'zod'; 4 | import { option } from 'pastel'; 5 | import CloneComponent from '../../../components/gitops/CloneComponent.js'; 6 | 7 | export const description = 8 | 'Clone a single Environment or the entire repository from active GitOps Environment'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'The API key for the permit Project which is used to clone the Environment', 18 | }), 19 | ), 20 | dryRun: zod 21 | .boolean() 22 | .optional() 23 | .describe( 24 | option({ 25 | description: 26 | 'Do not clone the Environment, just show the command which is used to be cloned', 27 | }), 28 | ) 29 | .default(false), 30 | project: zod 31 | .boolean() 32 | .optional() 33 | .describe( 34 | option({ 35 | description: 'Clone the entire repository', 36 | }), 37 | ), 38 | }); 39 | 40 | type Props = { 41 | options: zod.infer; 42 | }; 43 | 44 | export default function Clone({ options }: Props) { 45 | return ( 46 | 47 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /source/hooks/openapi/process/apiTypes.ts: -------------------------------------------------------------------------------- 1 | // Response types 2 | export interface ResourceResponse { 3 | key: string; 4 | name: string; 5 | description?: string; 6 | created_at?: string; 7 | updated_at?: string; 8 | } 9 | 10 | export interface ActionResponse { 11 | key: string; 12 | name: string; 13 | description?: string; 14 | } 15 | 16 | export interface RoleResponse { 17 | key: string; 18 | name: string; 19 | description?: string; 20 | permissions?: string[]; 21 | } 22 | 23 | export interface RelationResponse { 24 | key: string; 25 | name: string; 26 | subject_resource: string; 27 | object_resource: string; 28 | description?: string; 29 | } 30 | 31 | export interface DerivedRoleResponse { 32 | base_role: string; 33 | derived_role: string; 34 | resource: string; 35 | relation?: string; 36 | } 37 | 38 | export interface UrlMappingResponse { 39 | id: string; 40 | url: string; 41 | http_method: string; 42 | resource: string; 43 | action: string; 44 | } 45 | 46 | // Request types 47 | export interface RelationRequest { 48 | key: string; 49 | name: string; 50 | subject_resource: string; 51 | object_resource: string; 52 | description?: string; 53 | } 54 | 55 | export interface DerivedRoleRequest { 56 | base_role: string; 57 | derived_role: string; 58 | resource: string; 59 | relation?: string; 60 | } 61 | 62 | export interface UrlMappingRequest { 63 | url: string; 64 | http_method: string; 65 | resource: string; 66 | action: string; 67 | } 68 | -------------------------------------------------------------------------------- /source/commands/api/users/assign.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import { type infer as zInfer, string, object } from 'zod'; 4 | import { option } from 'pastel'; 5 | import PermitUsersAssignComponent from '../../../components/api/PermitUsersAssignComponent.js'; 6 | 7 | export const options = object({ 8 | apiKey: string() 9 | .optional() 10 | .describe( 11 | option({ 12 | description: 'Your Permit.io API key', 13 | }), 14 | ), 15 | projectId: string() 16 | .optional() 17 | .describe( 18 | option({ 19 | description: 'Permit.io Project ID', 20 | }), 21 | ), 22 | envId: string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Permit.io Environment ID', 27 | }), 28 | ), 29 | user: string().describe( 30 | option({ 31 | description: 'User ID to assign role to', 32 | }), 33 | ), 34 | role: string().describe( 35 | option({ 36 | description: 'Role key to assign', 37 | }), 38 | ), 39 | tenant: string().describe( 40 | option({ 41 | description: 'Tenant key for the role assignment', 42 | }), 43 | ), 44 | }); 45 | 46 | type Props = { 47 | options: zInfer; 48 | }; 49 | 50 | export default function Assign({ options }: Props) { 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /source/commands/pdp/stats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import zod, { string } from 'zod'; 3 | import { option } from 'pastel'; 4 | import PDPStatComponent from '../../components/pdp/PDPStatComponent.js'; 5 | import { AuthProvider } from '../../components/AuthProvider.js'; 6 | 7 | export const options = zod.object({ 8 | projectKey: zod 9 | .string() 10 | .optional() 11 | .describe( 12 | option({ 13 | description: 'The project key', 14 | alias: 'p', 15 | }), 16 | ), 17 | environmentKey: zod 18 | .string() 19 | .optional() 20 | .describe( 21 | option({ 22 | description: 'The environment key', 23 | alias: 'e', 24 | }), 25 | ), 26 | statsUrl: string() 27 | .optional() 28 | .describe( 29 | option({ 30 | description: 31 | 'The URL of the PDP service. Default to the cloud PDP. (Optional)', 32 | }), 33 | ), 34 | apiKey: zod 35 | .string() 36 | .optional() 37 | .describe( 38 | option({ 39 | description: 40 | 'The API key for the Permit env, project or Workspace (Optional)', 41 | }), 42 | ), 43 | top: zod.boolean().default(false).describe('Run stats in top mode'), 44 | }); 45 | 46 | export type PDPStatsProps = { 47 | options: zod.infer; 48 | }; 49 | 50 | export default function Stats({ options }: PDPStatsProps) { 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /source/commands/pdp/run.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../components/AuthProvider.js'; 3 | import PDPRunComponent from '../../components/pdp/PDPRunComponent.js'; 4 | import { type infer as zInfer, number, object, boolean, string } from 'zod'; 5 | import { option } from 'pastel'; 6 | 7 | export const description = 8 | 'Run a Permit PDP Docker container for local development'; 9 | 10 | export const options = object({ 11 | opa: number() 12 | .optional() 13 | .describe(option({ description: 'Expose OPA port from the PDP' })), 14 | dryRun: boolean() 15 | .optional() 16 | .default(false) 17 | .describe( 18 | option({ 19 | description: 'Print the Docker command without executing it', 20 | alias: 'd', 21 | }), 22 | ), 23 | apiKey: string() 24 | .optional() 25 | .describe( 26 | option({ 27 | description: 'The API key for the Permit env, project or Workspace', 28 | alias: 'k', 29 | }), 30 | ), 31 | tag: string() 32 | .default('latest') 33 | .describe( 34 | option({ 35 | description: 'The tag of the PDP image to use', 36 | alias: 't', 37 | }), 38 | ), 39 | }); 40 | 41 | type Props = { 42 | options: zInfer; 43 | }; 44 | 45 | export default function Run({ options: { opa, dryRun, apiKey, tag } }: Props) { 46 | return ( 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /source/components/export/ExportStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | import { ExportState } from '../../commands/env/export/types.js'; 5 | 6 | interface ExportStatusProps { 7 | state: ExportState; 8 | file?: string; 9 | } 10 | 11 | export const ExportStatus: React.FC = ({ state, file }) => { 12 | if (state.error) { 13 | return ( 14 | <> 15 | Error: {state.error} 16 | {state.warnings.length > 0 && ( 17 | <> 18 | Warnings: 19 | {state.warnings.map((warning, i) => ( 20 | 21 | - {warning} 22 | 23 | ))} 24 | 25 | )} 26 | 27 | ); 28 | } 29 | 30 | if (!state.isComplete) { 31 | return ( 32 | <> 33 | 34 | {' '} 35 | {state.status || 'Exporting environment configuration...'} 36 | 37 | 38 | ); 39 | } 40 | 41 | return ( 42 | <> 43 | Export completed successfully! 44 | {file && HCL content has been saved to: {file}} 45 | {state.warnings.length > 0 && ( 46 | <> 47 | Warnings during export: 48 | {state.warnings.map((warning, i) => ( 49 | 50 | - {warning} 51 | 52 | ))} 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /source/commands/api/users/unassign.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import { type infer as zInfer, string, object } from 'zod'; 4 | import { option } from 'pastel'; 5 | import PermitUsersUnassignComponent from '../../../components/api/PermitUsersUnassignComponent.js'; 6 | 7 | export const options = object({ 8 | apiKey: string() 9 | .optional() 10 | .describe( 11 | option({ 12 | description: 'Your Permit.io API key', 13 | }), 14 | ), 15 | projectId: string() 16 | .optional() 17 | .describe( 18 | option({ 19 | description: 'Permit.io Project ID', 20 | }), 21 | ), 22 | envId: string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Permit.io Environment ID', 27 | }), 28 | ), 29 | user: string().describe( 30 | option({ 31 | description: 'User ID to unassign role from', 32 | }), 33 | ), 34 | role: string().describe( 35 | option({ 36 | description: 'Role key to unassign', 37 | }), 38 | ), 39 | tenant: string().describe( 40 | option({ 41 | description: 'Tenant key for the role unassignment', 42 | }), 43 | ), 44 | }); 45 | 46 | type Props = { 47 | options: zInfer; 48 | }; 49 | 50 | export default function Unassign({ options }: Props) { 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /source/components/test/views/ResultsView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { ComparisonResult } from '../auditTypes.js'; 4 | import DifferencesView from './DifferencesView.js'; 5 | 6 | interface ResultsViewProps { 7 | results: ComparisonResult[]; 8 | pdpUrl: string; 9 | } 10 | 11 | const ResultsView: React.FC = ({ results, pdpUrl }) => { 12 | // Count matches and mismatches 13 | const matches = results.filter(r => r.matches).length; 14 | const mismatches = results.length - matches; 15 | const errors = results.filter(r => r.error).length; 16 | 17 | return ( 18 | 19 | 20 | 21 | Compared {results.length} audit logs against PDP at {pdpUrl} 22 | 23 | 24 | 25 | 26 | Results: {matches} matches,{' '} 27 | 0 ? 'red' : 'green'}> 28 | {mismatches} differences 29 | 30 | {errors > 0 && , {errors} errors} 31 | 32 | 33 | 34 | {mismatches > 0 && ( 35 | !r.matches)} /> 36 | )} 37 | 38 | {mismatches === 0 && ( 39 | 40 | ✓ All decisions match! The PDP behaves identically to the audit log 41 | data. 42 | 43 | )} 44 | 45 | ); 46 | }; 47 | 48 | export default ResultsView; 49 | -------------------------------------------------------------------------------- /tests/hooks/useUsersAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import { Text } from 'ink'; 5 | import { getMockFetchResponse } from '../utils.js'; 6 | import { CreateUserBody, useUserApi } from '../../source/hooks/useUserApi.js'; 7 | 8 | global.fetch = vi.fn(); 9 | 10 | describe('useUserApi', () => { 11 | beforeEach(() => { 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | it('should create user', async () => { 16 | (fetch as any).mockResolvedValueOnce({ 17 | ...getMockFetchResponse(), 18 | json: async () => ({ 19 | key: 'user-1', 20 | firstName: 'First', 21 | lastName: 'Last', 22 | }), 23 | }); 24 | 25 | const TestComponent = () => { 26 | const { createUser } = useUserApi(); 27 | const [result, setResult] = React.useState(null); 28 | 29 | React.useEffect(() => { 30 | const create = async () => { 31 | const body: CreateUserBody = { 32 | attributes: {}, 33 | key: 'user-1', 34 | first_name: 'First', 35 | last_name: 'Last', 36 | }; 37 | const { data: user } = await createUser(body); 38 | setResult(user?.key ?? 'User not created'); 39 | }; 40 | create(); 41 | }, []); 42 | 43 | return {result}; 44 | }; 45 | 46 | const { lastFrame } = render(); 47 | await vi.waitFor(() => { 48 | expect(lastFrame()).toBe('user-1'); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /source/components/HtmlGraphSaver.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | import open from 'open'; 4 | 5 | // Define the data structure for a graph node's data 6 | interface GraphNodeData { 7 | id: string; 8 | label: string; 9 | } 10 | 11 | // Define a graph node; `classes` is optional 12 | interface GraphNode { 13 | data: GraphNodeData; 14 | classes?: string; 15 | } 16 | 17 | // Define the data structure for a graph edge's data 18 | interface GraphEdgeData { 19 | source: string; 20 | target: string; 21 | label: string; 22 | } 23 | 24 | // Define a graph edge; here `classes` is required 25 | interface GraphEdge { 26 | data: GraphEdgeData; 27 | classes: string; 28 | } 29 | 30 | // Define the overall GraphData structure 31 | interface GraphData { 32 | nodes: GraphNode[]; 33 | edges: GraphEdge[]; 34 | } 35 | 36 | export const saveHTMLGraph = (graphData: GraphData) => { 37 | const templatePath = resolve( 38 | process.cwd(), 39 | 'source', 40 | 'components', 41 | 'graph-template', 42 | 'graph-template.html', 43 | ); 44 | 45 | let htmlTemplate = readFileSync(templatePath, 'utf8'); 46 | 47 | htmlTemplate = htmlTemplate.replace( 48 | '', 49 | JSON.stringify(graphData, null, 2), 50 | ); 51 | 52 | const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); 53 | 54 | writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); 55 | console.log(`Graph saved as: ${outputHTMLPath}`); 56 | open(outputHTMLPath); 57 | }; 58 | -------------------------------------------------------------------------------- /source/components/env/openapi/OpenapiResults.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | 5 | interface OpenapiResultsProps { 6 | status: 'loading' | 'error' | 'success'; 7 | error: string | null; 8 | progress: string; 9 | processingDone: boolean; 10 | } 11 | 12 | /** 13 | * Component for displaying processing status and results 14 | */ 15 | export default function OpenapiResults({ 16 | status, 17 | error, 18 | progress, 19 | processingDone, 20 | }: OpenapiResultsProps): React.ReactElement { 21 | // Loading state 22 | if (status === 'loading' && !processingDone) { 23 | return ( 24 | 25 | 26 | {progress || 'Processing...'} 27 | 28 | 29 | ); 30 | } 31 | 32 | // Error state 33 | if (status === 'error') { 34 | return ( 35 | 36 | Error: {error} 37 | Please try again with a valid OpenAPI spec file. 38 | 39 | ); 40 | } 41 | 42 | // Success state 43 | if (status === 'success') { 44 | return ( 45 | 46 | ✓ OpenAPI spec successfully applied! 47 | 48 | Resources, actions, roles, and URL mappings have been created based on 49 | the OpenAPI spec. 50 | 51 | 52 | ); 53 | } 54 | 55 | // Unexpected state fallback 56 | return Unexpected state. Please try again.; 57 | } 58 | -------------------------------------------------------------------------------- /source/commands/env/template/apply.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../../components/AuthProvider.js'; 6 | import ApplyComponent from '../../../components/env/template/ApplyComponent.js'; 7 | 8 | export const description = 'A apply command to run the TF file'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'Optional: API Key to be used for the environemnt to apply the policy template.', 18 | }), 19 | ), 20 | local: zod 21 | .boolean() 22 | .optional() 23 | .describe( 24 | option({ 25 | description: 26 | 'To run the Terraform command locally instead of the server (will fail if Terraform is not installed).', 27 | }), 28 | ), 29 | template: zod 30 | .string() 31 | .optional() 32 | .describe( 33 | option({ 34 | description: 35 | 'Skips the template choice and and apply the given template. It will fail if the template does not exist.', 36 | }), 37 | ), 38 | }); 39 | 40 | type Props = { 41 | readonly options: zInfer; 42 | }; 43 | 44 | export default function Apply({ options: { apiKey, local, template } }: Props) { 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /source/utils/api/user/utils.ts: -------------------------------------------------------------------------------- 1 | export type UserSyncOptions = { 2 | key: string; 3 | email?: string; 4 | firstName?: string; 5 | lastName?: string; 6 | attributes?: Record; 7 | roleAssignments?: Array<{ 8 | role: string; 9 | tenant?: string; 10 | resourceInstance?: string; 11 | }>; 12 | }; 13 | 14 | export function validate(options: UserSyncOptions) { 15 | const useridRegex = /^[A-Za-z0-9@+\-._]+$/; 16 | if (!options.key) { 17 | throw new Error('Missing Error: userid is required'); 18 | } 19 | if (options.key && !useridRegex.test(options.key)) { 20 | console.log(options.key); 21 | console.log(useridRegex.test(options.key)); 22 | throw new Error('Validation Error: Invalid userid'); 23 | } 24 | const emailRegex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; 25 | if (options.email && !emailRegex.test(options.email)) { 26 | throw new Error('Validation Error: Invalid email'); 27 | } 28 | const nameRegex = /^[A-Za-z]{2,50}$/; 29 | if (options.firstName && !nameRegex.test(options.firstName)) { 30 | throw new Error('Validation Error: Invalid firstName'); 31 | } 32 | if (options.lastName && !nameRegex.test(options.lastName)) { 33 | throw new Error('Validation Error: Invalid lastName'); 34 | } 35 | if (options.attributes && typeof options.attributes !== 'object') { 36 | throw new Error('Validation Error: Invalid attributes'); 37 | } 38 | if (options.roleAssignments && !Array.isArray(options.roleAssignments)) { 39 | throw new Error('Validation Error: Invalid roleAssignments'); 40 | } 41 | return true; 42 | } 43 | -------------------------------------------------------------------------------- /tests/export/ExportContent.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, vi, describe, it, beforeEach } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import { ExportContent } from '../../source/components/export/ExportContent.js'; 5 | import { getMockPermit, mockValidateApiKeyScope } from './mocks/permit.js'; 6 | import { mockUseAuth } from './mocks/hooks'; 7 | 8 | vi.mock('permitio', () => ({ 9 | Permit: vi.fn(() => { 10 | return getMockPermit(); 11 | }), 12 | })); 13 | 14 | vi.mock('../../source/hooks/useApiKeyApi', () => ({ 15 | useApiKeyApi: () => ({ validateApiKeyScope: mockValidateApiKeyScope }), 16 | })); 17 | 18 | vi.mock('../../source/components/AuthProvider', () => ({ 19 | useAuth: () => mockUseAuth(), 20 | })); 21 | 22 | describe('ExportContent', () => { 23 | beforeEach(() => { 24 | vi.clearAllMocks(); 25 | }); 26 | 27 | it('handles successful export to console', async () => { 28 | const { lastFrame } = render( 29 | , 30 | ); 31 | await vi.waitFor(() => { 32 | expect(lastFrame()).toContain('Export completed successfully!'); 33 | }); 34 | }); 35 | 36 | it('handles validation errors', async () => { 37 | mockValidateApiKeyScope.mockResolvedValueOnce({ 38 | valid: false, 39 | error: 'Invalid API key', 40 | }); 41 | const { lastFrame } = render( 42 | , 43 | ); 44 | await vi.waitFor(() => { 45 | expect(lastFrame()).toContain('Invalid API key'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /source/commands/api/list/proxy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import { type infer as zInfer, string, object, number, boolean } from 'zod'; 4 | import { option } from 'pastel'; 5 | import APIListProxyComponent from '../../../components/api/proxy/APIListProxyComponent.js'; 6 | 7 | export const options = object({ 8 | apiKey: string() 9 | .optional() 10 | .describe( 11 | option({ 12 | description: 'Your Permit.io API key', 13 | }), 14 | ), 15 | expandKey: boolean() 16 | .optional() 17 | .default(false) 18 | .describe( 19 | option({ 20 | description: 'Show full key values instead of truncated', 21 | alias: 'e', 22 | }), 23 | ), 24 | page: number() 25 | .optional() 26 | .default(1) 27 | .describe( 28 | option({ 29 | description: 'Page number for pagination', 30 | alias: 'p', 31 | }), 32 | ), 33 | perPage: number() 34 | .optional() 35 | .default(30) 36 | .describe( 37 | option({ 38 | description: 'Number of items per page', 39 | alias: 'l', 40 | }), 41 | ), 42 | all: boolean() 43 | .optional() 44 | .default(false) 45 | .describe( 46 | option({ 47 | description: 'Fetch all pages of users', 48 | alias: 'a', 49 | }), 50 | ), 51 | }); 52 | 53 | type Props = { 54 | options: zInfer; 55 | }; 56 | 57 | export default function Proxy({ options }: Props) { 58 | return ( 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Description 4 | 5 | 6 | 7 | ## Type of Change 8 | 9 | - [ ] Bug fix 10 | - [ ] New feature/command 11 | - [ ] Documentation update 12 | - [ ] Code refactoring 13 | - [ ] Performance improvement 14 | - [ ] Test addition/update 15 | - [ ] Other (please describe): 16 | 17 | ## Checklist 18 | 19 | - [ ] I have created an issue and linked it in this PR 20 | - [ ] I have created a branch from `main` with an appropriate name (e.g., `fix/issue-123`, `feature/new-command`) 21 | - [ ] My code follows the project's coding style guidelines 22 | - [ ] I have added tests for my changes (>90% coverage of new code) 23 | - [ ] I have updated the documentation if necessary 24 | - [ ] All tests pass locally 25 | - [ ] Lint checks pass locally 26 | - [ ] I have reviewed my own code for potential issues 27 | 28 | ## New Command Details (if applicable) 29 | 30 | - [ ] Command is placed in the `src/commands` directory 31 | - [ ] Command file contains only argument configuration and a root command component 32 | - [ ] Command is wrapped with the `AuthProvider` component 33 | - [ ] Command has an optional `apiKey` argument 34 | - [ ] API key scope is declared for the command 35 | - [ ] Documentation added to the README 36 | 37 | ## Additional Notes 38 | 39 | 40 | 41 | ## Screenshots/Recordings 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/auditTest/views/LoadingView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { describe, it, expect } from 'vitest'; 4 | import LoadingView from '../../../source/components/test/views/LoadingView.js'; 5 | import { ProcessPhase } from '../../../source/components/test/auditTypes.js'; 6 | 7 | describe('LoadingView', () => { 8 | it('should render fetching message', () => { 9 | const { lastFrame } = render( 10 | , 14 | ); 15 | 16 | expect(lastFrame()).toContain('Fetching audit logs'); 17 | }); 18 | 19 | it('should render processing message with progress', () => { 20 | const { lastFrame } = render( 21 | , 25 | ); 26 | 27 | expect(lastFrame()).toContain('Processing audit logs (5/10)'); 28 | }); 29 | 30 | it('should render checking message with progress', () => { 31 | const { lastFrame } = render( 32 | , 36 | ); 37 | 38 | expect(lastFrame()).toContain('Checking against PDP (8/20)'); 39 | }); 40 | 41 | it('should handle complete phase gracefully', () => { 42 | const { lastFrame } = render( 43 | , 47 | ); 48 | 49 | // Should not crash and should render something 50 | expect(lastFrame()).toBeTruthy(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /source/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { getPermitApiUrl, getPermitOriginUrl } from '../config.js'; 2 | 3 | type ApiResponse = { 4 | headers: Headers; 5 | response: T; 6 | status: number; 7 | error: string | null; 8 | }; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | export const apiCall = async ( 12 | endpoint: string, 13 | token: string, 14 | cookie?: string | null | undefined, 15 | method = 'GET', 16 | body?: string, 17 | ): Promise> => { 18 | let defaultResponse: ApiResponse = { 19 | headers: new Headers(), 20 | response: {} as T, 21 | status: -1, 22 | error: null, 23 | }; 24 | 25 | const options: RequestInit = { 26 | method, 27 | headers: { 28 | Accept: '*/*', 29 | Origin: getPermitOriginUrl(), 30 | Authorization: `Bearer ${token}`, 31 | Cookie: cookie ?? '', 32 | 'Content-Type': 'application/json', 33 | }, 34 | }; 35 | 36 | if (body) { 37 | options.body = body; 38 | } 39 | 40 | try { 41 | const res = await fetch(`${getPermitApiUrl()}/${endpoint}`, options); 42 | 43 | if (!res.ok) { 44 | const errorText = await res.json(); 45 | defaultResponse.error = `Request failed with status ${res.status}: ${errorText.message ?? errorText.detail}`; 46 | defaultResponse.status = res.status; 47 | } else { 48 | const response = await res.json(); 49 | defaultResponse.headers = res.headers; 50 | defaultResponse.response = response as T; 51 | defaultResponse.status = res.status; 52 | } 53 | } catch (error: unknown) { 54 | defaultResponse.error = 55 | error instanceof Error ? error.message : 'Unknown fetch error occurred'; 56 | } 57 | return defaultResponse; 58 | }; 59 | -------------------------------------------------------------------------------- /source/hooks/useParseResources.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { components } from '../lib/api/v1.js'; 3 | 4 | export function useParseResources( 5 | resourceStrings?: string[], 6 | ): components['schemas']['ResourceCreate'][] { 7 | return useMemo(() => { 8 | if (!resourceStrings || resourceStrings.length === 0) return []; 9 | 10 | try { 11 | return resourceStrings.map(resource => { 12 | // Split resource definition into key and attributes 13 | const [mainPart, attributesPart] = resource 14 | .split('@') 15 | .map(s => s.trim()); 16 | 17 | if (!mainPart) { 18 | throw new Error('Invalid resource format'); 19 | } 20 | 21 | // Split main part into key and name/description 22 | const [key, name] = mainPart.split(':').map(s => s.trim()); 23 | 24 | if (!key) { 25 | throw new Error(`Invalid resource key in: ${resource}`); 26 | } 27 | 28 | // Process attributes if they exist 29 | const attributes = attributesPart 30 | ? attributesPart.split(',').reduce( 31 | (acc, attr) => { 32 | const attrKey = attr.trim(); 33 | if (attrKey) { 34 | acc[attrKey] = {} as never; 35 | } 36 | return acc; 37 | }, 38 | {} as Record, 39 | ) 40 | : undefined; 41 | 42 | return { 43 | key, 44 | name: name || key, 45 | description: name || undefined, 46 | attributes, 47 | actions: {}, 48 | }; 49 | }); 50 | } catch (err) { 51 | throw new Error( 52 | `Invalid resource format. Expected ["key:name@attribute1,attribute2"], got ${JSON.stringify(resourceStrings) + err}`, 53 | ); 54 | } 55 | }, [resourceStrings]); 56 | } 57 | -------------------------------------------------------------------------------- /source/implement/example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/permitio/permit-golang/pkg/config" 8 | "github.com/permitio/permit-golang/pkg/enforcement" 9 | "github.com/permitio/permit-golang/pkg/permit" 10 | 11 | ) 12 | 13 | const ( 14 | port = 4000 15 | ) 16 | 17 | func main() { 18 | 19 | permitClient := permit.NewPermit( 20 | config.NewConfigBuilder( 21 | "{{API_KEY}}"). 22 | WithPdpUrl("http://localhost:7766"). 23 | Build(), 24 | ) 25 | 26 | 27 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 28 | 29 | user := enforcement.UserBuilder("{{USER_ID}}"). 30 | WithFirstName("{{FIRST_NAME}}"). 31 | WithLastName("{{LAST_NAME}}"). 32 | WithEmail("{{EMAIL}}"). 33 | Build() 34 | 35 | 36 | resource := enforcement.ResourceBuilder("{{RESOURCES}}").Build() 37 | 38 | 39 | permitted, err := permitClient.Check(user, "{{ACTIONS}}", resource) 40 | if err != nil { 41 | fmt.Println(err) 42 | return 43 | } 44 | if permitted { 45 | w.WriteHeader(http.StatusOK) 46 | _, err = w.Write([]byte(fmt.Sprintf("%s %s is PERMITTED to %s %s!", user.FirstName, user.LastName, "{{ACTIONS}}" , resource.Type))) 47 | } else { 48 | w.WriteHeader(http.StatusForbidden) 49 | _, err = w.Write([]byte(fmt.Sprintf("%s %s is NOT PERMITTED to %s %s!", user.FirstName, user.LastName, "{{ACTIONS}}" , resource.Type))) 50 | } 51 | }) 52 | fmt.Printf("Listening on http://localhost:%d", port) 53 | http.ListenAndServe(fmt.Sprintf(":%d", port), nil) 54 | 55 | } -------------------------------------------------------------------------------- /tests/trino/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | # PostgreSQL database 5 | postgres: 6 | image: postgres:15 7 | environment: 8 | POSTGRES_DB: testdb 9 | POSTGRES_USER: testuser 10 | POSTGRES_PASSWORD: testpass 11 | ports: 12 | - '5432:5432' 13 | volumes: 14 | - ./sample_data/postgres_init.sql:/docker-entrypoint-initdb.d/init.sql 15 | healthcheck: 16 | test: ['CMD-SHELL', 'pg_isready -U testuser -d testdb'] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 5 20 | 21 | # MySQL database 22 | mysql: 23 | image: mysql:8.0 24 | environment: 25 | MYSQL_ROOT_PASSWORD: rootpass 26 | MYSQL_DATABASE: testdb 27 | MYSQL_USER: testuser 28 | MYSQL_PASSWORD: testpass 29 | ports: 30 | - '3306:3306' 31 | volumes: 32 | - ./sample_data/mysql_init.sql:/docker-entrypoint-initdb.d/init.sql 33 | healthcheck: 34 | test: 35 | [ 36 | 'CMD', 37 | 'mysqladmin', 38 | 'ping', 39 | '-h', 40 | 'localhost', 41 | '-u', 42 | 'testuser', 43 | '-ptestpass', 44 | ] 45 | interval: 5s 46 | timeout: 5s 47 | retries: 5 48 | 49 | # Trino server 50 | trino: 51 | image: trinodb/trino:latest 52 | ports: 53 | - '8080:8080' 54 | volumes: 55 | - ./trino_config:/etc/trino 56 | depends_on: 57 | postgres: 58 | condition: service_healthy 59 | mysql: 60 | condition: service_healthy 61 | healthcheck: 62 | test: ['CMD', 'curl', '-f', 'http://localhost:8080/v1/info'] 63 | interval: 10s 64 | timeout: 5s 65 | retries: 5 66 | -------------------------------------------------------------------------------- /source/commands/policy/create/simple.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import { option } from 'pastel'; 4 | import zod from 'zod'; 5 | import CreateSimpleWizard from '../../../components/policy/CreateSimpleWizard.js'; 6 | 7 | export const description = 8 | 'Create a new Policy table for Role Based Access Control'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'The API key for the permit Environment Organization or Project', 18 | alias: 'k', 19 | }), 20 | ), 21 | resources: zod 22 | .array(zod.string()) 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 27 | 'Array of resources in format "key:name@attribute1,attribute2"', 28 | }), 29 | ), 30 | actions: zod 31 | .array(zod.string()) 32 | .optional() 33 | .describe( 34 | option({ 35 | description: 36 | 'Array of actions in format "key:description@attribute1,attribute2"', 37 | }), 38 | ), 39 | roles: zod 40 | .array(zod.string()) 41 | .optional() 42 | .describe( 43 | option({ 44 | description: 45 | 'Array of roles in format "role|resource:action|resource:action"', 46 | }), 47 | ), 48 | }); 49 | 50 | type Props = { 51 | options: zod.infer; 52 | }; 53 | 54 | export default function Simple({ options }: Props) { 55 | return ( 56 | 57 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /source/hooks/trino/useTrinoProcessor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hook for processing Trino schema extraction and mapping to Permit resources. 3 | * Lint/prettier compliant, strict types, ready for implementation. 4 | */ 5 | 6 | import { useCallback } from 'react'; 7 | import { 8 | connectToTrino, 9 | fetchTrinoSchema, 10 | mapTrinoSchemaToPermitResources, 11 | TrinoSchemaData, 12 | } from '../../utils/trinoUtils.js'; 13 | import { useResourcesApi } from '../useResourcesApi.js'; 14 | import type { TrinoOptions } from '../../components/env/trino/types.js'; 15 | 16 | export function useTrinoProcessor() { 17 | const { createBulkResources, status, errorMessage } = useResourcesApi(); 18 | 19 | /** 20 | * Main processing function. 21 | * Connects to Trino, extracts schema, maps to Permit resources, and syncs with Permit. 22 | */ 23 | const processTrinoSchema = useCallback( 24 | async (options: TrinoOptions): Promise => { 25 | // 1. Connect to Trino 26 | const client = connectToTrino(options); 27 | 28 | // 2. Fetch Trino schema 29 | const trinoSchema: TrinoSchemaData = await fetchTrinoSchema(client, { 30 | catalog: options.catalog, 31 | schema: options.schema, 32 | }); 33 | 34 | // 3. Map to Permit resources 35 | const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); 36 | 37 | // 4. Sync with Permit (omit 'type' property, ensure actions is an object) 38 | await createBulkResources( 39 | permitResources.map(({ actions, ...r }) => ({ 40 | ...r, 41 | actions: Object.fromEntries( 42 | actions.map((action: string) => [action, {}]), 43 | ), 44 | })), 45 | ); 46 | }, 47 | [createBulkResources], 48 | ); 49 | 50 | return { processTrinoSchema, status, errorMessage }; 51 | } 52 | -------------------------------------------------------------------------------- /source/utils/attributes.ts: -------------------------------------------------------------------------------- 1 | // source/utils/attributes.ts 2 | 3 | class AttributeParseError extends Error { 4 | constructor(message: string) { 5 | super(message); 6 | this.name = 'AttributeParseError'; 7 | } 8 | } 9 | 10 | export function parseAttributes( 11 | attrString: string, 12 | ): Record { 13 | if (!attrString || attrString.trim() === '') { 14 | return {}; 15 | } 16 | 17 | const attributes: Record = {}; 18 | 19 | const pairs = attrString.split(','); 20 | for (const pair of pairs) { 21 | const parts = pair.split(':'); 22 | 23 | // Validate the pair format 24 | if (parts.length !== 2) { 25 | throw new AttributeParseError( 26 | `Invalid attribute format: "${pair}". Expected format "key:value"`, 27 | ); 28 | } 29 | 30 | const [key, value] = parts.map(s => s.trim()); 31 | 32 | // Validate key 33 | if (!key) { 34 | throw new AttributeParseError('Attribute key cannot be empty'); 35 | } 36 | 37 | // Validate value 38 | if (value === undefined || value === '') { 39 | throw new AttributeParseError(`Value for key "${key}" cannot be empty`); 40 | } 41 | 42 | // Parse the value into appropriate type 43 | try { 44 | if (value.toLowerCase() === 'true') { 45 | attributes[key] = true; 46 | } else if (value.toLowerCase() === 'false') { 47 | attributes[key] = false; 48 | } else if (!isNaN(Number(value)) && value.trim() !== '') { 49 | attributes[key] = Number(value); 50 | } else { 51 | attributes[key] = value; 52 | } 53 | } catch (error) { 54 | throw new AttributeParseError( 55 | `Failed to parse value for key "${key}": ${(error as Error).message}`, 56 | ); 57 | } 58 | } 59 | 60 | return attributes; 61 | } 62 | -------------------------------------------------------------------------------- /source/hooks/useParseActions.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { components } from '../lib/api/v1.js'; 3 | 4 | export function useParseActions( 5 | actionStrings?: string[], 6 | ): Record { 7 | return useMemo(() => { 8 | if (!actionStrings || actionStrings.length === 0) return {}; 9 | 10 | try { 11 | return actionStrings.reduce( 12 | (acc, action) => { 13 | // Split action definition into main part and attributes part 14 | const [mainPart, attributesPart] = action 15 | .split('@') 16 | .map(s => s.trim()); 17 | 18 | if (!mainPart) { 19 | throw new Error('Invalid action format'); 20 | } 21 | 22 | // Split main part into key and description 23 | const [key, description] = mainPart.split(':').map(s => s.trim()); 24 | 25 | if (!key) { 26 | throw new Error(`Invalid action key in: ${action}`); 27 | } 28 | 29 | // Process attributes if they exist 30 | const attributes = attributesPart 31 | ? attributesPart.split(',').reduce( 32 | (attrAcc, attr) => { 33 | const attrKey = attr.trim(); 34 | if (attrKey) { 35 | attrAcc[attrKey] = {} as never; 36 | } 37 | return attrAcc; 38 | }, 39 | {} as Record, 40 | ) 41 | : undefined; 42 | 43 | acc[key] = { 44 | name: key, 45 | description: description || undefined, 46 | attributes: attributes, 47 | }; 48 | 49 | return acc; 50 | }, 51 | {} as Record, 52 | ); 53 | } catch (err) { 54 | throw new Error( 55 | `Invalid action format. Expected ["key:description@attribute1,attribute2"], got ${JSON.stringify(actionStrings) + err}`, 56 | ); 57 | } 58 | }, [actionStrings]); 59 | } 60 | -------------------------------------------------------------------------------- /source/hooks/usePolicyGitReposApi.ts: -------------------------------------------------------------------------------- 1 | import { components } from '../lib/api/v1.js'; 2 | import { useCallback, useMemo } from 'react'; 3 | import useClient from './useClient.js'; 4 | 5 | export type PolicyRepoCreate = components['schemas']['PolicyRepoCreate']; 6 | export type PolicyRepoRead = components['schemas']['PolicyRepoRead']; 7 | 8 | export type GitConfig = { 9 | url: string; 10 | mainBranchName: string; 11 | credentials: { 12 | authType: 'ssh'; 13 | username: string; 14 | privateKey: string; 15 | }; 16 | key: string; 17 | activateWhenValidated: boolean; 18 | }; 19 | 20 | export const usePolicyGitReposApi = () => { 21 | const { authenticatedApiClient } = useClient(); 22 | 23 | const getRepoList = useCallback( 24 | async (projectKey: string) => { 25 | return await authenticatedApiClient().GET( 26 | `/v2/projects/{proj_id}/repos`, 27 | { proj_id: projectKey }, 28 | ); 29 | }, 30 | [authenticatedApiClient], 31 | ); 32 | 33 | const configurePermit = useCallback( 34 | async (projectKey: string, gitconfig: GitConfig) => { 35 | const endpoint = `/v2/projects/{proj_id}/repos`; 36 | const body: PolicyRepoCreate = { 37 | url: gitconfig.url, 38 | main_branch_name: gitconfig.mainBranchName, 39 | credentials: { 40 | auth_type: gitconfig.credentials.authType, 41 | username: gitconfig.credentials.username, 42 | private_key: gitconfig.credentials.privateKey, 43 | }, 44 | key: gitconfig.key, 45 | activate_when_validated: gitconfig.activateWhenValidated, 46 | }; 47 | return await authenticatedApiClient().POST( 48 | endpoint, 49 | { proj_id: projectKey }, 50 | body, 51 | ); 52 | }, 53 | [authenticatedApiClient], 54 | ); 55 | 56 | return useMemo( 57 | () => ({ 58 | getRepoList, 59 | configurePermit, 60 | }), 61 | [configurePermit, getRepoList], 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | - run: npm ci 19 | - run: npm run build --if-present 20 | - run: npm run lint 21 | - run: npm run test 22 | - name: Upload build artifacts 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: build 26 | path: dist 27 | 28 | publish-npm: 29 | needs: build 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 20 36 | registry-url: https://registry.npmjs.org/ 37 | - name: Extract version from release tag 38 | id: get_version 39 | run: | 40 | VERSION=${GITHUB_REF#refs/tags/} 41 | VERSION=${VERSION#v} 42 | echo "version=$VERSION" >> $GITHUB_OUTPUT 43 | echo "Publishing version: $VERSION" 44 | - name: Update package.json version 45 | run: | 46 | sed -i 's/"version": "0.0.0-placeholder"/"version": "${{ steps.get_version.outputs.version }}"/' package.json 47 | - run: npm ci 48 | - name: Download build artifacts 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: build 52 | path: dist 53 | - run: npm publish --access public 54 | env: 55 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 56 | -------------------------------------------------------------------------------- /source/commands/test/generate/code-sample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AuthProvider } from '../../../components/AuthProvider.js'; 4 | import zod from 'zod'; 5 | import type { infer as zInfer } from 'zod'; 6 | import { option } from 'pastel'; 7 | import { CodeSampleComponent } from '../../../components/test/code-samples/CodeSampleComponent.js'; 8 | 9 | export const options = zod.object({ 10 | framework: zod.enum(['jest', 'pytest', 'vitest']).describe( 11 | option({ 12 | description: 13 | 'Test code sample that iterates the config file and asserts the results.', 14 | }), 15 | ), 16 | configPath: zod 17 | .string() 18 | .optional() 19 | .default('authz-test.json') 20 | .describe( 21 | option({ 22 | description: 'Optional: Path to the generated config json file', 23 | }), 24 | ), 25 | path: zod 26 | .string() 27 | .optional() 28 | .describe( 29 | option({ 30 | description: 'Optional: Path to the json file to save the code sample', 31 | }), 32 | ), 33 | apiKey: zod 34 | .string() 35 | .optional() 36 | .describe( 37 | option({ 38 | description: 'Optional: API Key to be used for running tests', 39 | }), 40 | ), 41 | pdpUrl: zod 42 | .string() 43 | .optional() 44 | .default('http://localhost:7766') 45 | .describe( 46 | option({ 47 | description: 'Optional: PDP to be used in tests.', 48 | }), 49 | ), 50 | }); 51 | 52 | type Props = { 53 | readonly options: zInfer; 54 | }; 55 | 56 | export default function E2e({ 57 | options: { framework, configPath, path, apiKey, pdpUrl }, 58 | }: Props) { 59 | return ( 60 | 61 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /source/hooks/useGitopsCloneApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import useClient from './useClient.js'; 3 | 4 | function convertGitSshToHttps(sshUrl: string): string { 5 | if (!sshUrl.startsWith('git@')) { 6 | throw new Error('Invalid SSH URL'); 7 | } 8 | 9 | // Step 1: Remove 'git@' 10 | const parts: string[] = sshUrl.split('@'); 11 | if (parts.length !== 2) { 12 | throw new Error('Malformed SSH URL'); 13 | } 14 | 15 | const domainAndPath: string = parts[1] ?? ''; // "github.com:Abiji-2020/PermitTest.git" 16 | 17 | // Step 2: Find the ':' separator 18 | const index: number = domainAndPath.indexOf(':'); 19 | if (index === -1) { 20 | throw new Error('Invalid SSH URL format'); 21 | } 22 | 23 | const domain: string = domainAndPath.substring(0, index); // "github.com" 24 | const path: string = domainAndPath.substring(index + 1); // "Abiji-2020/PermitTest.git" 25 | 26 | // Step 3: Construct the HTTPS URL 27 | let httpsUrl: string = `https://${domain}/${path}`; 28 | 29 | // Step 4: Remove '.git' suffix if present 30 | if (httpsUrl.endsWith('.git')) { 31 | httpsUrl = httpsUrl.slice(0, -4); 32 | } 33 | 34 | return httpsUrl; 35 | } 36 | 37 | export default function useGitOpsCloneApi() { 38 | const { authenticatedApiClient } = useClient(); 39 | 40 | const fetchActivePolicyRepo = useCallback(async (): Promise< 41 | string | null 42 | > => { 43 | const client = authenticatedApiClient(); 44 | const { data, error } = await client.GET( 45 | `/v2/projects/{proj_id}/repos/active`, 46 | ); 47 | if (error) { 48 | throw new Error(`Failed to fetch Active policy Repository: ${error}`); 49 | } 50 | if (data) { 51 | if (data.url.startsWith('git@')) { 52 | return convertGitSshToHttps(data.url); 53 | } 54 | 55 | return data.url; 56 | } 57 | return null; 58 | }, [authenticatedApiClient]); 59 | return { fetchActivePolicyRepo }; 60 | } 61 | -------------------------------------------------------------------------------- /source/hooks/useCheckPdpApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { components } from '../lib/api/pdp-v1.js'; 3 | import useClient from './useClient.js'; 4 | 5 | export type AuthorizationQuery = components['schemas']['AuthorizationQuery']; 6 | 7 | export interface UrlRequestInput { 8 | user: { 9 | key: string; 10 | firstName?: string; 11 | lastName?: string; 12 | email?: string; 13 | attributes: Record; 14 | }; 15 | http_method: string; 16 | url: string; 17 | tenant: string; 18 | context: Record; 19 | sdk?: string; 20 | } 21 | 22 | export const useCheckPdpApi = () => { 23 | const { authenticatedPdpClient } = useClient(); 24 | 25 | const getAllowedCheck = useCallback( 26 | async (body: AuthorizationQuery, pdp_url?: string) => { 27 | return await authenticatedPdpClient(pdp_url).POST( 28 | '/allowed', 29 | undefined, 30 | body, 31 | ); 32 | }, 33 | [authenticatedPdpClient], 34 | ); 35 | 36 | const getAllowedUrlCheck = useCallback( 37 | async (requestInput: UrlRequestInput, pdp_url?: string) => { 38 | const apiBody = { ...requestInput }; 39 | 40 | type ApiCompliantBody = { 41 | user: { 42 | key: string; 43 | firstName?: string; 44 | lastName?: string; 45 | email?: string; 46 | attributes: Record; 47 | }; 48 | http_method: string; 49 | url: string; 50 | tenant: string; 51 | context: Record; 52 | sdk?: string; 53 | }; 54 | 55 | return await authenticatedPdpClient(pdp_url).POST( 56 | '/allowed_url', 57 | undefined, 58 | apiBody as unknown as ApiCompliantBody, 59 | ); 60 | }, 61 | [authenticatedPdpClient], 62 | ); 63 | 64 | return useMemo( 65 | () => ({ 66 | getAllowedCheck, 67 | getAllowedUrlCheck, 68 | }), 69 | [getAllowedCheck, getAllowedUrlCheck], 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /source/utils/init/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import Handlebars from 'handlebars'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | // Manually define __dirname 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const implementDir = path.join(__dirname + '../../../implement'); 10 | 11 | function getFileContent(fileName: string) { 12 | const filePath = path.join(implementDir, fileName); 13 | if (!fs.existsSync(filePath)) { 14 | throw new Error(`File not found: ${filePath}`); 15 | } 16 | const content = fs.readFileSync(filePath, 'utf-8'); 17 | return content; 18 | } 19 | 20 | export function getFormatedFile( 21 | fileName: string, 22 | apiKey: string, 23 | action: string, 24 | resource: string, 25 | userId?: string, 26 | userEmail?: string, 27 | userFirstName?: string, 28 | userLastName?: string, 29 | ) { 30 | // Get the template content 31 | const templateContent = getFileContent(fileName); 32 | 33 | // Compile the Handlebars template 34 | const template = Handlebars.compile(templateContent); 35 | 36 | // Define the context with all variables 37 | const context = { 38 | API_KEY: apiKey, 39 | ACTIONS: action, 40 | RESOURCES: resource, 41 | USER_ID: userId || '', 42 | EMAIL: userEmail || '', 43 | FIRST_NAME: userFirstName || '', 44 | LAST_NAME: userLastName || '', 45 | }; 46 | 47 | // Render the template with the context 48 | return template(context); 49 | } 50 | 51 | export const installationCommand = { 52 | python: 'pip install permit fastapi "uvicorn[standard]"', 53 | node: 'npm install permitio', 54 | ruby: 'gem install permit-sdk webrick', 55 | java: `// add this line to install the Permit.io Java SDK in your project 56 | implementation 'io.permit:permit-sdk-java'`, 57 | dotnet: `dotnet add package Permit`, 58 | go: `go get github.com/permitio/permit-golang`, 59 | }; 60 | -------------------------------------------------------------------------------- /source/hooks/openapi/process/openapiConstants.ts: -------------------------------------------------------------------------------- 1 | // Define all x-permit extensions as object properties 2 | export const PERMIT_EXTENSIONS = { 3 | RESOURCE: 'x-permit-resource', 4 | ACTION: 'x-permit-action', 5 | ROLE: 'x-permit-role', 6 | RESOURCE_ROLE: 'x-permit-resource-role', 7 | RELATION: 'x-permit-relation', 8 | DERIVED_ROLE: 'x-permit-derived-role', 9 | }; 10 | 11 | // Define interface types 12 | export interface ResourceKey { 13 | key: string; 14 | } 15 | export interface RoleKey { 16 | key: string; 17 | } 18 | export interface RoleWithPermissions { 19 | permissions?: string[]; 20 | } 21 | export interface UrlMapping { 22 | url: string; 23 | http_method: string; 24 | resource: string; 25 | action: string; 26 | } 27 | export interface ProcessorContext { 28 | resources: Set; 29 | actions: Map>; 30 | roles: Set; 31 | resourceRoles: Map; 32 | relations: Map; 33 | mappings: UrlMapping[]; 34 | errors: string[]; 35 | warnings: string[]; 36 | existingResources: ResourceKey[]; 37 | existingRoles: RoleKey[]; 38 | baseUrl: string; 39 | } 40 | export interface ProcessorProps { 41 | inputPath: string; 42 | setProgress: (message: string) => void; 43 | setStatus: (status: 'loading' | 'error' | 'success') => void; 44 | setError: (error: string | null) => void; 45 | setProcessingDone: (done: boolean) => void; 46 | } 47 | 48 | export interface UpdateResourceRoleFunction { 49 | ( 50 | resource: string, 51 | role: string, 52 | permission: string | string[], 53 | ): Promise; 54 | } 55 | 56 | // Define constants for repeated error messages 57 | export const ERROR_CREATING_RESOURCE = 'Failed to create resource'; 58 | export const ERROR_CREATING_ROLE = 'Failed to create role'; 59 | export const ERROR_UPDATING_ROLE = 'Failed to update role'; 60 | export const ERROR_CREATING_RESOURCE_ROLE = 'Failed to create resource role'; 61 | -------------------------------------------------------------------------------- /source/commands/test/generate/e2e.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AuthProvider } from '../../../components/AuthProvider.js'; 4 | import { GeneratePolicySnapshot } from '../../../components/test/GeneratePolicySnapshot.js'; 5 | import zod from 'zod'; 6 | import type { infer as zInfer } from 'zod'; 7 | import { option } from 'pastel'; 8 | 9 | export const options = zod.object({ 10 | apiKey: zod 11 | .string() 12 | .optional() 13 | .describe( 14 | option({ 15 | description: 'Optional: API Key to be used for test generation', 16 | }), 17 | ), 18 | dryRun: zod 19 | .boolean() 20 | .optional() 21 | .default(false) 22 | .describe( 23 | option({ 24 | description: 25 | 'Optional: Will generate all the test cases without data creation.', 26 | }), 27 | ), 28 | models: zod 29 | .array(zod.string()) 30 | .optional() 31 | .default(['RBAC']) 32 | .describe( 33 | option({ 34 | description: 35 | 'Optional: an array of all the models the user wants to generate.', 36 | }), 37 | ), 38 | path: zod 39 | .string() 40 | .optional() 41 | .describe( 42 | option({ 43 | description: 44 | 'Optional: Path to the json file to store the generated config (We recommend doing this)', 45 | }), 46 | ), 47 | snippet: zod 48 | .enum(['jest', 'pytest', 'vitest']) 49 | .optional() 50 | .describe( 51 | option({ 52 | description: 53 | 'Test code sample that iterates the config file and asserts the results.', 54 | }), 55 | ), 56 | }); 57 | 58 | type Props = { 59 | readonly options: zInfer; 60 | }; 61 | 62 | export default function E2e({ 63 | options: { dryRun, models, path, apiKey, snippet }, 64 | }: Props) { 65 | return ( 66 | 67 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /source/components/ui/Table.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import Table from 'cli-table'; 3 | import chalk from 'chalk'; 4 | import { Text } from 'ink'; 5 | 6 | interface Props { 7 | data: object[]; 8 | headers: string[]; 9 | headersHexColor: string; 10 | } 11 | 12 | const TableComponent: React.FC = ({ 13 | data, 14 | headers, 15 | headersHexColor, 16 | }) => { 17 | // Build table string synchronously whenever data/headers/colors change 18 | const tableString = useMemo(() => { 19 | if (!data.length || !headers.length) return null; 20 | 21 | // Map rows to objects based on headers 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | const updatedRows = data.map((item: any) => { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | const row: Record = {}; 26 | headers.forEach(header => { 27 | row[header] = item[header]; 28 | }); 29 | return row; 30 | }); 31 | 32 | // Calculate column widths based on the longest cell (or header) in each column 33 | const colWidths = headers.map(header => { 34 | const allValues = updatedRows.map(r => String(r[header] ?? '')); 35 | const maxCellLength = Math.max( 36 | header.length, 37 | ...allValues.map(v => v.length), 38 | ); 39 | // add a little padding so things don't butt right up against the border 40 | return maxCellLength + 2; 41 | }); 42 | 43 | // Instantiate cli-table with dynamic widths 44 | const cliTable = new Table({ 45 | head: headers.map(h => chalk.hex(headersHexColor)(h)), 46 | colWidths, 47 | }); 48 | 49 | // Push values into table 50 | updatedRows.forEach(row => { 51 | const vals = headers.map(h => row[h] ?? ''); 52 | cliTable.push(vals); 53 | }); 54 | 55 | return cliTable.toString(); 56 | }, [data, headers, headersHexColor]); 57 | 58 | if (!tableString) return null; 59 | return {tableString}; 60 | }; 61 | 62 | export default TableComponent; 63 | -------------------------------------------------------------------------------- /source/hooks/openapi/useOpenapiApi.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { usePermitResourceApi } from './usePermitResourceApi.js'; 3 | import { usePermitRoleApi } from './usePermitRoleApi.js'; 4 | import { usePermitRelationApi } from './usePermitRelationApi.js'; 5 | import { usePermitUrlMappingApi } from './usePermitUrlMappingApi.js'; 6 | 7 | /** 8 | * Hook to interact with Permit API for OpenAPI spec processing 9 | */ 10 | export const useOpenapiApi = () => { 11 | const { listResources, createResource, updateResource, createAction } = 12 | usePermitResourceApi(); 13 | const { 14 | listRoles, 15 | getRole, 16 | createRole, 17 | updateRole, 18 | createResourceRole, 19 | updateResourceRole, 20 | } = usePermitRoleApi(); 21 | const { getRelationByKey, createRelation, createDerivedRole } = 22 | usePermitRelationApi(); 23 | const { deleteUrlMappings, createUrlMappings } = usePermitUrlMappingApi(); 24 | 25 | return useMemo( 26 | () => ({ 27 | // Resource operations 28 | listResources, 29 | createResource, 30 | updateResource, 31 | createAction, 32 | 33 | // Role operations 34 | listRoles, 35 | getRole, 36 | createRole, 37 | updateRole, 38 | createResourceRole, 39 | updateResourceRole, 40 | 41 | // Relation operations 42 | getRelationByKey, 43 | createRelation, 44 | createDerivedRole, 45 | 46 | // URL mapping operations 47 | deleteUrlMappings, 48 | createUrlMappings, 49 | }), 50 | [ 51 | // Resource operations 52 | listResources, 53 | createResource, 54 | updateResource, 55 | createAction, 56 | 57 | // Role operations 58 | listRoles, 59 | getRole, 60 | createRole, 61 | updateRole, 62 | createResourceRole, 63 | updateResourceRole, 64 | 65 | // Relation operations 66 | getRelationByKey, 67 | createRelation, 68 | createDerivedRole, 69 | 70 | // URL mapping operations 71 | deleteUrlMappings, 72 | createUrlMappings, 73 | ], 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import sonarjsPlugin from 'eslint-plugin-sonarjs'; 5 | import reactPlugin from 'eslint-plugin-react'; 6 | import reactHooksPlugin from 'eslint-plugin-react-hooks'; 7 | import prettierPlugin from 'eslint-plugin-prettier'; 8 | import js from '@eslint/js'; 9 | import prettierConfig from 'eslint-config-prettier'; 10 | import globals from 'globals'; 11 | 12 | const compat = new FlatCompat({ 13 | baseDirectory: import.meta.url, 14 | }); 15 | 16 | const eslintConfig = [ 17 | js.configs.recommended, 18 | ...compat.extends('plugin:react/recommended'), 19 | prettierConfig, 20 | ]; 21 | 22 | export default [ 23 | ...eslintConfig, 24 | { 25 | ignores: ['source/lib/api/', 'source/implement/'], 26 | }, 27 | { 28 | languageOptions: { 29 | parser: tsParser, 30 | parserOptions: { 31 | ecmaVersion: 'latest', 32 | sourceType: 'module', 33 | ecmaFeatures: { 34 | jsx: true, 35 | }, 36 | }, 37 | globals: { 38 | ...globals.browser, 39 | Headers: 'readonly', 40 | RequestInit: 'readonly', 41 | fetch: 'readonly', 42 | process: 'readonly', 43 | }, 44 | }, 45 | }, 46 | { 47 | files: ['source/**/*.{js,ts,tsx}', '*/.{js,ts,jsx,tsx}'], 48 | plugins: { 49 | '@typescript-eslint': tsPlugin, 50 | sonarjs: sonarjsPlugin, 51 | react: reactPlugin, 52 | 'react-hooks': reactHooksPlugin, 53 | prettier: prettierPlugin, 54 | }, 55 | rules: { 56 | ...reactPlugin.configs.recommended.rules, 57 | ...reactHooksPlugin.configs.recommended.rules, 58 | ...tsPlugin.configs['recommended'].rules, 59 | 'no-use-before-define': 'warn', 60 | '@typescript-eslint/no-unused-vars': 'warn', 61 | 'sonarjs/no-identical-functions': 'error', 62 | 'sonarjs/no-duplicate-string': 'error', 63 | 'prettier/prettier': 'warn', 64 | }, 65 | }, 66 | { 67 | settings: { 68 | react: { version: 'detect' }, 69 | }, 70 | }, 71 | ]; 72 | -------------------------------------------------------------------------------- /source/commands/env/apply/trino.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { AuthProvider } from '../../../components/AuthProvider.js'; 5 | import TrinoComponent from '../../../components/env/trino/TrinoComponent.js'; 6 | import type { TrinoOptions } from '../../../components/env/trino/types.js'; 7 | 8 | export const description = 9 | 'Apply permissions policy from a Trino schema, creating resources from catalogs, schemas, tables, columns.'; 10 | 11 | export const options = zod.object({ 12 | apiKey: zod 13 | .string() 14 | .optional() 15 | .describe( 16 | option({ 17 | description: 'API key for Permit authentication', 18 | alias: 'k', 19 | }), 20 | ), 21 | url: zod.string().describe( 22 | option({ 23 | description: 'Trino cluster URL (e.g., http://localhost:8080)', 24 | alias: 'u', 25 | }), 26 | ), 27 | user: zod.string().describe( 28 | option({ 29 | description: 'Trino username', 30 | }), 31 | ), 32 | password: zod 33 | .string() 34 | .optional() 35 | .describe( 36 | option({ 37 | description: 'Trino password or authentication token', 38 | alias: 'p', 39 | }), 40 | ), 41 | catalog: zod 42 | .string() 43 | .optional() 44 | .describe( 45 | option({ 46 | description: 'Restrict to a specific catalog', 47 | alias: 'c', 48 | }), 49 | ), 50 | schema: zod 51 | .string() 52 | .optional() 53 | .describe( 54 | option({ 55 | description: 'Restrict to a specific schema', 56 | alias: 's', 57 | }), 58 | ), 59 | createColumnResources: zod 60 | .boolean() 61 | .optional() 62 | .default(false) 63 | .describe( 64 | option({ 65 | description: 'Create individual column resources (default: false)', 66 | alias: 'cols', 67 | }), 68 | ), 69 | }); 70 | 71 | export default function Trino({ options }: { options: TrinoOptions }) { 72 | return ( 73 | 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /tests/components/env/openapi/OpenapiForm.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import { render } from 'ink-testing-library'; 4 | import OpenapiForm from '../../../../source/components/env/openapi/OpenapiForm.js'; 5 | 6 | describe('OpenapiForm', () => { 7 | it('should render the input form with correct label', () => { 8 | const { lastFrame } = render( 9 | {}} onSubmit={() => {}} />, 10 | ); 11 | 12 | expect(lastFrame()).toContain('Enter the path to your OpenAPI spec file'); 13 | }); 14 | 15 | it('should show the current input path', () => { 16 | const { lastFrame } = render( 17 | {}} 20 | onSubmit={() => {}} 21 | />, 22 | ); 23 | 24 | expect(lastFrame()).toContain('/path/to/file.json'); 25 | }); 26 | 27 | it('should use TextInput to handle typing', () => { 28 | // Instead of testing stdin directly, we'll verify the component is set up correctly 29 | const setInputPathMock = vi.fn(); 30 | const { lastFrame } = render( 31 | {}} 35 | />, 36 | ); 37 | 38 | expect(lastFrame()).toContain('Enter the path to your OpenAPI spec file'); 39 | }); 40 | 41 | it('should call onSubmit when pressing enter', () => { 42 | const onSubmitMock = vi.fn(); 43 | const { stdin } = render( 44 | {}} 47 | onSubmit={onSubmitMock} 48 | />, 49 | ); 50 | 51 | // Simulate pressing enter 52 | stdin.write('\r'); 53 | 54 | expect(onSubmitMock).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | it('should show placeholder text when input is empty', () => { 58 | const { lastFrame } = render( 59 | {}} onSubmit={() => {}} />, 60 | ); 61 | 62 | expect(lastFrame()).toContain('Path to local file or URL'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /source/components/SelectProject.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'ink'; 3 | import SelectInput from 'ink-select-input'; 4 | import Spinner from 'ink-spinner'; 5 | import { ActiveState } from './EnvironmentSelection.js'; 6 | import { useProjectAPI } from '../hooks/useProjectAPI.js'; 7 | 8 | type Props = { 9 | accessToken?: string; 10 | cookie?: string | null; 11 | onComplete: (project: ActiveState) => void; 12 | onError: (error: string) => void; 13 | }; 14 | 15 | const SelectProject: React.FC = ({ 16 | accessToken, 17 | cookie, 18 | onComplete, 19 | onError, 20 | }) => { 21 | const [projects, setProjects] = useState([]); 22 | const [loading, setLoading] = useState(true); 23 | 24 | const { getProjects } = useProjectAPI(); 25 | 26 | const handleProjectSelect = (project: object) => { 27 | const selectedProject = project as ActiveState; 28 | onComplete({ label: selectedProject.label, value: selectedProject.value }); 29 | }; 30 | 31 | useEffect(() => { 32 | const fetchProjects = async () => { 33 | const { data: projects, error } = await getProjects(accessToken, cookie); 34 | 35 | if (error || !projects) { 36 | onError( 37 | `Failed to load projects. Reason: ${error}. Please check your network connection or credentials and try again.`, 38 | ); 39 | return; 40 | } 41 | 42 | if (projects.length === 1 && projects[0]) { 43 | onComplete({ label: projects[0].name, value: projects[0].id }); 44 | } 45 | 46 | setProjects( 47 | projects.map(project => ({ label: project.name, value: project.id })), 48 | ); 49 | setLoading(false); 50 | }; 51 | 52 | fetchProjects(); 53 | 54 | setLoading(false); 55 | }, [accessToken, cookie, getProjects, onComplete, onError]); 56 | 57 | return loading ? ( 58 | 59 | Loading Projects... 60 | 61 | ) : ( 62 | <> 63 | Select a project 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default SelectProject; 70 | -------------------------------------------------------------------------------- /source/components/LoginFlow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | import { browserAuth, authCallbackServer } from '../lib/auth.js'; 5 | import { useAuthApi } from '../hooks/useAuthApi.js'; 6 | import { useApiKeyApi } from '../hooks/useApiKeyApi.js'; 7 | // import { useUnauthenticatedApi } from '../hooks/useUnauthenticatedApi.js'; 8 | 9 | type LoginFlowProps = { 10 | apiKey?: string; 11 | onSuccess: (accessToken: string, cookie: string) => void; 12 | onError: (error: string) => void; 13 | }; 14 | 15 | const LoginFlow: React.FC = ({ 16 | apiKey, 17 | onSuccess, 18 | onError, 19 | }) => { 20 | const [loading, setLoading] = useState(true); 21 | 22 | const { getLogin } = useAuthApi(); 23 | 24 | const { validateApiKey } = useApiKeyApi(); 25 | 26 | useEffect(() => { 27 | const authenticateUser = async () => { 28 | if (apiKey && validateApiKey(apiKey)) { 29 | onSuccess(apiKey, ''); 30 | } else if (apiKey) { 31 | onError( 32 | 'Invalid API Key. Please provide a valid API Key or leave it blank to use browser authentication.', 33 | ); 34 | return; 35 | } else { 36 | try { 37 | const verifier = await browserAuth(); 38 | const token = await authCallbackServer(verifier); 39 | const { headers, error } = await getLogin(token); 40 | if (error) { 41 | onError( 42 | `Login failed. Reason: ${error}. Please check your network connection and try again.`, 43 | ); 44 | return; 45 | } 46 | onSuccess(token, headers.getSetCookie()[0] ?? ''); 47 | } catch (error: unknown) { 48 | onError(`Unexpected error during authentication. ${error as string}`); 49 | return; 50 | } 51 | } 52 | }; 53 | 54 | setLoading(true); 55 | authenticateUser(); 56 | setLoading(false); 57 | }, [apiKey, getLogin, onError, onSuccess, validateApiKey]); 58 | 59 | return loading ? ( 60 | 61 | Logging in... 62 | 63 | ) : ( 64 | Login to Permit 65 | ); 66 | }; 67 | 68 | export default LoginFlow; 69 | -------------------------------------------------------------------------------- /source/components/gitops/SSHKey.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useCallback } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import TextInput from 'ink-text-input'; 4 | import clipboard from 'clipboardy'; 5 | import { generateSSHKey } from '../../lib/gitops/utils.js'; 6 | 7 | type Props = { 8 | onSSHKeySubmit: (sshKey: string, sshUrl: string) => void; 9 | onError: (error: string) => void; 10 | }; 11 | const SSHHelperMessage = 12 | 'Go to https://github.com/{{organization}}/{{repository}}/settings/keys/new and create your new SSH key. You can also refer to https://docs.permit.io/integrations/gitops/github#create-a-repository for more details\n'; 13 | 14 | const SSHKey: React.FC = ({ onSSHKeySubmit, onError }) => { 15 | const [sshUrl, setSshUrl] = useState(''); 16 | const [sshKey, setSshKey] = useState<{ 17 | publicKey: string; 18 | privateKey: string; 19 | }>({ publicKey: '', privateKey: '' }); 20 | useEffect(() => { 21 | const key = generateSSHKey(); 22 | setSshKey(key); 23 | clipboard.writeSync(key.publicKey); 24 | }, []); 25 | const handleSSHSubmit = useCallback( 26 | (sshUrl: string) => { 27 | if (sshUrl.length <= 1) { 28 | onError('Please enter a valid SSH URL'); 29 | return; 30 | } 31 | const sshRegex = /^git@[a-zA-Z0-9.-]+:[a-zA-Z0-9/_-]+\.git$/; 32 | if (!sshRegex.test(sshUrl)) { 33 | onError('Please enter a valid SSH URL'); 34 | return; 35 | } 36 | onSSHKeySubmit(sshKey.privateKey, sshUrl); 37 | }, 38 | [sshKey, onSSHKeySubmit, onError], 39 | ); 40 | 41 | return ( 42 | <> 43 | 44 | {SSHHelperMessage} 45 | 46 | 47 | SSH Key Generated. 48 | 49 | 50 | 51 | {' '} 52 | Copy The Public Key to Github: {'\n'} 53 | {sshKey.publicKey} 54 | 55 | 56 | 57 | Enter the SSH URL of the Repo: 58 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default SSHKey; 69 | -------------------------------------------------------------------------------- /source/components/env/SelectComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | 5 | import { saveAuthToken } from '../../lib/auth.js'; 6 | import EnvironmentSelection, { 7 | ActiveState, 8 | } from '../../components/EnvironmentSelection.js'; 9 | import { useAuth } from '../AuthProvider.js'; 10 | 11 | export default function SelectComponent({ key }: { key: string | undefined }) { 12 | const [error, setError] = React.useState(null); 13 | // const [authToken, setAuthToken] = React.useState(apiKey); 14 | const [state, setState] = useState<'loading' | 'selecting' | 'done'>( 15 | 'loading', 16 | ); 17 | const [environment, setEnvironment] = useState(null); 18 | const [authToken, setAuthToken] = useState(key); 19 | 20 | const auth = useAuth(); 21 | 22 | useEffect(() => { 23 | if (error || (state === 'done' && environment)) { 24 | process.exit(1); 25 | } 26 | }, [error, state, environment]); 27 | 28 | useEffect(() => { 29 | if (auth.authToken) { 30 | setAuthToken(auth.authToken); 31 | setState('selecting'); 32 | } 33 | }, [auth.authToken]); 34 | 35 | const onEnvironmentSelectSuccess = async ( 36 | _organisation: ActiveState, 37 | _project: ActiveState, 38 | environment: ActiveState, 39 | secret: string, 40 | ) => { 41 | try { 42 | await saveAuthToken(secret); 43 | } catch (error: unknown) { 44 | setError(error as string); 45 | } 46 | setEnvironment(environment.label); 47 | setState('done'); 48 | }; 49 | 50 | return ( 51 | <> 52 | {state === 'loading' && ( 53 | 54 | 55 | Loading your environment 56 | 57 | )} 58 | {authToken && state === 'selecting' && ( 59 | 64 | )} 65 | {state === 'done' && environment && ( 66 | Environment: {environment} selected successfully 67 | )} 68 | {error && {error}} 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /tests/hooks/useRolesAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, it, expect } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from 'ink-testing-library'; 4 | import { Text } from 'ink'; 5 | import { 6 | RoleAssignmentCreate, 7 | useRolesApi, 8 | } from '../../source/hooks/useRolesApi.js'; 9 | import { getMockFetchResponse } from '../utils.js'; 10 | 11 | global.fetch = vi.fn(); 12 | 13 | describe('useRolesApi', () => { 14 | beforeEach(() => { 15 | vi.clearAllMocks(); 16 | }); 17 | 18 | it('should get roles', async () => { 19 | (fetch as any).mockResolvedValueOnce({ 20 | ...getMockFetchResponse(), 21 | json: async () => [ 22 | { id: 'role-1', name: 'Admin' }, 23 | { id: 'role-2', name: 'Editor' }, 24 | ], 25 | }); 26 | 27 | const TestComponent = () => { 28 | const { getRoles } = useRolesApi(); 29 | const [result, setResult] = React.useState(null); 30 | 31 | React.useEffect(() => { 32 | getRoles().then((res: any) => { 33 | setResult(res.data.map((r: any) => r.name).join(', ')); 34 | }); 35 | }, []); 36 | 37 | return {result}; 38 | }; 39 | 40 | const { lastFrame } = render(); 41 | await vi.waitFor(() => { 42 | expect(lastFrame()).toBe('Admin, Editor'); 43 | }); 44 | }); 45 | 46 | it('should assign roles', async () => { 47 | (fetch as any).mockResolvedValueOnce({ 48 | ...getMockFetchResponse(), 49 | json: async () => ({ 50 | success: true, 51 | }), 52 | }); 53 | 54 | const TestComponent = () => { 55 | const { assignRoles } = useRolesApi(); 56 | const [result, setResult] = React.useState(null); 57 | 58 | React.useEffect(() => { 59 | const body: RoleAssignmentCreate[] = [ 60 | { user: 'user-1', role: 'role-1' }, 61 | { user: 'user-2', role: 'role-2' }, 62 | ]; 63 | assignRoles(body).then(() => { 64 | setResult('roles assigned'); 65 | }); 66 | }, []); 67 | 68 | return {result}; 69 | }; 70 | 71 | const { lastFrame } = render(); 72 | await vi.waitFor(() => { 73 | expect(lastFrame()).toBe('roles assigned'); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /source/components/policy/create/PolicyTables.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import Table from 'cli-table'; 4 | import chalk from 'chalk'; 5 | import { PolicyData } from './types.js'; 6 | 7 | interface PolicyTablesProps { 8 | tableData: PolicyData; 9 | waitingForApproval: boolean; 10 | } 11 | 12 | export const PolicyTables: React.FC = ({ 13 | tableData, 14 | waitingForApproval, 15 | }) => { 16 | if (!tableData) return null; 17 | 18 | const { resources, roles } = tableData; 19 | 20 | // Calculate column widths based on content 21 | const roleColumnWidths = roles.map(r => Math.max(15, r.name.length + 2)); 22 | 23 | const table = new Table({ 24 | head: [ 25 | '', // Empty header for resources/actions column 26 | ...roles.map(r => chalk.hex('#FFA500')(r.name)), // Orange color for role names 27 | ], 28 | colWidths: [30, ...roleColumnWidths], 29 | chars: { 30 | top: '─', 31 | 'top-mid': '┬', 32 | 'top-left': '┌', 33 | 'top-right': '┐', 34 | bottom: '─', 35 | 'bottom-mid': '┴', 36 | 'bottom-left': '└', 37 | 'bottom-right': '┘', 38 | left: '│', 39 | 'left-mid': '├', 40 | mid: '─', 41 | 'mid-mid': '┼', 42 | right: '│', 43 | 'right-mid': '┤', 44 | middle: '│', 45 | }, 46 | }); 47 | 48 | // Add rows for each resource and its actions 49 | resources.forEach(resource => { 50 | // Add resource name in light purple 51 | table.push([chalk.hex('#9370DB')(resource.name), ...roles.map(() => '')]); 52 | 53 | // Add actions under the resource 54 | resource.actions.forEach(action => { 55 | const row = [ 56 | ` ${action}`, // Indent actions 57 | ...roles.map(role => { 58 | const hasPermission = role.permissions.some( 59 | p => p.resource === resource.name && p.actions.includes(action), 60 | ); 61 | return hasPermission ? '✓' : ''; 62 | }), 63 | ]; 64 | table.push(row); 65 | }); 66 | }); 67 | 68 | return ( 69 | 70 | {table.toString()} 71 | {waitingForApproval && ( 72 | Do you approve this policy? (yes/no) 73 | )} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /source/commands/api/users/list.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthProvider } from '../../../components/AuthProvider.js'; 3 | import { type infer as zInfer, string, object, boolean, number } from 'zod'; 4 | import { option } from 'pastel'; 5 | import PermitUsersListComponent from '../../../components/api/PermitUsersListComponent.js'; 6 | 7 | export const options = object({ 8 | apiKey: string() 9 | .optional() 10 | .describe( 11 | option({ 12 | description: 'Your Permit.io API key', 13 | }), 14 | ), 15 | projectId: string() 16 | .optional() 17 | .describe( 18 | option({ 19 | description: 'Permit.io Project ID', 20 | }), 21 | ), 22 | envId: string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Permit.io Environment ID', 27 | }), 28 | ), 29 | expandKey: boolean() 30 | .optional() 31 | .default(false) 32 | .describe( 33 | option({ 34 | description: 'Show full key values instead of truncated', 35 | alias: 'e', 36 | }), 37 | ), 38 | page: number() 39 | .optional() 40 | .default(1) 41 | .describe( 42 | option({ 43 | description: 'Page number for pagination', 44 | alias: 'p', 45 | }), 46 | ), 47 | perPage: number() 48 | .optional() 49 | .default(50) 50 | .describe( 51 | option({ 52 | description: 'Number of items per page', 53 | alias: 'l', 54 | }), 55 | ), 56 | role: string() 57 | .optional() 58 | .describe( 59 | option({ 60 | description: 'Filter users by role', 61 | alias: 'r', 62 | }), 63 | ), 64 | tenant: string() 65 | .optional() 66 | .describe( 67 | option({ 68 | description: 'Filter users by tenant', 69 | alias: 't', 70 | }), 71 | ), 72 | all: boolean() 73 | .optional() 74 | .default(false) 75 | .describe( 76 | option({ 77 | description: 'Fetch all pages of users', 78 | alias: 'a', 79 | }), 80 | ), 81 | }); 82 | 83 | type Props = { 84 | options: zInfer; 85 | }; 86 | 87 | export default function List({ options }: Props) { 88 | return ( 89 | 90 | 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /tests/EnvTemplateApply.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import Apply from '../source/commands/env/template/apply.js'; 4 | import { vi, describe, it, expect } from 'vitest'; 5 | import delay from 'delay'; 6 | 7 | const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); 8 | vi.mock('keytar', () => { 9 | const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); 10 | const keytar = { 11 | setPassword: vi.fn().mockResolvedValue(demoPermitKey), 12 | getPassword: vi.fn().mockResolvedValue(demoPermitKey), 13 | deletePassword: vi.fn().mockResolvedValue(demoPermitKey), 14 | }; 15 | return { ...keytar, default: keytar }; 16 | }); 17 | 18 | vi.mock('../source/lib/auth.js', async () => { 19 | const original = await vi.importActual('../source/lib/auth.js'); 20 | return { 21 | ...original, 22 | loadAuthToken: vi.fn(() => demoPermitKey), 23 | }; 24 | }); 25 | 26 | vi.mock('../source/hooks/useEnvironmentApi.js', () => ({ 27 | useEnvironmentApi: vi.fn(), 28 | })); 29 | 30 | vi.mock('../source/hooks/useApiKeyApi', async () => { 31 | const original = await vi.importActual('../source/hooks/useApiKeyApi'); 32 | 33 | return { 34 | ...original, 35 | useApiKeyApi: () => ({ 36 | getApiKeyScope: vi.fn().mockResolvedValue({ 37 | response: { 38 | environment_id: 'env1', 39 | project_id: 'proj1', 40 | organization_id: 'org1', 41 | }, 42 | error: null, 43 | status: 200, 44 | }), 45 | getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ 46 | response: { secret: 'test-secret' }, 47 | error: null, 48 | }), 49 | validateApiKeyScope: vi.fn().mockResolvedValue({ 50 | valid: true, 51 | scope: { 52 | environment_id: 'env1', 53 | project_id: 'proj1', 54 | organization_id: 'org1', 55 | }, 56 | error: null, 57 | }), 58 | }), 59 | }; 60 | }); 61 | 62 | describe('Apply Command', () => { 63 | it('Should display the values', async () => { 64 | const { lastFrame } = render( 65 | , 66 | ); 67 | await delay(100); 68 | expect(lastFrame()).toContain('fga-tradeoffs'); 69 | expect(lastFrame()).toContain('mesa-verde-banking-demo'); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /source/utils/fetchUtil.ts: -------------------------------------------------------------------------------- 1 | export enum MethodE { 2 | GET = 'GET', 3 | POST = 'POST', 4 | PUT = 'PUT', 5 | PATCH = 'PATCH', 6 | DELETE = 'DELETE', 7 | } 8 | 9 | /** 10 | * Generic fetch wrapper with consistent error handling and type safety 11 | */ 12 | export interface FetchResponse { 13 | success: boolean; 14 | data?: T; 15 | error?: string; 16 | status?: number; 17 | } 18 | 19 | /** 20 | * Determines if a method requires a body. 21 | */ 22 | const isBodyRequired = (method: MethodE): boolean => { 23 | return [MethodE.POST, MethodE.PATCH, MethodE.DELETE, MethodE.PUT].includes( 24 | method, 25 | ); 26 | }; 27 | 28 | /** 29 | * Centralized fetch utility - single point for HTTP request configuration 30 | */ 31 | export async function fetchUtil( 32 | url: string, 33 | method: MethodE, 34 | apiKey?: string, 35 | headers?: Record, 36 | body?: object, 37 | ): Promise> { 38 | // Consistent error handling across all API calls 39 | try { 40 | const response = await fetch(url, { 41 | method, 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 45 | ...headers, 46 | }, 47 | body: isBodyRequired(method) && body ? JSON.stringify(body) : undefined, 48 | }); 49 | 50 | // Handle non-OK responses (e.g., 400, 404, 500) 51 | if (!response.ok) { 52 | const errorMessage = await response.text(); 53 | return { 54 | success: false, 55 | error: errorMessage || `HTTP Error: ${response.status}`, 56 | status: response.status, 57 | }; 58 | } 59 | 60 | // Try parsing JSON, handle errors if response isn't JSON 61 | try { 62 | const data: T = await response.json(); 63 | return { success: true, data, status: response.status }; 64 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 65 | } catch (jsonError: any) { 66 | return { 67 | success: false, 68 | error: jsonError.message, 69 | status: response.status, 70 | }; 71 | } 72 | } catch (error) { 73 | // Handle network errors and unexpected issues 74 | return { 75 | success: false, 76 | error: error instanceof Error ? error.message : 'Unknown error occurred', 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /source/components/policy/create/TerraformGenerator.tsx: -------------------------------------------------------------------------------- 1 | import { PolicyData } from './types.js'; 2 | import { getPermitApiUrl } from '../../../config.js'; 3 | 4 | interface TerraformGeneratorProps { 5 | tableData: PolicyData; 6 | authToken: string; 7 | onTerraformGenerated: (terraform: string) => void; 8 | } 9 | 10 | export const generateTerraform = ({ 11 | tableData, 12 | authToken, 13 | onTerraformGenerated, 14 | }: TerraformGeneratorProps) => { 15 | if (!tableData) return; 16 | 17 | const { resources, roles } = tableData; 18 | 19 | // Convert resource names to keys (lowercase, no spaces) 20 | const resourceKeys = resources.map(r => ({ 21 | ...r, 22 | key: r.name.toLowerCase().replace(/\s+/g, '_'), 23 | })); 24 | 25 | // Convert role names to keys 26 | const roleKeys = roles.map(r => ({ 27 | ...r, 28 | key: r.name.toLowerCase().replace(/\s+/g, '_'), 29 | })); 30 | 31 | const terraform = `terraform { 32 | required_providers { 33 | permitio = { 34 | source = "registry.terraform.io/permitio/permit-io" 35 | version = "~> 0.0.14" 36 | } 37 | } 38 | } 39 | 40 | provider "permitio" { 41 | api_url = "${getPermitApiUrl()}" 42 | api_key = "${authToken}" 43 | } 44 | 45 | ${resourceKeys 46 | .map( 47 | r => `resource "permitio_resource" "${r.key}" { 48 | key = "${r.key}" 49 | name = "${r.name}" 50 | description = "${r.name} resource" 51 | attributes = {} 52 | actions = { 53 | ${r.actions.map(a => `"${a.toLowerCase()}" : { "name" : "${a.charAt(0).toUpperCase() + a.slice(1)}" }`).join(',\n ')} 54 | } 55 | }`, 56 | ) 57 | .join('\n\n')} 58 | 59 | ${roleKeys 60 | .map( 61 | r => `resource "permitio_role" "${r.key}" { 62 | key = "${r.key}" 63 | name = "${r.name}" 64 | description = "${r.name} role" 65 | permissions = [ 66 | ${r.permissions 67 | .flatMap(p => p.actions.map(a => `"${p.resource.toLowerCase()}:${a}"`)) 68 | .join(',\n ')} 69 | ] 70 | depends_on = [ 71 | ${r.permissions 72 | .map( 73 | p => 74 | `permitio_resource.${p.resource.toLowerCase().replace(/\s+/g, '_')}`, 75 | ) 76 | .join(',\n ')} 77 | ] 78 | }`, 79 | ) 80 | .join('\n\n')} 81 | `; 82 | 83 | onTerraformGenerated(terraform); 84 | }; 85 | -------------------------------------------------------------------------------- /source/hooks/useOrganisationApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { components } from '../lib/api/v1.js'; 3 | import useClient from './useClient.js'; 4 | // import { authenticatedApiClient } from '../lib/api.js'; 5 | 6 | export interface UsageLimits { 7 | mau: number; 8 | tenants: number; 9 | billing_tier: string; 10 | } 11 | 12 | export type OrganizationCreate = components['schemas']['OrganizationCreate']; 13 | export type OrganizationReadWithAPIKey = 14 | components['schemas']['OrganizationReadWithAPIKey']; 15 | 16 | export type Settings = object; 17 | 18 | export const useOrganisationApi = () => { 19 | const { authenticatedApiClient, unAuthenticatedApiClient } = useClient(); 20 | 21 | const getOrgs = useCallback( 22 | async (accessToken?: string, cookie?: string | null) => { 23 | return accessToken || cookie 24 | ? await unAuthenticatedApiClient(accessToken, cookie).GET('/v2/orgs') 25 | : await authenticatedApiClient().GET('/v2/orgs'); 26 | }, 27 | [authenticatedApiClient, unAuthenticatedApiClient], 28 | ); 29 | 30 | const getOrg = useCallback( 31 | async (org_id: string, accessToken?: string, cookie?: string | null) => { 32 | return accessToken || cookie 33 | ? await unAuthenticatedApiClient(accessToken, cookie).GET( 34 | `/v2/orgs/{org_id}`, 35 | { 36 | org_id: org_id, 37 | }, 38 | ) 39 | : await authenticatedApiClient().GET(`/v2/orgs/{org_id}`, { 40 | org_id, 41 | }); 42 | }, 43 | [authenticatedApiClient, unAuthenticatedApiClient], 44 | ); 45 | 46 | const createOrg = useCallback( 47 | async ( 48 | body: OrganizationCreate, 49 | accessToken?: string, 50 | cookie?: string | null, 51 | ) => { 52 | return accessToken || cookie 53 | ? await unAuthenticatedApiClient(accessToken, cookie).POST( 54 | `/v2/orgs`, 55 | undefined, 56 | body, 57 | ) 58 | : await authenticatedApiClient().POST( 59 | `/v2/orgs`, 60 | undefined, 61 | body, 62 | undefined, 63 | ); 64 | }, 65 | [authenticatedApiClient, unAuthenticatedApiClient], 66 | ); 67 | 68 | return useMemo( 69 | () => ({ 70 | getOrgs, 71 | getOrg, 72 | createOrg, 73 | }), 74 | [createOrg, getOrg, getOrgs], 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /source/hooks/useParseRoles.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { components } from '../lib/api/v1.js'; 3 | 4 | /** 5 | * Parses role strings in the format: 6 | * "role|resource:action|resource:action" or "role|resource" 7 | * If availableActions is provided, expands resource-only permissions to all actions. 8 | */ 9 | export function useParseRoles( 10 | roleStrings?: string[], 11 | availableActions?: string[], 12 | ): components['schemas']['RoleCreate'][] { 13 | return useMemo(() => { 14 | if (!roleStrings || roleStrings.length === 0) return []; 15 | 16 | try { 17 | return roleStrings.map(roleStr => { 18 | const trimmed = roleStr.trim(); 19 | if (!trimmed) throw new Error('Invalid role format'); 20 | 21 | const [roleKey, ...permParts] = trimmed.split('|').map(s => s.trim()); 22 | if (!roleKey || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(roleKey)) { 23 | throw new Error(`Invalid role key in: ${roleStr}`); 24 | } 25 | if (permParts.length === 0) { 26 | throw new Error( 27 | `Role must have at least one resource or resource:action in: ${roleStr}`, 28 | ); 29 | } 30 | 31 | const permissions: string[] = []; 32 | for (const perm of permParts) { 33 | if (!perm) continue; 34 | const [resource, action] = perm.split(':').map(s => s.trim()); 35 | if (!resource) 36 | throw new Error(`Invalid resource in permission: ${perm}`); 37 | if (!action) { 38 | // Expand to all actions if availableActions is provided 39 | if (availableActions && availableActions.length > 0) { 40 | permissions.push( 41 | ...availableActions.map(a => `${resource}:${a}`), 42 | ); 43 | } else { 44 | permissions.push(resource); // fallback: just resource 45 | } 46 | } else { 47 | permissions.push(`${resource}:${action}`); 48 | } 49 | } 50 | 51 | return { 52 | key: roleKey, 53 | name: roleKey, 54 | permissions, 55 | }; 56 | }); 57 | } catch (err) { 58 | throw new Error( 59 | `Invalid role format. Expected ["role|resource:action|resource:action"], got ${JSON.stringify( 60 | roleStrings, 61 | )}. ${err instanceof Error ? err.message : err}`, 62 | ); 63 | } 64 | }, [roleStrings, availableActions]); 65 | } 66 | -------------------------------------------------------------------------------- /tests/EnvDelete.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { describe, vi, expect, it, beforeEach } from 'vitest'; 4 | import Delete from '../source/commands/env/delete'; 5 | import * as AuthProvider from '../source/components/AuthProvider'; 6 | import * as DeleteComponent from '../source/components/env/DeleteComponent'; 7 | 8 | // Mock the components 9 | vi.mock('../source/components/AuthProvider', () => ({ 10 | AuthProvider: vi.fn(({ children }) => children), 11 | })); 12 | 13 | vi.mock('../source/components/env/DeleteComponent', () => ({ 14 | default: vi.fn(() =>
Mocked DeleteComponent
), 15 | })); 16 | 17 | describe('env delete command', () => { 18 | beforeEach(() => { 19 | vi.clearAllMocks(); 20 | }); 21 | 22 | it('renders with default options', () => { 23 | const options = { 24 | apiKey: undefined, 25 | envId: undefined, 26 | force: false, 27 | }; 28 | 29 | render(); 30 | 31 | // Check AuthProvider was called with correct props 32 | expect(AuthProvider.AuthProvider).toHaveBeenCalledWith( 33 | expect.objectContaining({ 34 | permit_key: undefined, 35 | scope: 'project', 36 | }), 37 | expect.anything(), 38 | ); 39 | 40 | // Check DeleteComponent was called with correct props 41 | expect(DeleteComponent.default).toHaveBeenNthCalledWith( 42 | 1, 43 | { 44 | environmentId: undefined, 45 | force: false, 46 | }, 47 | expect.anything(), 48 | ); 49 | }); 50 | 51 | it('passes options correctly to DeleteComponent', () => { 52 | const options = { 53 | apiKey: 'test-key', 54 | envId: 'env456', 55 | force: true, 56 | }; 57 | 58 | render(); 59 | 60 | // Check AuthProvider was called with correct props 61 | expect(AuthProvider.AuthProvider).toHaveBeenCalledWith( 62 | expect.objectContaining({ 63 | permit_key: 'test-key', 64 | scope: 'project', 65 | }), 66 | expect.anything(), 67 | ); 68 | 69 | // Check DeleteComponent was called with correct props 70 | expect(DeleteComponent.default).toHaveBeenNthCalledWith( 71 | 1, 72 | { 73 | environmentId: 'env456', 74 | force: true, 75 | }, 76 | expect.anything(), 77 | ); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /source/components/test/GeneratePolicySnapshot.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Newline, Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | import { useGeneratePolicySnapshot } from './hooks/usePolicySnapshot.js'; 5 | import { CodeSampleComponent } from './code-samples/CodeSampleComponent.js'; 6 | 7 | export type GeneratePolicySnapshotProps = { 8 | dryRun: boolean; 9 | models: string[]; 10 | path?: string; 11 | isTestTenant?: boolean; 12 | snippet?: 'jest' | 'pytest' | 'vitest'; 13 | snippetPath?: string; 14 | }; 15 | 16 | export function GeneratePolicySnapshot({ 17 | dryRun, 18 | models, 19 | path, 20 | snippet, 21 | }: GeneratePolicySnapshotProps) { 22 | const filePath = snippet && !path ? 'authz-test.json' : path; 23 | const { state, error, roles, tenantId, finalConfig, dryUsers } = 24 | useGeneratePolicySnapshot({ dryRun, models, path: filePath }); 25 | 26 | // Handle Error and lifecycle completion. 27 | useEffect(() => { 28 | if (error || (state === 'done' && !snippet)) { 29 | setTimeout(() => { 30 | process.exit(1); 31 | }, 1000); 32 | } 33 | }, [error, snippet, state]); 34 | return ( 35 | <> 36 | {state === 'roles' && Getting all roles} 37 | {roles.length > 0 && Roles found: {roles.length}} 38 | {state === 'rbac-tenant' && Crating a new Tenant} 39 | {tenantId && Created a new test tenant: {tenantId}} 40 | {state === 'rbac-generate' && ( 41 | 42 | Generating test data for you {' '} 43 | 44 | )} 45 | {dryRun && Dry run mode!} 46 | {state === 'done' && filePath && Config saved to {filePath}} 47 | {state === 'done' && !filePath && ( 48 | 49 | {' '} 50 | {JSON.stringify( 51 | dryRun 52 | ? { users: dryUsers, config: finalConfig } 53 | : { config: finalConfig }, 54 | )}{' '} 55 | 56 | )} 57 | {state === 'done' && snippet && ( 58 | <> 59 | 60 | 65 | 66 | )} 67 | {error && {error}} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /source/components/test/code-samples/CodeSampleComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { useAuth } from '../../AuthProvider.js'; 3 | import { Text } from 'ink'; 4 | import { 5 | generateJestSample, 6 | generatePytestSample, 7 | generateVitestSample, 8 | } from '../../../utils/codeSnippets.js'; 9 | import { saveFile } from '../../../utils/fileSaver.js'; 10 | import Spinner from 'ink-spinner'; 11 | 12 | type frameworkProps = { 13 | framework: 'jest' | 'pytest' | 'vitest'; 14 | configPath?: string; 15 | path?: string; 16 | pdpUrl?: string; 17 | }; 18 | 19 | export function CodeSampleComponent({ 20 | framework, 21 | configPath, 22 | path, 23 | pdpUrl, 24 | }: frameworkProps) { 25 | const [code, setCode] = useState(undefined); 26 | const [state, setState] = useState<'loading' | 'done'>('loading'); 27 | const [error, setError] = useState(undefined); 28 | const auth = useAuth(); 29 | 30 | useEffect(() => { 31 | if (error || state === 'done') { 32 | process.exit(1); 33 | } 34 | }, [error, state]); 35 | 36 | useEffect(() => { 37 | if (auth.loading) return; 38 | if (framework === 'jest') { 39 | setCode(generateJestSample(pdpUrl, configPath, auth.authToken)); 40 | } else if (framework === 'pytest') { 41 | setCode(generatePytestSample(pdpUrl, configPath, auth.authToken)); 42 | } else if (framework === 'vitest') { 43 | setCode(generateVitestSample(pdpUrl, configPath, auth.authToken)); 44 | } 45 | if (!path) { 46 | setState('done'); 47 | } 48 | }, [auth, framework, configPath, path, pdpUrl, state]); 49 | 50 | const saveCodeTOPath = useCallback(async () => { 51 | const { error } = await saveFile(path ?? '', code ?? ''); 52 | if (error) { 53 | setError(error); 54 | } 55 | setState('done'); 56 | }, [code, path]); 57 | 58 | useEffect(() => { 59 | if (code && path) { 60 | saveCodeTOPath(); 61 | } else if (code) { 62 | setState('done'); 63 | } 64 | }, [code, path, saveCodeTOPath]); 65 | 66 | return ( 67 | <> 68 | {state === 'loading' && ( 69 | 70 | Building 71 | 72 | )} 73 | {path && state === 'done' && Code Sample saved to {path}} 74 | {state === 'done' && !path && {code}} 75 | {error && {error}} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /tests/EnvTemplateList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import List from '../source/commands/env/template/list.js'; 4 | import { vi, describe, it, expect } from 'vitest'; 5 | import delay from 'delay'; 6 | import { useApiKeyApi } from '../source/hooks/useApiKeyApi'; 7 | 8 | const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); 9 | vi.mock('keytar', () => { 10 | const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); 11 | const keytar = { 12 | setPassword: vi.fn().mockResolvedValue(demoPermitKey), 13 | getPassword: vi.fn().mockResolvedValue(demoPermitKey), 14 | deletePassword: vi.fn().mockResolvedValue(demoPermitKey), 15 | }; 16 | return { ...keytar, default: keytar }; 17 | }); 18 | 19 | vi.mock('../source/lib/auth.js', async () => { 20 | const original = await vi.importActual('../source/lib/auth.js'); 21 | return { 22 | ...original, 23 | loadAuthToken: vi.fn(() => demoPermitKey), 24 | }; 25 | }); 26 | 27 | vi.mock('../source/hooks/useEnvironmentApi.js', () => ({ 28 | useEnvironmentApi: vi.fn(), 29 | })); 30 | 31 | vi.mock('../source/hooks/useApiKeyApi', async () => { 32 | const original = await vi.importActual('../source/hooks/useApiKeyApi'); 33 | 34 | return { 35 | ...original, 36 | useApiKeyApi: () => ({ 37 | getApiKeyScope: vi.fn().mockResolvedValue({ 38 | response: { 39 | environment_id: 'env1', 40 | project_id: 'proj1', 41 | organization_id: 'org1', 42 | }, 43 | error: null, 44 | status: 200, 45 | }), 46 | getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ 47 | response: { secret: 'test-secret' }, 48 | error: null, 49 | }), 50 | validateApiKeyScope: vi.fn().mockResolvedValue({ 51 | valid: true, 52 | scope: { 53 | environment_id: 'env1', 54 | project_id: 'proj1', 55 | organization_id: 'org1', 56 | }, 57 | error: null, 58 | }), 59 | }), 60 | }; 61 | }); 62 | 63 | describe('Apply Command', () => { 64 | it('Should display the values', async () => { 65 | const { stdout } = render( 66 | , 67 | ); 68 | await delay(50); 69 | expect(stdout.lastFrame()).contains('Templates List'); 70 | expect(stdout.lastFrame()).contains('• fga-tradeoffs'); 71 | expect(stdout.lastFrame()).contains('• mesa-verde-banking-demo'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /source/components/env/openapi/OpenapiComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import OpenapiForm from './OpenapiForm.js'; 3 | import OpenapiResults from './OpenapiResults.js'; 4 | import { useOpenapiProcessor } from '../../../hooks/openapi/useOpenapiProcessor.js'; 5 | 6 | interface OpenapiComponentProps { 7 | specFile?: string; 8 | } 9 | 10 | /** 11 | * Main component for the OpenAPI spec processing 12 | * 13 | * This component coordinates the form, processing, and results display. 14 | */ 15 | export default function OpenapiComponent({ 16 | specFile, 17 | }: OpenapiComponentProps): React.ReactElement { 18 | // State management 19 | const [status, setStatus] = useState< 20 | 'init' | 'loading' | 'error' | 'success' 21 | >('init'); 22 | const [inputPath, setInputPath] = useState(specFile || ''); 23 | const [error, setError] = useState(null); 24 | const [progress, setProgress] = useState(''); 25 | const [processingDone, setProcessingDone] = useState(false); 26 | 27 | // Get the processor hook 28 | const { processSpec } = useOpenapiProcessor({ 29 | inputPath, 30 | setProgress, 31 | setStatus, 32 | setError, 33 | setProcessingDone, 34 | }); 35 | 36 | // Handle form submission 37 | const handleSubmit = () => { 38 | if (!inputPath) { 39 | setError('Please enter a valid file path or URL'); 40 | return; 41 | } 42 | 43 | setStatus('loading'); 44 | setProgress('Starting to process OpenAPI spec...'); 45 | 46 | // Process the OpenAPI spec 47 | processSpec(); 48 | }; 49 | 50 | // Run processing when specFile is provided initially 51 | useEffect(() => { 52 | if (specFile && status === 'init') { 53 | setInputPath(specFile); 54 | setStatus('loading'); 55 | setProgress('Starting to process OpenAPI spec...'); 56 | processSpec(); 57 | } 58 | }, [specFile, status, processSpec]); 59 | 60 | // Render the appropriate component based on the status 61 | if (status === 'init') { 62 | return ( 63 | 68 | ); 69 | } 70 | 71 | // For all other states, show the results component 72 | return ( 73 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /tests/utils/openapiUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { sanitizeKey, isDuplicateError } from '../../source/utils/openapiUtils'; 3 | 4 | describe('openapiUtils', () => { 5 | describe('sanitizeKey', () => { 6 | it('should replace colons with underscores', () => { 7 | expect(sanitizeKey('user:admin')).toBe('user_admin'); 8 | }); 9 | 10 | it('should handle multiple colons', () => { 11 | expect(sanitizeKey('org:project:resource')).toBe('org_project_resource'); 12 | }); 13 | 14 | it('should leave other characters unchanged', () => { 15 | expect(sanitizeKey('resource-123_abc')).toBe('resource-123_abc'); 16 | }); 17 | 18 | it('should handle empty strings', () => { 19 | expect(sanitizeKey('')).toBe(''); 20 | }); 21 | }); 22 | 23 | describe('isDuplicateError', () => { 24 | it('should identify string errors containing DUPLICATE_ENTITY', () => { 25 | expect(isDuplicateError('Error: DUPLICATE_ENTITY')).toBe(true); 26 | }); 27 | 28 | it('should identify string errors containing "already exists"', () => { 29 | expect(isDuplicateError('The resource already exists')).toBe(true); 30 | }); 31 | 32 | it('should identify object errors with error_code DUPLICATE_ENTITY', () => { 33 | const error = { 34 | error_code: 'DUPLICATE_ENTITY', 35 | message: 'Entity already exists', 36 | }; 37 | expect(isDuplicateError(error)).toBe(true); 38 | }); 39 | 40 | it('should identify JSON string errors with error_code DUPLICATE_ENTITY', () => { 41 | const errorStr = JSON.stringify({ error_code: 'DUPLICATE_ENTITY' }); 42 | expect(isDuplicateError(errorStr)).toBe(true); 43 | }); 44 | 45 | it('should identify JSON string errors with title containing "already exists"', () => { 46 | const errorStr = JSON.stringify({ title: 'This role already exists' }); 47 | expect(isDuplicateError(errorStr)).toBe(true); 48 | }); 49 | 50 | it('should return false for non-duplicate errors', () => { 51 | expect(isDuplicateError('Not found error')).toBe(false); 52 | expect(isDuplicateError({ error_code: 'NOT_FOUND' })).toBe(false); 53 | expect(isDuplicateError(null)).toBe(false); 54 | expect(isDuplicateError(undefined)).toBe(false); 55 | }); 56 | 57 | it('should handle invalid JSON strings gracefully', () => { 58 | expect(isDuplicateError('{not-valid-json}')).toBe(false); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /source/commands/pdp/check-url.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import zod from 'zod'; 3 | import { option } from 'pastel'; 4 | import { AuthProvider } from '../../components/AuthProvider.js'; 5 | import PDPCheckUrlComponent from '../../components/pdp/PDPCheckUrlComponent.js'; 6 | 7 | export const description = 'Check if a user has permission to access a URL'; 8 | 9 | export const options = zod.object({ 10 | user: zod 11 | .string() 12 | .min(1, 'User identifier cannot be empty') 13 | .describe( 14 | option({ 15 | description: 'Unique Identity to check for (Required)', 16 | alias: 'u', 17 | }), 18 | ), 19 | url: zod 20 | .string() 21 | .min(1, 'URL cannot be empty') 22 | .describe( 23 | option({ 24 | description: 'URL to check permissions for (Required)', 25 | }), 26 | ), 27 | method: zod 28 | .string() 29 | .optional() 30 | .default('GET') 31 | .describe( 32 | option({ 33 | description: 'HTTP method (Optional, defaults to "GET")', 34 | alias: 'm', 35 | }), 36 | ), 37 | tenant: zod 38 | .string() 39 | .optional() 40 | .default('default') 41 | .describe( 42 | option({ 43 | description: 44 | 'The tenant the resource belongs to (Optional, defaults to "default")', 45 | alias: 't', 46 | }), 47 | ), 48 | userAttributes: zod 49 | .array(zod.string()) 50 | .optional() 51 | .describe( 52 | option({ 53 | description: 54 | 'User attributes in format key1:value1,key2:value2 (Optional, can be specified multiple times)', 55 | alias: 'ua', 56 | }), 57 | ), 58 | 'pdp-url': zod 59 | .string() 60 | .optional() 61 | .default('http://localhost:7766') 62 | .describe( 63 | option({ 64 | description: 65 | 'The URL of the PDP service. Defaults to http://localhost:7766. (Optional)', 66 | }), 67 | ), 68 | apiKey: zod 69 | .string() 70 | .optional() 71 | .describe( 72 | option({ 73 | description: 74 | 'The API key for the Permit env, project or Workspace (Optional)', 75 | alias: 'k', 76 | }), 77 | ), 78 | }); 79 | 80 | export type PDPCheckUrlProps = { 81 | options: zod.infer; 82 | }; 83 | 84 | export default function CheckUrl({ options }: PDPCheckUrlProps) { 85 | return ( 86 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /source/hooks/openapi/usePermitResourceApi.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import useClient from '../../hooks/useClient.js'; 3 | 4 | /** 5 | * Hook for resource-related Permit API operations 6 | */ 7 | export const usePermitResourceApi = () => { 8 | const { authenticatedApiClient } = useClient(); 9 | 10 | /** 11 | * List all resources in the current environment 12 | */ 13 | const listResources = useCallback(async () => { 14 | return await authenticatedApiClient().GET( 15 | '/v2/schema/{proj_id}/{env_id}/resources', 16 | ); 17 | }, [authenticatedApiClient]); 18 | 19 | /** 20 | * Creates a new resource in Permit 21 | */ 22 | const createResource = useCallback( 23 | async (resourceKey: string, resourceName: string) => { 24 | return await authenticatedApiClient().POST( 25 | '/v2/schema/{proj_id}/{env_id}/resources', 26 | undefined, 27 | { 28 | key: resourceKey, 29 | name: resourceName, 30 | description: `Resource created from OpenAPI spec`, 31 | actions: {}, 32 | attributes: {}, 33 | }, 34 | ); 35 | }, 36 | [authenticatedApiClient], 37 | ); 38 | 39 | /** 40 | * Updates an existing resource in Permit 41 | */ 42 | const updateResource = useCallback( 43 | async (resourceKey: string, resourceName: string) => { 44 | return await authenticatedApiClient().PATCH( 45 | '/v2/schema/{proj_id}/{env_id}/resources/{resource_id}', 46 | { resource_id: resourceKey }, 47 | { 48 | name: resourceName, 49 | description: `Resource updated from OpenAPI spec`, 50 | }, 51 | ); 52 | }, 53 | [authenticatedApiClient], 54 | ); 55 | 56 | /** 57 | * Creates a new action for a resource 58 | */ 59 | const createAction = useCallback( 60 | async (resourceKey: string, actionKey: string, actionName: string) => { 61 | return await authenticatedApiClient().POST( 62 | '/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/actions', 63 | { resource_id: resourceKey }, 64 | { 65 | key: actionKey, 66 | name: actionName, 67 | description: `Action created from OpenAPI spec`, 68 | }, 69 | ); 70 | }, 71 | [authenticatedApiClient], 72 | ); 73 | 74 | return useMemo( 75 | () => ({ 76 | listResources, 77 | createResource, 78 | updateResource, 79 | createAction, 80 | }), 81 | [listResources, createResource, updateResource, createAction], 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /source/components/init/EnforceComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import SelectInput from 'ink-select-input'; 3 | import { Text, Box } from 'ink'; 4 | import Spinner from 'ink-spinner'; 5 | import PDPRunComponent from '../pdp/PDPRunComponent.js'; 6 | 7 | type Props = { 8 | onComplete: () => void; 9 | onError: (error: string) => void; 10 | }; 11 | 12 | export default function EnforceComponent({ onComplete, onError }: Props) { 13 | const [error, setError] = useState(null); 14 | const [step, setStep] = useState< 15 | 'initial' | 'done' | 'error' | 'run' | 'show' | 'processing' 16 | >('initial'); 17 | useEffect(() => { 18 | if (error) { 19 | onError(error); 20 | } 21 | if (step === 'done') { 22 | onComplete(); 23 | } 24 | }, [error, onError, step, onComplete]); 25 | 26 | if (step === 'initial') { 27 | return ( 28 | 29 | Enforce: 30 | { 42 | if (item.value === 'run') { 43 | setStep('run'); 44 | } else if (item.value === 'show') { 45 | setStep('show'); 46 | } 47 | }} 48 | /> 49 | 50 | ); 51 | } 52 | 53 | if (step === 'run') { 54 | return ( 55 | 56 | Running PDP... 57 | { 60 | setStep('done'); 61 | }} 62 | onError={error => { 63 | setError(error); 64 | setStep('error'); 65 | }} 66 | /> 67 | 68 | ); 69 | } 70 | if (step === 'show') { 71 | return ( 72 | 73 | { 76 | setStep('done'); 77 | }} 78 | skipWaitScreen={false} 79 | onError={error => { 80 | setError(error); 81 | setStep('error'); 82 | }} 83 | /> 84 | 85 | ); 86 | } 87 | if (step === 'processing') { 88 | return ( 89 | 90 | 91 | 92 | Processing... 93 | 94 | 95 | ); 96 | } 97 | return null; 98 | } 99 | -------------------------------------------------------------------------------- /source/commands/env/copy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../components/AuthProvider.js'; 6 | import CopyComponent from '../../components/env/CopyComponent.js'; 7 | 8 | export const options = zod.object({ 9 | apiKey: zod 10 | .string() 11 | .optional() 12 | .describe( 13 | option({ 14 | description: 15 | 'Optional: API Key to be used for the environment copying (should be at least a project level key). In case not set, CLI lets you select one', 16 | }), 17 | ), 18 | from: zod 19 | .string() 20 | .optional() 21 | .describe( 22 | option({ 23 | description: 24 | 'Optional: Set the environment ID to copy from. In case not set, the CLI lets you select one.', 25 | }), 26 | ), 27 | name: zod 28 | .string() 29 | .optional() 30 | .describe( 31 | option({ 32 | description: 33 | 'Optional: Set the name of the new environment. In case not set, the CLI will prompt you to enter one.', 34 | }), 35 | ), 36 | description: zod 37 | .string() 38 | .optional() 39 | .describe( 40 | option({ 41 | description: 42 | 'Optional: The new environment description. In case not set, the CLI will ask you for it.', 43 | }), 44 | ), 45 | to: zod 46 | .string() 47 | .optional() 48 | .describe( 49 | option({ 50 | description: 51 | "Optional: Copy the environment to an existing environment. In case this variable is set, the 'name' and 'description' variables will be ignored.", 52 | }), 53 | ), 54 | conflictStrategy: zod 55 | .enum(['fail', 'overwrite']) 56 | .default('fail') 57 | .optional() 58 | .describe( 59 | option({ 60 | description: 61 | "Optional: Set the environment conflict strategy. In case not set, will use 'fail'.", 62 | }), 63 | ), 64 | }); 65 | 66 | type Props = { 67 | readonly options: zInfer; 68 | }; 69 | 70 | export default function Copy({ 71 | options: { apiKey, from, to, name, description, conflictStrategy }, 72 | }: Props) { 73 | return ( 74 | <> 75 | 76 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /tests/hooks/useMemberAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import { useMemberApi } from '../../source/hooks/useMemberApi.js'; 2 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 3 | import React from 'react'; 4 | import { render } from 'ink-testing-library'; 5 | import { Text } from 'ink'; 6 | import { getMockFetchResponse } from '../utils.js'; 7 | 8 | global.fetch = vi.fn(); 9 | 10 | describe('useMemberApi', () => { 11 | beforeEach(() => { 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | it('should invite a new member successfully', async () => { 16 | const TestComponent = () => { 17 | const { inviteNewMember } = useMemberApi(); 18 | const body = { 19 | email: 'newmember@example.com', 20 | role: 'member', 21 | }; 22 | 23 | (fetch as any).mockResolvedValueOnce({ 24 | ...getMockFetchResponse(), 25 | json: async () => ({ success: true }), 26 | }); 27 | 28 | const inviteMember = async () => { 29 | const { data: result } = await inviteNewMember( 30 | body, 31 | 'dummy_name', 32 | 'dummy_email', 33 | ); 34 | return result ? 'Member invited' : 'Failed to invite member'; 35 | }; 36 | const [result, setResult] = React.useState(null); 37 | inviteMember().then(res => setResult(res)); 38 | 39 | return {result}; 40 | }; 41 | 42 | const { lastFrame } = render(); 43 | await vi.waitFor(() => { 44 | expect(lastFrame()).toBe('Member invited'); 45 | }); 46 | }); 47 | 48 | it('should handle failed member invitation', async () => { 49 | const TestComponent = () => { 50 | const { inviteNewMember } = useMemberApi(); 51 | const body = { 52 | email: 'newmember@example.com', 53 | role: 'member', 54 | }; 55 | 56 | (fetch as any).mockResolvedValueOnce({ 57 | ...getMockFetchResponse(), 58 | json: async () => undefined, 59 | }); 60 | 61 | const inviteMember = async () => { 62 | const { data: result } = await inviteNewMember( 63 | body, 64 | 'dummy_name', 65 | 'dummy_email', 66 | ); 67 | return result ? 'Member invited' : 'Failed to invite member'; 68 | }; 69 | const [result, setResult] = React.useState(null); 70 | inviteMember().then(res => setResult(res)); 71 | 72 | return {result}; 73 | }; 74 | 75 | const { lastFrame } = render(); 76 | await vi.waitFor(() => { 77 | expect(lastFrame()).toBe('Failed to invite member'); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /source/components/GraphCommands.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'ink'; 3 | import Spinner from 'ink-spinner'; 4 | import { useAuth } from '../components/AuthProvider.js'; 5 | import { useGraphDataApi } from '../hooks/useGraphDataApi.js'; 6 | import { saveHTMLGraph } from './HtmlGraphSaver.js'; 7 | 8 | export default function Graph() { 9 | const { scope, loading: authLoading, error: authError } = useAuth(); 10 | const [loading, setLoading] = useState(false); 11 | const [error, setError] = useState(null); 12 | const [noData, setNoData] = useState(false); 13 | const { fetchGraphData } = useGraphDataApi(); 14 | 15 | useEffect(() => { 16 | if (!scope.project_id || !scope.environment_id) { 17 | setError( 18 | 'Required project or environment not found in auth context. Please ensure you are logged in with the proper scope.', 19 | ); 20 | } 21 | }, [scope.project_id, scope.environment_id]); 22 | 23 | useEffect(() => { 24 | const fetchData = async () => { 25 | if (!scope.project_id || !scope.environment_id) return; 26 | try { 27 | setLoading(true); 28 | const { data: graphData, error: fetchError } = await fetchGraphData( 29 | scope.project_id, 30 | scope.environment_id, 31 | ); 32 | if (fetchError) { 33 | setError('Failed to fetch data. Check network or auth token.'); 34 | setLoading(false); 35 | return; 36 | } 37 | if (!graphData || graphData.nodes.length === 0) { 38 | setNoData(true); 39 | setLoading(false); 40 | return; 41 | } 42 | saveHTMLGraph(graphData); 43 | setLoading(false); 44 | } catch (err) { 45 | console.error('Error fetching graph data:', err); 46 | setError('Failed to fetch data. Check network or auth token.'); 47 | setLoading(false); 48 | } 49 | }; 50 | 51 | fetchData(); 52 | }, [scope.project_id, scope.environment_id, fetchGraphData]); 53 | 54 | // Render loading, error, or no data states. 55 | if (authLoading || loading) { 56 | return ( 57 | 58 | {' '} 59 | {authLoading ? 'Authenticating...' : 'Loading Permit Graph...'} 60 | 61 | ); 62 | } 63 | 64 | if (authError || error) { 65 | return {authError || error}; 66 | } 67 | 68 | if (noData) { 69 | return Environment does not contain any data; 70 | } 71 | 72 | return Graph generated successfully and saved as HTML!; 73 | } 74 | -------------------------------------------------------------------------------- /source/commands/pdp/check.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import zod, { string } from 'zod'; 3 | import { option } from 'pastel'; 4 | import PDPCheckComponent from '../../components/pdp/PDPCheckComponent.js'; 5 | import { AuthProvider } from '../../components/AuthProvider.js'; 6 | 7 | export const options = zod.object({ 8 | user: zod 9 | .string() 10 | .min(1, 'User identifier cannot be empty') 11 | .describe( 12 | option({ 13 | description: 'Unique Identity to check for (Required)', 14 | alias: 'u', 15 | }), 16 | ), 17 | userAttributes: zod 18 | .string() 19 | .optional() 20 | .describe( 21 | option({ 22 | description: 23 | 'User attributes in format key1:value1,key2:value2 (Optional)', 24 | alias: 'ua', 25 | }), 26 | ), 27 | resource: zod 28 | .string() 29 | .min(1, 'Resource cannot be empty') 30 | .describe( 31 | option({ 32 | description: 'Resource being accessed (Required)', 33 | alias: 'r', 34 | }), 35 | ), 36 | resourceAttributes: zod 37 | .string() 38 | .optional() 39 | .describe( 40 | option({ 41 | description: 42 | 'Resource attributes in format key1:value1,key2:value2 (Optional)', 43 | alias: 'ra', 44 | }), 45 | ), 46 | action: zod 47 | .string() 48 | .min(1, 'Action cannot be empty') 49 | .describe( 50 | option({ 51 | description: 52 | 'Action being performed on the resource by the user (Required)', 53 | alias: 'a', 54 | }), 55 | ), 56 | tenant: zod 57 | .string() 58 | .optional() 59 | .default('default') 60 | .describe( 61 | option({ 62 | description: 63 | 'The tenant the resource belongs to (Optional, defaults to "default")', 64 | alias: 't', 65 | }), 66 | ), 67 | pdpurl: string() 68 | .optional() 69 | .describe( 70 | option({ 71 | description: 72 | 'The URL of the PDP service. Default to the cloud PDP. (Optional)', 73 | }), 74 | ), 75 | apiKey: zod 76 | .string() 77 | .optional() 78 | .describe( 79 | option({ 80 | description: 81 | 'The API key for the Permit env, project or Workspace (Optional)', 82 | }), 83 | ), 84 | }); 85 | 86 | export type PDPCheckProps = { 87 | options: zod.infer; 88 | }; 89 | 90 | export default function Check({ options }: PDPCheckProps) { 91 | return ( 92 | <> 93 | 94 | 95 | 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /tests/EnvCreate.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { describe, vi, expect, it, beforeEach } from 'vitest'; 4 | import Create from '../source/commands/env/create'; 5 | import * as AuthProvider from '../source/components/AuthProvider'; 6 | import * as CreateEnvComponent from '../source/components/env/CreateEnvComponent'; 7 | 8 | // Mock the components 9 | vi.mock('../source/components/AuthProvider', () => ({ 10 | AuthProvider: vi.fn(({ children }) => children), 11 | })); 12 | 13 | vi.mock('../source/components/env/CreateEnvComponent', () => ({ 14 | default: vi.fn(() =>
Mocked CreateEnvComponent
), 15 | })); 16 | 17 | describe('env create command', () => { 18 | beforeEach(() => { 19 | vi.clearAllMocks(); 20 | }); 21 | 22 | it('renders with default options', () => { 23 | const options = { 24 | apiKey: undefined, 25 | name: undefined, 26 | envKey: undefined, 27 | description: undefined, 28 | }; 29 | 30 | render(); 31 | 32 | // Check AuthProvider was called with correct props 33 | expect(AuthProvider.AuthProvider).toHaveBeenCalledWith( 34 | expect.objectContaining({ 35 | permit_key: undefined, 36 | scope: 'project', 37 | }), 38 | expect.anything(), 39 | ); 40 | 41 | // Check CreateEnvComponent was called with the right first argument 42 | expect(CreateEnvComponent.default).toHaveBeenNthCalledWith( 43 | 1, 44 | { 45 | name: undefined, 46 | envKey: undefined, 47 | description: undefined, 48 | }, 49 | expect.anything(), 50 | ); 51 | }); 52 | 53 | it('passes options correctly to CreateEnvComponent', () => { 54 | const options = { 55 | apiKey: 'test-key', 56 | name: 'Test Environment', 57 | envKey: 'test_env', 58 | description: 'Test description', 59 | }; 60 | 61 | render(); 62 | 63 | // Check AuthProvider was called with correct props 64 | expect(AuthProvider.AuthProvider).toHaveBeenCalledWith( 65 | expect.objectContaining({ 66 | permit_key: 'test-key', 67 | scope: 'project', 68 | }), 69 | expect.anything(), 70 | ); 71 | 72 | // Check CreateEnvComponent was called with the right first argument 73 | expect(CreateEnvComponent.default).toHaveBeenNthCalledWith( 74 | 1, 75 | { 76 | name: 'Test Environment', 77 | envKey: 'test_env', 78 | description: 'Test description', 79 | }, 80 | expect.anything(), 81 | ); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /source/commands/env/create.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | import zod from 'zod'; 4 | import { type infer as zInfer } from 'zod'; 5 | import { AuthProvider } from '../../components/AuthProvider.js'; 6 | import CreateComponent from '../../components/env/CreateEnvComponent.js'; 7 | 8 | export const description = 'Create a new Permit environment'; 9 | 10 | export const options = zod.object({ 11 | apiKey: zod 12 | .string() 13 | .optional() 14 | .describe( 15 | option({ 16 | description: 17 | 'Optional: API Key to be used for the environment creation', 18 | alias: 'k', 19 | }), 20 | ), 21 | name: zod 22 | .string() 23 | .optional() 24 | .describe( 25 | option({ 26 | description: 'Environment name', 27 | alias: 'n', 28 | }), 29 | ), 30 | envKey: zod 31 | .string() 32 | .optional() 33 | .describe( 34 | option({ 35 | description: 'Environment key identifier (slug)', 36 | alias: 'e', 37 | }), 38 | ), 39 | description: zod 40 | .string() 41 | .optional() 42 | .describe( 43 | option({ 44 | description: 'Environment description', 45 | alias: 'd', 46 | }), 47 | ), 48 | customBranchName: zod 49 | .string() 50 | .optional() 51 | .describe( 52 | option({ 53 | description: 'Custom branch name for GitOps feature', 54 | alias: 'b', 55 | }), 56 | ), 57 | jwks: zod 58 | .string() 59 | .optional() 60 | .describe( 61 | option({ 62 | description: 63 | 'JSON Web Key Set (JWKS) for frontend login, in JSON format', 64 | alias: 'j', 65 | }), 66 | ), 67 | settings: zod 68 | .string() 69 | .optional() 70 | .describe( 71 | option({ 72 | description: 'Environment settings in JSON format', 73 | alias: 's', 74 | }), 75 | ), 76 | }); 77 | 78 | type Props = { 79 | readonly options: zInfer; 80 | }; 81 | 82 | export default function Create({ 83 | options: { 84 | apiKey, 85 | name, 86 | envKey, 87 | description, 88 | customBranchName, 89 | jwks, 90 | settings, 91 | }, 92 | }: Props) { 93 | return ( 94 | 95 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /source/components/SelectEnvironment.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'ink'; 3 | import SelectInput from 'ink-select-input'; 4 | import Spinner from 'ink-spinner'; 5 | import { ActiveState } from './EnvironmentSelection.js'; 6 | import { useEnvironmentApi } from '../hooks/useEnvironmentApi.js'; 7 | 8 | type Props = { 9 | accessToken?: string; 10 | cookie?: string | null; 11 | activeProject: ActiveState; 12 | onComplete: (environment: ActiveState) => void; 13 | onError: (error: string) => void; 14 | }; 15 | 16 | const SelectEnvironment: React.FC = ({ 17 | accessToken, 18 | cookie, 19 | onComplete, 20 | activeProject, 21 | onError, 22 | }) => { 23 | const [environments, setEnvironments] = useState([]); 24 | const [state, setState] = useState(true); 25 | 26 | const { getEnvironments } = useEnvironmentApi(); 27 | 28 | const handleEnvironmentSelect = (environment: object) => { 29 | const selectedEnv = environment as ActiveState; 30 | onComplete({ label: selectedEnv.label, value: selectedEnv.value }); 31 | }; 32 | 33 | useEffect(() => { 34 | const fetchEnvironments = async () => { 35 | const { data: environments, error } = await getEnvironments( 36 | activeProject.value, 37 | accessToken, 38 | cookie, 39 | ); 40 | 41 | if (error || !environments) { 42 | onError( 43 | `Failed to load environments for project "${activeProject.label}". Reason: ${error}. Please check your network connection or credentials and try again.`, 44 | ); 45 | return; 46 | } 47 | 48 | if (environments.length === 1 && environments[0]) { 49 | onComplete({ label: environments[0].name, value: environments[0].id }); 50 | } 51 | 52 | setEnvironments( 53 | environments.map(env => ({ label: env.name, value: env.id })), 54 | ); 55 | }; 56 | fetchEnvironments(); 57 | setState(false); 58 | }, [ 59 | accessToken, 60 | activeProject.label, 61 | activeProject.value, 62 | cookie, 63 | getEnvironments, 64 | onComplete, 65 | onError, 66 | ]); 67 | 68 | return ( 69 | <> 70 | {state && ( 71 | 72 | Loading Environments... 73 | 74 | )} 75 | {!state && ( 76 | <> 77 | Select an environment 78 | 82 | 83 | )} 84 | 85 | ); 86 | }; 87 | 88 | export default SelectEnvironment; 89 | -------------------------------------------------------------------------------- /tests/export/ConditionSetGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, beforeEach, vi } from 'vitest'; 2 | import { ConditionSetGenerator } from '../../source/commands/env/export/generators/ConditionSetGenerator.js'; 3 | import { createWarningCollector } from '../../source/commands/env/export/utils.js'; 4 | import type { Permit } from 'permitio'; 5 | 6 | describe('ConditionSetGenerator', () => { 7 | let generator: ConditionSetGenerator; 8 | let mockPermit: { api: any }; 9 | let warningCollector: ReturnType; 10 | 11 | beforeEach(() => { 12 | mockPermit = { 13 | api: { 14 | conditionSetRules: { 15 | list: vi.fn().mockResolvedValue([ 16 | { 17 | user_set: '__autogen_us_employees', 18 | resource_set: 'document_set', 19 | permission: 'document:read', 20 | }, 21 | { 22 | user_set: '__autogen_managers', 23 | resource_set: 'confidential_docs', 24 | permission: 'document:write', 25 | }, 26 | ]), 27 | }, 28 | conditionSets: { 29 | // Include the type property so that valid rules are recognized. 30 | list: vi.fn().mockResolvedValue([ 31 | { key: '__autogen_us_employees', type: 'userset' }, 32 | { key: 'document_set', type: 'resourceset' }, 33 | { key: '__autogen_managers', type: 'userset' }, 34 | { key: 'confidential_docs', type: 'resourceset' }, 35 | ]), 36 | }, 37 | }, 38 | }; 39 | warningCollector = createWarningCollector(); 40 | generator = new ConditionSetGenerator( 41 | mockPermit as unknown as Permit, 42 | warningCollector, 43 | ); 44 | }); 45 | 46 | it('generates valid HCL for condition sets', async () => { 47 | const hcl = await generator.generateHCL(); 48 | // Instead of checking for exact equality, we ensure the HCL starts with the header. 49 | expect(hcl.startsWith('\n# Condition Set Rules\n')).toBe(true); 50 | }); 51 | 52 | it('handles empty condition sets', async () => { 53 | mockPermit.api.conditionSetRules.list.mockResolvedValueOnce([]); 54 | const hcl = await generator.generateHCL(); 55 | expect(hcl).toBe(''); 56 | }); 57 | 58 | it('handles errors and adds warnings', async () => { 59 | mockPermit.api.conditionSetRules.list.mockRejectedValueOnce( 60 | new Error('API Error'), 61 | ); 62 | const hcl = await generator.generateHCL(); 63 | expect(hcl).toBe(''); 64 | expect(warningCollector.getWarnings()[0]).toContain( 65 | 'Failed to export condition set rules', 66 | ); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/components/env/openapi/OpenapiResults.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { render } from 'ink-testing-library'; 4 | import OpenapiResults from '../../../../source/components/env/openapi/OpenapiResults.js'; 5 | 6 | describe('OpenapiResults', () => { 7 | it('should show loading spinner and progress message when loading', () => { 8 | const { lastFrame } = render( 9 | , 15 | ); 16 | 17 | expect(lastFrame()).toContain('Loading OpenAPI spec...'); 18 | // Note: Spinner character might vary depending on the platform 19 | expect(lastFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); 20 | }); 21 | 22 | it('should show error message when status is error', () => { 23 | const { lastFrame } = render( 24 | , 30 | ); 31 | 32 | expect(lastFrame()).toContain('Error: Failed to parse OpenAPI spec'); 33 | expect(lastFrame()).toContain( 34 | 'Please try again with a valid OpenAPI spec file', 35 | ); 36 | }); 37 | 38 | it('should show success message when status is success', () => { 39 | const { lastFrame } = render( 40 | , 46 | ); 47 | 48 | expect(lastFrame()).toContain('OpenAPI spec successfully applied!'); 49 | expect(lastFrame()).toContain( 50 | 'Resources, actions, roles, and URL mappings have been created', 51 | ); 52 | }); 53 | 54 | it('should not show spinner when processing is done but still loading', () => { 55 | const { lastFrame } = render( 56 | , 62 | ); 63 | 64 | // Should show unexpected state message 65 | expect(lastFrame()).toContain('Unexpected state'); 66 | }); 67 | 68 | it('should show fallback message for unexpected states', () => { 69 | // @ts-ignore - Deliberately passing an invalid status 70 | const { lastFrame } = render( 71 | , 77 | ); 78 | 79 | expect(lastFrame()).toContain('Unexpected state'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/hooks/usePolicyGitReposApi.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GitConfig, 3 | usePolicyGitReposApi, 4 | } from '../../source/hooks/usePolicyGitReposApi.js'; 5 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 6 | import React from 'react'; 7 | import { render } from 'ink-testing-library'; 8 | import { Text } from 'ink'; 9 | import { getMockFetchResponse } from '../utils.js'; 10 | 11 | global.fetch = vi.fn(); 12 | 13 | describe('usePolicyGitReposApi', () => { 14 | beforeEach(() => { 15 | vi.clearAllMocks(); 16 | }); 17 | 18 | it('should fetch all repos', async () => { 19 | const TestComponent = () => { 20 | const { getRepoList } = usePolicyGitReposApi(); 21 | 22 | (fetch as any).mockResolvedValueOnce({ 23 | ...getMockFetchResponse(), 24 | json: async () => [ 25 | { status: 'active', key: 'repo1' }, 26 | { status: 'active', key: 'repo2' }, 27 | ], 28 | }); 29 | 30 | const fetchRepoList = async () => { 31 | const { data: repos } = await getRepoList('project_id'); 32 | return repos; 33 | }; 34 | const [result, setResult] = React.useState(null); 35 | fetchRepoList().then(res => 36 | setResult(res ? (res[0]?.key ?? null) : null), 37 | ); 38 | 39 | return {result}; 40 | }; 41 | 42 | const { lastFrame } = render(); 43 | await vi.waitFor(() => { 44 | expect(lastFrame()).toBe('repo1'); 45 | }); 46 | }); 47 | 48 | it('should handle failure to fetch projects', async () => { 49 | const TestComponent = () => { 50 | const { configurePermit } = usePolicyGitReposApi(); 51 | 52 | (fetch as any).mockResolvedValueOnce({ 53 | ...getMockFetchResponse(), 54 | json: async () => ({ 55 | status: 'valid', 56 | }), 57 | }); 58 | 59 | const doConfigurePermit = async () => { 60 | const { data } = await configurePermit('', { 61 | url: 'string', 62 | mainBranchName: 'string', 63 | credentials: { 64 | authType: 'ssh', 65 | username: 'string', 66 | privateKey: 'string', 67 | }, 68 | key: 'string', 69 | activateWhenValidated: true, 70 | } as GitConfig); 71 | return data; 72 | }; 73 | const [result, setResult] = React.useState(null); 74 | doConfigurePermit().then(res => setResult(res?.status ?? null)); 75 | 76 | return {result}; 77 | }; 78 | 79 | const { lastFrame } = render(); 80 | await vi.waitFor(() => { 81 | expect(lastFrame()).toBe('valid'); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /source/components/policy/ResourceInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import TextInput from 'ink-text-input'; 4 | import { components } from '../../lib/api/v1.js'; 5 | 6 | interface ResourceInputProps { 7 | onComplete: (resources: components['schemas']['ResourceCreate'][]) => void; 8 | } 9 | 10 | export const ResourceInput: React.FC = ({ onComplete }) => { 11 | const [input, setInput] = useState(''); 12 | const [validationError, setValidationError] = useState(null); 13 | const placeholder = 'Posts, Comments, Authors'; 14 | 15 | const validateResourceKey = (key: string): boolean => { 16 | return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key); 17 | }; 18 | 19 | const handleSubmit = async (value: string) => { 20 | // Clear any previous validation errors 21 | setValidationError(null); 22 | 23 | if (value.trim() === '') { 24 | setInput(placeholder); 25 | return; 26 | } 27 | try { 28 | const valueToProcess = value.trim(); 29 | const resourceKeys = valueToProcess 30 | .split(',') 31 | .map(k => k.trim()) 32 | .filter(k => k.length > 0); 33 | 34 | if (resourceKeys.length === 0) { 35 | setValidationError('Please enter at least one resource'); 36 | return; 37 | } 38 | 39 | const invalidKeys = resourceKeys.filter(key => !validateResourceKey(key)); 40 | if (invalidKeys.length > 0) { 41 | setValidationError(`Invalid resource keys: ${invalidKeys.join(', ')}`); 42 | return; 43 | } 44 | 45 | const resources: components['schemas']['ResourceCreate'][] = 46 | resourceKeys.map(key => ({ 47 | key, 48 | name: key, 49 | actions: {}, 50 | })); 51 | 52 | onComplete(resources); 53 | 54 | // Clear input after successful submission 55 | setInput(''); 56 | } catch (err) { 57 | setValidationError((err as Error).message); 58 | } 59 | }; 60 | 61 | return ( 62 | 63 | 64 | Configure Resources 65 | 66 | 67 | Enter resource keys (comma-separated): 68 | 69 | 70 | 71 | 72 | For Example: {placeholder} 73 | 74 | 75 | 76 | {'> '} 77 | 78 | 79 | {validationError && ( 80 | 81 | {validationError} 82 | 83 | )} 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /source/components/init/PolicyStepComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import SelectInput from 'ink-select-input'; 3 | import { Box, Text } from 'ink'; 4 | import Spinner from 'ink-spinner'; 5 | 6 | import SimplePolicyCreation from '../policy/CreateSimpleWizard.js'; 7 | import TemplatePolicyCreation from '../env/template/ApplyComponent.js'; 8 | 9 | type Props = { 10 | onComplete: (action: string, resource: string) => void; 11 | onError: (error: string) => void; 12 | }; 13 | 14 | export default function PolicyStepComponent({ onComplete, onError }: Props) { 15 | const [step, setStep] = useState< 16 | 'template' | 'simple' | 'processing' | 'done' | 'error' | 'initial' 17 | >('initial'); 18 | const [error, setError] = useState(null); 19 | const [action, setAction] = useState(null); 20 | const [resource, setResource] = useState(null); 21 | 22 | useEffect(() => { 23 | if (error) { 24 | onError(error); 25 | } 26 | }, [error, onError]); 27 | 28 | useEffect(() => { 29 | if (step === 'done') { 30 | onComplete(action || '', resource || ''); 31 | } 32 | }, [step, onComplete, action, resource]); 33 | 34 | if (step === 'initial') { 35 | return ( 36 | 37 | Policy Setup: 38 | { 44 | if (item.value === 'simple') { 45 | setStep('simple'); 46 | } else { 47 | setStep('template'); 48 | } 49 | }} 50 | /> 51 | 52 | ); 53 | } 54 | 55 | if (step === 'simple') { 56 | return ( 57 | { 59 | setResource(resource); 60 | setAction(action); 61 | setStep('done'); 62 | }} 63 | onError={error => { 64 | setError(error); 65 | setStep('error'); 66 | }} 67 | /> 68 | ); 69 | } 70 | if (step === 'template') { 71 | return ( 72 | { 74 | setResource(resource); 75 | setAction(action); 76 | setStep('done'); 77 | }} 78 | onError={error => { 79 | setError(error); 80 | setStep('error'); 81 | }} 82 | /> 83 | ); 84 | } 85 | if (step === 'processing') { 86 | return ( 87 | 88 | 89 | Processing... 90 | 91 | 92 | ); 93 | } 94 | return null; 95 | } 96 | -------------------------------------------------------------------------------- /tests/hooks/useProjectAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import { useProjectAPI } from '../../source/hooks/useProjectAPI.js'; 2 | import { vi, expect, it, describe, beforeEach } from 'vitest'; 3 | import React from 'react'; 4 | import { render } from 'ink-testing-library'; 5 | import { Text } from 'ink'; 6 | import { getMockFetchResponse } from '../utils.js'; 7 | 8 | global.fetch = vi.fn(); 9 | 10 | describe('useProjectAPI', () => { 11 | beforeEach(() => { 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | it('should fetch all projects', async () => { 16 | const TestComponent = () => { 17 | const { getProjects } = useProjectAPI(); 18 | 19 | (fetch as any).mockResolvedValueOnce({ 20 | ...getMockFetchResponse(), 21 | json: async () => [ 22 | { 23 | key: 'project-key', 24 | id: 'project-id', 25 | organization_id: 'org-id', 26 | created_at: '2024-01-01', 27 | updated_at: '2024-01-02', 28 | name: 'Project Name', 29 | settings: {}, 30 | active_policy_repo_id: 'policy-id', 31 | }, 32 | ], 33 | }); 34 | 35 | const fetchProjects = async () => { 36 | const { data: projects } = await getProjects(); 37 | return (projects?.length ?? 0 > 0) && projects 38 | ? projects[0]?.name 39 | : 'No projects'; 40 | }; 41 | const [result, setResult] = React.useState(null); 42 | fetchProjects().then(res => setResult(res ?? null)); 43 | 44 | return {result}; 45 | }; 46 | 47 | const { lastFrame } = render(); 48 | await vi.waitFor(() => { 49 | expect(lastFrame()).toBe('Project Name'); 50 | }); 51 | }); 52 | 53 | it('should handle failure to fetch projects', async () => { 54 | const TestComponent = () => { 55 | const { getProjects } = useProjectAPI(); 56 | const accessToken = 'access-token'; 57 | const cookie = 'cookie'; 58 | 59 | (fetch as any).mockRejectedValueOnce( 60 | new Error('Failed to fetch projects'), 61 | ); 62 | 63 | const fetchProjects = async () => { 64 | try { 65 | const { data: projects } = await getProjects(); 66 | return (projects?.length ?? 0 > 0) && projects 67 | ? projects[0]?.name 68 | : 'No projects'; 69 | } catch (error) { 70 | return error.message; 71 | } 72 | }; 73 | const [result, setResult] = React.useState(null); 74 | fetchProjects().then(res => setResult(res)); 75 | 76 | return {result}; 77 | }; 78 | 79 | const { lastFrame } = render(); 80 | await vi.waitFor(() => { 81 | expect(lastFrame()).toBe('Failed to fetch projects'); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /source/commands/env/member.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { option } from 'pastel'; 3 | 4 | import zod from 'zod'; 5 | import { type infer as zInfer } from 'zod'; 6 | import { AuthProvider } from '../../components/AuthProvider.js'; 7 | import MemberComponent from '../../components/env/MemberComponent.js'; 8 | 9 | export const options = zod.object({ 10 | apiKey: zod 11 | .string() 12 | .optional() 13 | .describe( 14 | option({ 15 | description: 16 | 'Optional: An API key to perform the invite. A project or organization level API key is required to invite members to the account. In case not set, CLI lets you select one', 17 | }), 18 | ), 19 | environment: zod 20 | .string() 21 | .optional() 22 | .describe( 23 | option({ 24 | description: 25 | 'Optional: Id of the environment you want to add a member to. In case not set, the CLI will prompt you to select one.', 26 | }), 27 | ), 28 | project: zod 29 | .string() 30 | .optional() 31 | .describe( 32 | option({ 33 | description: 34 | 'Optional: Id of the project you want to add a member to. In case not set, the CLI will prompt you to select one.', 35 | }), 36 | ), 37 | email: zod 38 | .string() 39 | .optional() 40 | .describe( 41 | option({ 42 | description: 43 | 'Optional: Email of the user you want to invite. In case not set, the CLI will ask you for it', 44 | }), 45 | ), 46 | role: zod 47 | .enum(['admin', 'write', 'read']) 48 | .optional() 49 | .describe( 50 | option({ 51 | description: 'Optional: Environment role for the user', 52 | }), 53 | ), 54 | inviterEmail: zod 55 | .string() 56 | .optional() 57 | .describe( 58 | option({ 59 | description: 'Optional: Inviter email address', 60 | }), 61 | ), 62 | inviterName: zod 63 | .string() 64 | .optional() 65 | .describe( 66 | option({ 67 | description: 'Optional: Inviter name', 68 | }), 69 | ), 70 | }); 71 | 72 | type Props = { 73 | readonly options: zInfer; 74 | }; 75 | 76 | export default function Member({ 77 | options: { 78 | apiKey, 79 | environment, 80 | project, 81 | email, 82 | role, 83 | inviterName, 84 | inviterEmail, 85 | }, 86 | }: Props) { 87 | return ( 88 | <> 89 | 90 | 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /source/templates/orm-data-filtering.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | permitio = { 4 | source = "permitio/permit-io" 5 | version = "~> 0.0.14" 6 | } 7 | } 8 | } 9 | 10 | provider "permitio" { 11 | api_url = {{API_URL}} 12 | api_key = {{API_KEY}} 13 | } 14 | 15 | # Resources 16 | resource "permitio_resource" "project" { 17 | name = "Project" 18 | description = "" 19 | key = "project" 20 | 21 | actions = { 22 | "read" = { 23 | name = "read" 24 | }, 25 | "create" = { 26 | name = "create" 27 | }, 28 | "update" = { 29 | name = "update" 30 | }, 31 | "delete" = { 32 | name = "delete" 33 | } 34 | } 35 | attributes = { 36 | } 37 | } 38 | resource "permitio_resource" "task" { 39 | name = "Task" 40 | description = "" 41 | key = "task" 42 | 43 | actions = { 44 | "read" = { 45 | name = "read" 46 | }, 47 | "create" = { 48 | name = "create" 49 | }, 50 | "update" = { 51 | name = "update" 52 | }, 53 | "delete" = { 54 | name = "delete" 55 | } 56 | } 57 | attributes = { 58 | } 59 | } 60 | 61 | # Roles 62 | resource "permitio_role" "project__Member" { 63 | key = "Member" 64 | name = "Member" 65 | resource = permitio_resource.project.key 66 | permissions = ["read"] 67 | 68 | depends_on = [permitio_resource.project] 69 | } 70 | resource "permitio_role" "task__Member" { 71 | key = "Member" 72 | name = "Member" 73 | resource = permitio_resource.task.key 74 | permissions = ["read"] 75 | 76 | depends_on = [permitio_resource.task] 77 | } 78 | 79 | # Relations 80 | resource "permitio_relation" "project_task" { 81 | key = "parent" 82 | name = "parent" 83 | subject_resource = permitio_resource.project.key 84 | object_resource = permitio_resource.task.key 85 | depends_on = [ 86 | permitio_resource.task, 87 | permitio_resource.project, 88 | ] 89 | } 90 | 91 | # Role Derivations 92 | resource "permitio_role_derivation" "project_Member_to_task_Member" { 93 | role = permitio_role.project__Member.key 94 | on_resource = permitio_resource.project.key 95 | to_role = permitio_role.task__Member.key 96 | resource = permitio_resource.task.key 97 | linked_by = permitio_relation.project_task.key 98 | depends_on = [ 99 | permitio_role.project__Member, 100 | permitio_resource.project, 101 | permitio_role.task__Member, 102 | permitio_resource.task, 103 | permitio_relation.project_task 104 | ] 105 | } -------------------------------------------------------------------------------- /source/components/api/PermitUsersUnassignComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { type infer as zInfer } from 'zod'; 4 | import { options } from '../../commands/api/users/unassign.js'; 5 | import { useAuth } from '../AuthProvider.js'; 6 | import Spinner from 'ink-spinner'; 7 | import { usersApi } from '../../utils/permitApi.js'; 8 | 9 | type Props = { 10 | options: zInfer; 11 | }; 12 | 13 | // Handles role unassignment operations with real-time feedback 14 | export default function PermitUsersUnassignComponent({ options }: Props) { 15 | const auth = useAuth(); 16 | // Mirror assign component state management for consistency 17 | const [status, setStatus] = useState<'processing' | 'done' | 'error'>( 18 | 'processing', 19 | ); 20 | const [result, setResult] = useState({}); 21 | const [errorMessage, setErrorMessage] = useState(null); 22 | 23 | useEffect(() => { 24 | const unassignRole = async () => { 25 | try { 26 | // Validate required fields before making API call 27 | if (!options.user || !options.role || !options.tenant) { 28 | throw new Error( 29 | 'User ID, role key, and tenant key are required for unassignment', 30 | ); 31 | } 32 | 33 | const response = await usersApi.unassign({ 34 | auth, 35 | projectId: options.projectId, 36 | envId: options.envId, 37 | apiKey: options.apiKey, 38 | user: options.user, 39 | role: options.role, 40 | tenant: options.tenant, 41 | }); 42 | 43 | // Handle both success and error responses uniformly 44 | if (!response.success) { 45 | setResult(response.data || {}); 46 | throw new Error(response.error); 47 | } 48 | 49 | setResult(response.data || {}); 50 | setStatus('done'); 51 | } catch (error) { 52 | setStatus('error'); 53 | setErrorMessage( 54 | error instanceof Error ? error.message : 'Unknown error occurred', 55 | ); 56 | } 57 | }; 58 | 59 | unassignRole(); 60 | }, [options, auth]); 61 | 62 | // Maintain consistent UI feedback pattern across role management operations 63 | return ( 64 | 65 | {status === 'processing' && } 66 | 67 | {status === 'done' && ( 68 | 69 | ✓ Operation completed successfully 70 | {JSON.stringify(result, null, 2)} 71 | 72 | )} 73 | 74 | {status === 'error' && ( 75 | <> 76 | ✗ Error: {errorMessage} 77 | {JSON.stringify(result, null, 2)} 78 | 79 | )} 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /source/components/api/PermitUsersAssignComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { type infer as zInfer } from 'zod'; 4 | import { options } from '../../commands/api/users/assign.js'; 5 | import { useAuth } from '../AuthProvider.js'; 6 | import Spinner from 'ink-spinner'; 7 | import { usersApi } from '../../utils/permitApi.js'; 8 | 9 | type Props = { 10 | options: zInfer; 11 | }; 12 | 13 | // Handles role assignment operations with real-time feedback 14 | export default function PermitUsersAssignComponent({ options }: Props) { 15 | const auth = useAuth(); 16 | // Track operation state for better UX feedback 17 | const [status, setStatus] = useState<'processing' | 'done' | 'error'>( 18 | 'processing', 19 | ); 20 | // Store API response for both success and error cases 21 | const [result, setResult] = useState({}); 22 | const [errorMessage, setErrorMessage] = useState(null); 23 | 24 | useEffect(() => { 25 | const assignRole = async () => { 26 | try { 27 | // Validate required fields before making API call 28 | if (!options.user || !options.role || !options.tenant) { 29 | throw new Error( 30 | 'User ID, role key, and tenant key are required for assignment', 31 | ); 32 | } 33 | 34 | const response = await usersApi.assign({ 35 | auth, 36 | projectId: options.projectId, 37 | envId: options.envId, 38 | apiKey: options.apiKey, 39 | user: options.user, 40 | role: options.role, 41 | tenant: options.tenant, 42 | }); 43 | 44 | // Handle both success and error responses uniformly 45 | if (!response.success) { 46 | setResult(response.data || {}); 47 | throw new Error(response.error); 48 | } 49 | 50 | setResult(response.data || {}); 51 | setStatus('done'); 52 | } catch (error) { 53 | setStatus('error'); 54 | setErrorMessage( 55 | error instanceof Error ? error.message : 'Unknown error occurred', 56 | ); 57 | } 58 | }; 59 | 60 | assignRole(); 61 | }, [options, auth]); 62 | 63 | // Provide clear visual feedback for all operation states 64 | return ( 65 | 66 | {status === 'processing' && } 67 | 68 | {status === 'done' && ( 69 | 70 | ✓ Operation completed successfully 71 | {JSON.stringify(result, null, 2)} 72 | 73 | )} 74 | 75 | {status === 'error' && ( 76 | <> 77 | ✗ Error: {errorMessage} 78 | {JSON.stringify(result, null, 2)} 79 | 80 | )} 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /source/hooks/openapi/process/urlMappingProcessor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sanitizeKey, 3 | PathItem, 4 | Operation, 5 | HTTP_METHODS, 6 | ApiResponse, 7 | } from '../../../utils/openapiUtils.js'; 8 | import { PERMIT_EXTENSIONS, ProcessorContext } from './openapiConstants.js'; 9 | import { UrlMappingRequest, UrlMappingResponse } from './apiTypes.js'; 10 | 11 | export async function generateUrlMappings( 12 | context: ProcessorContext, 13 | pathItems: Record, 14 | ) { 15 | const { mappings, baseUrl } = context; 16 | 17 | for (const [pathKey, pathItem] of Object.entries(pathItems || {})) { 18 | if (!pathItem || typeof pathItem !== 'object') continue; 19 | 20 | const typedPathItem = pathItem as PathItem; 21 | 22 | const rawResource = typedPathItem[PERMIT_EXTENSIONS.RESOURCE]; 23 | if (!rawResource) continue; 24 | 25 | const resource = sanitizeKey(rawResource as string); 26 | 27 | // Process HTTP methods 28 | for (const method of HTTP_METHODS) { 29 | const operation = typedPathItem[method] as Operation | undefined; 30 | if (!operation) continue; 31 | 32 | // Add URL mapping with absolute path 33 | const action = operation[PERMIT_EXTENSIONS.ACTION] || method; 34 | mappings.push({ 35 | url: baseUrl ? `${baseUrl}${pathKey}` : pathKey, 36 | http_method: method as string, 37 | resource: resource, 38 | action: action as string, 39 | }); 40 | } 41 | } 42 | 43 | return context; 44 | } 45 | 46 | // Define function type signatures 47 | type DeleteUrlMappingsFunction = ( 48 | source: string, 49 | ) => Promise>>; 50 | type CreateUrlMappingsFunction = ( 51 | mappings: UrlMappingRequest[], 52 | authType: string, 53 | tokenHeader: string, 54 | ) => Promise>; 55 | 56 | export async function createMappings( 57 | context: ProcessorContext, 58 | deleteUrlMappings: DeleteUrlMappingsFunction, 59 | createUrlMappings: CreateUrlMappingsFunction, 60 | ) { 61 | const { mappings, errors } = context; 62 | 63 | // Create URL mappings 64 | if (mappings.length > 0) { 65 | try { 66 | // Try to delete existing mappings first 67 | try { 68 | await deleteUrlMappings('openapi'); 69 | } catch { 70 | // No existing mappings to delete or error deleting 71 | } 72 | 73 | const result = await createUrlMappings( 74 | mappings as UrlMappingRequest[], 75 | 'Bearer', 76 | 'openapi_token', 77 | ); 78 | 79 | if (result.error) { 80 | errors.push( 81 | `Failed to create URL mappings: ${JSON.stringify(result.error)}`, 82 | ); 83 | } 84 | } catch (mappingError) { 85 | errors.push(`Error creating URL mappings: ${mappingError}`); 86 | } 87 | } 88 | 89 | return context; 90 | } 91 | --------------------------------------------------------------------------------