From 9836976c7bbbe58c300adf03d88cbf35c00d2512 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 19 May 2025 22:24:45 +0530 Subject: [PATCH 1/7] feat: abac e2e --- package-lock.json | 18 +- package.json | 1 + source/commands/test/generate/e2e.tsx | 11 +- .../init/GenerateUsersComponent.tsx | 4 +- .../test/GeneratePolicySnapshot.tsx | 134 ++++-- .../test/code-samples/CodeSampleComponent.tsx | 6 +- .../test/hooks/usePolicyABACSnapshot.ts | 426 ++++++++++++++++++ ...cySnapshot.ts => usePolicyRBACSnapshot.ts} | 173 ++----- source/hooks/useConditionSetApi.ts | 29 ++ source/hooks/useSetPermissions.ts | 23 + source/utils/reverse-attributes.ts | 57 +++ .../init/GenerateUsersComponent.test.tsx | 4 +- 12 files changed, 724 insertions(+), 162 deletions(-) create mode 100644 source/components/test/hooks/usePolicyABACSnapshot.ts rename source/components/test/hooks/{usePolicySnapshot.ts => usePolicyRBACSnapshot.ts} (65%) create mode 100644 source/hooks/useConditionSetApi.ts create mode 100644 source/hooks/useSetPermissions.ts create mode 100644 source/utils/reverse-attributes.ts diff --git a/package-lock.json b/package-lock.json index bec57c63..20b0212a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@permitio/cli", - "version": "0.2.0", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@permitio/cli", - "version": "0.2.0", + "version": "0.2.5", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", @@ -33,6 +33,7 @@ "pastel": "^3.0.0", "permitio": "2.6.1", "react": "^18.2.0", + "uuid": "^11.1.0", "zod": "^3.21.3" }, "bin": { @@ -15527,6 +15528,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 6f339535..f0d4e228 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "pastel": "^3.0.0", "permitio": "2.6.1", "react": "^18.2.0", + "uuid": "^11.1.0", "zod": "^3.21.3" }, "devDependencies": { diff --git a/source/commands/test/generate/e2e.tsx b/source/commands/test/generate/e2e.tsx index 72e8dec2..91463bd1 100644 --- a/source/commands/test/generate/e2e.tsx +++ b/source/commands/test/generate/e2e.tsx @@ -53,6 +53,14 @@ export const options = zod.object({ 'Test code sample that iterates the config file and asserts the results.', }), ), + snippetPath: zod + .string() + .optional() + .describe( + option({ + description: 'Optional: Path to save the test file', + }), + ), }); type Props = { @@ -60,7 +68,7 @@ type Props = { }; export default function E2e({ - options: { dryRun, models, path, apiKey, snippet }, + options: { dryRun, models, path, apiKey, snippet, snippetPath }, }: Props) { return ( @@ -69,6 +77,7 @@ export default function E2e({ models={models} path={path} snippet={snippet} + snippetPath={snippetPath} /> ); diff --git a/source/components/init/GenerateUsersComponent.tsx b/source/components/init/GenerateUsersComponent.tsx index 69e2131a..ce3ba418 100644 --- a/source/components/init/GenerateUsersComponent.tsx +++ b/source/components/init/GenerateUsersComponent.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useMemo, useState } from 'react'; -import { useGeneratePolicySnapshot } from '../test/hooks/usePolicySnapshot.js'; +import { useGeneratePolicyRBACSnapshot } from '../test/hooks/usePolicyRBACSnapshot.js'; import { Text, Box } from 'ink'; import Spinner from 'ink-spinner'; import SelectInput from 'ink-select-input'; @@ -38,7 +38,7 @@ export default function GeneratedUsersComponent({ ); const { state, error, createdUsers, tenantId } = - useGeneratePolicySnapshot(snapshotOptions); + useGeneratePolicyRBACSnapshot(snapshotOptions); // Handle errors useEffect(() => { diff --git a/source/components/test/GeneratePolicySnapshot.tsx b/source/components/test/GeneratePolicySnapshot.tsx index 6fa287b1..b1e52076 100644 --- a/source/components/test/GeneratePolicySnapshot.tsx +++ b/source/components/test/GeneratePolicySnapshot.tsx @@ -1,8 +1,16 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Newline, Text } from 'ink'; -import Spinner from 'ink-spinner'; -import { useGeneratePolicySnapshot } from './hooks/usePolicySnapshot.js'; +import { + RBACConfig, + useGeneratePolicyRBACSnapshot, +} from './hooks/usePolicyRBACSnapshot.js'; import { CodeSampleComponent } from './code-samples/CodeSampleComponent.js'; +import { + ABACConfig, + useGeneratePolicyABACSnapshot, +} from './hooks/usePolicyABACSnapshot.js'; +import { saveFile } from '../../utils/fileSaver.js'; +import Spinner from 'ink-spinner'; export type GeneratePolicySnapshotProps = { dryRun: boolean; @@ -13,46 +21,115 @@ export type GeneratePolicySnapshotProps = { snippetPath?: string; }; +export type DryUser = { + key: string; + email: string; + firstName: string; + lastName: string; + roles: string[]; +}; + +export type AccessControlConfig = { + config: (RBACConfig | ABACConfig)[]; + users?: DryUser[]; +}; + export function GeneratePolicySnapshot({ dryRun, models, path, snippet, + snippetPath, }: GeneratePolicySnapshotProps) { const filePath = snippet && !path ? 'authz-test.json' : path; - const { state, error, roles, tenantId, finalConfig, dryUsers } = - useGeneratePolicySnapshot({ dryRun, models, path: filePath }); + const [state, setState] = useState<'building' | 'done'>('building'); + const [error, setError] = useState(undefined); + const [finalConfig, setFinalConfig] = useState({ + config: [], + }); + // const [finalDryUsers, setFinalDryUsers] = useState([]); + const { + state: RBACState, + error: RBACError, + finalConfig: RBACConfig, + dryUsers: dryRBACUsers, + } = useGeneratePolicyRBACSnapshot({ + dryRun, + models, + path: filePath, + }); - // Handle Error and lifecycle completion. - useEffect(() => { - if (error || (state === 'done' && !snippet)) { + const { + state: ABACState, + error: ABACError, + finalConfig: ABACConfig, + dryUsers: dryABACUsers, + } = useGeneratePolicyABACSnapshot({ + dryRun, + models, + path, + }); + + const saveConfigToPath = useCallback( + async (finalConfig: AccessControlConfig) => { + // Write config as pretty JSON + const json = JSON.stringify(finalConfig, null, 2); + const { error } = await saveFile(path ?? '', json); + if (error) { + setError(error); + return; + } setTimeout(() => { - process.exit(1); + setState('done'); }, 1000); + }, + [path], + ); + + useEffect(() => { + // console.log('IM MAIN', [RBACState, ABACState], models); + const configsGenerated = [RBACState, ABACState].filter( + state => state === 'done', + ); + if (configsGenerated.length === models.length) { + const combinedConfigs: AccessControlConfig = { + config: [...ABACConfig, ...RBACConfig], + users: [...dryABACUsers, ...dryRBACUsers], + }; + setFinalConfig(prev => ({ + ...prev, + ...combinedConfigs, + })); + if (path) { + saveConfigToPath(combinedConfigs); + } else { + setTimeout(() => { + setState('done'); + }, 1000); + } } - }, [error, snippet, state]); + }, [ + ABACConfig, + ABACState, + RBACConfig, + RBACState, + dryABACUsers, + dryRBACUsers, + models, + path, + saveConfigToPath, + ]); + return ( <> - {state === 'roles' && Getting all roles} - {roles.length > 0 && Roles found: {roles.length}} - {state === 'rbac-tenant' && Crating a new Tenant} - {tenantId && Created a new test tenant: {tenantId}} - {state === 'rbac-generate' && ( + {state === 'building' && ( - Generating test data for you {' '} + Building Config {' '} )} - {dryRun && Dry run mode!} - {state === 'done' && filePath && Config saved to {filePath}} - {state === 'done' && !filePath && ( - - {' '} - {JSON.stringify( - dryRun - ? { users: dryUsers, config: finalConfig } - : { config: finalConfig }, - )}{' '} - + {state === 'done' && path && Config saved to {path}!} + {state === 'done' && !path && ( + {JSON.stringify(finalConfig)} )} {state === 'done' && snippet && ( <> @@ -61,10 +138,13 @@ export function GeneratePolicySnapshot({ framework={snippet} configPath={filePath} pdpUrl={'http://localhost:7766'} + path={snippetPath} /> )} {error && {error}} + {ABACError && {ABACError}} + {RBACError && {RBACError}} ); } diff --git a/source/components/test/code-samples/CodeSampleComponent.tsx b/source/components/test/code-samples/CodeSampleComponent.tsx index 9dca9185..f1839d21 100644 --- a/source/components/test/code-samples/CodeSampleComponent.tsx +++ b/source/components/test/code-samples/CodeSampleComponent.tsx @@ -47,7 +47,7 @@ export function CodeSampleComponent({ } }, [auth, framework, configPath, path, pdpUrl, state]); - const saveCodeTOPath = useCallback(async () => { + const saveCodeToPath = useCallback(async () => { const { error } = await saveFile(path ?? '', code ?? ''); if (error) { setError(error); @@ -57,11 +57,11 @@ export function CodeSampleComponent({ useEffect(() => { if (code && path) { - saveCodeTOPath(); + saveCodeToPath(); } else if (code) { setState('done'); } - }, [code, path, saveCodeTOPath]); + }, [code, path, saveCodeToPath]); return ( <> diff --git a/source/components/test/hooks/usePolicyABACSnapshot.ts b/source/components/test/hooks/usePolicyABACSnapshot.ts new file mode 100644 index 00000000..51094e8b --- /dev/null +++ b/source/components/test/hooks/usePolicyABACSnapshot.ts @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + DryUser, + GeneratePolicySnapshotProps, +} from '../GeneratePolicySnapshot.js'; +import { + ConditionSetRead, + useConditionSetApi, +} from '../../../hooks/useConditionSetApi.js'; +import { + ConditionSetRuleRead, + useSetPermissionsApi, +} from '../../../hooks/useSetPermissions.js'; +import randomName from '@scaleway/random-name'; +import { + CreateTenantBody, + CreateUserBody, + useTenantApi, +} from '../../../hooks/useTenantApi.js'; +import { useUserApi } from '../../../hooks/useUserApi.js'; +import { + attributeBuilder, + getRefValue, +} from '../../../utils/reverse-attributes.js'; + +type ABACUser = + | { + key: string; + attributes?: Record; + } + | string; + +type ABACResource = + | { + type: string; + attributes?: Record; + tenant: string; + } + | string; + +export type ABACConfig = { + user: ABACUser; + action: string; + resource: ABACResource; + result: boolean; +}; + +type ConditionValue = { + attr: string; + condition: string; + value: string; +}; + +export type ConditionRef = { + attr: string; + condition: string; + value: string; +}; + +export const useGeneratePolicyABACSnapshot = ({ + dryRun, + models, +}: GeneratePolicySnapshotProps) => { + const { getConditionSets } = useConditionSetApi(); + const { getSetPermissions } = useSetPermissionsApi(); + const { createTenant } = useTenantApi(); + const { createUser } = useUserApi(); + + const [error, setError] = useState(null); + const [state, setState] = useState< + | 'condition-sets' + | 'set-permissions' + | 'create-tenant' + | 'create-users' + | 'done' + >('condition-sets'); + + const refResourceConditionSets = useRef>({}); + const refUserConditionSets = useRef>({}); + const refSetPermissions = useRef([]); + const refTenantId = useRef(null); + + const [finalConfig, setFinalConfig] = useState([]); + const [dryUsers, setDryUsers] = useState([]); + + const getResourceSets = useCallback(async () => { + const { data, error } = await getConditionSets('resourceset'); + if (error) { + setError(error); + } + (data as ConditionSetRead[])?.forEach( + resourceSet => + (refResourceConditionSets.current[resourceSet.key] = resourceSet), + ); + }, [getConditionSets]); + + const buildUserInfoFromUsername = useCallback((user: string) => { + const [firstName = '', lastName = ''] = user.split(' '); + return { + key: firstName + lastName, + email: firstName + lastName + '@gmail.com', + firstName, + lastName, + }; + }, []); + + const createNewTenant = useCallback(async () => { + const name = 'test-tenant-' + randomName('', ''); + refTenantId.current = name; + if (dryRun) { + // eslint-disable-next-line sonarjs/no-duplicate-string + setState('create-users'); + return; + } + const body: CreateTenantBody = { + key: name, + name: name, + description: + 'This is a tenant created by permit-cli for creating test users', + attributes: {}, + }; + const { error } = await createTenant(body); + if (error) { + setError(error); + return; + } + setState('create-users'); + }, [createTenant, dryRun]); + + const createNewUser = useCallback( + async (roles?: string[]) => { + const user = randomName('', ' '); + const { key, email, firstName, lastName } = + buildUserInfoFromUsername(user); + + const body: CreateUserBody = { + key: key, + first_name: firstName, + last_name: lastName, + email: email, + attributes: {}, + }; + if (roles) { + body.role_assignments = roles.map(role => ({ + role: role, + tenant: refTenantId.current || '', + })); + } + + if (dryRun) { + setDryUsers(prev => [ + ...prev, + { + key: body.key, + email: body.email ?? '', + firstName: body.first_name ?? ' ', + lastName: body.last_name ?? ' ', + roles: body.role_assignments?.map(role => role.role) ?? [], + }, + ]); + return key; + } + + const { error } = await createUser(body); + if (error) { + setError(error); + return; + } + return key; + }, + [buildUserInfoFromUsername, createUser, dryRun], + ); + + const getUserSets = useCallback(async () => { + const { data, error } = await getConditionSets('userset'); + if (error) { + setError(error); + } + (data as ConditionSetRead[])?.forEach( + userSet => (refUserConditionSets.current[userSet.key] = userSet), + ); + }, [getConditionSets]); + + const getUserResourceConditionSet = useCallback(async () => { + await getResourceSets(); + await getUserSets(); + setState('set-permissions'); + }, [getResourceSets, getUserSets]); + + const getAllSetPermissions = useCallback(async () => { + const { data, error } = await getSetPermissions(); + if (error) { + setError(error); + return; + } + refSetPermissions.current = data as ConditionSetRuleRead[]; + setState('create-tenant'); + }, [getSetPermissions]); + + const extractAllConditions = ( + conditions: Record, + ): { conditionValues: ConditionValue[]; conditionRefs: ConditionRef[] } => { + // will also yield ConditionRefs on a later date + const conditionValues: ConditionValue[] = []; + const conditionRefs: ConditionRef[] = []; + + const traverse = (node: Record) => { + if (typeof node === 'object' && node !== null) { + Object.entries(node).forEach(([key, value]) => { + // If it's an array, recurse into it (handling logical operators like allOf) + if (Array.isArray(value)) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + value.forEach(traverse); + } else { + // If it's a condition, extract the attribute, condition, and value + Object.entries(value).forEach(([condition, val]) => { + if (typeof val === 'object') { + conditionRefs.push({ + attr: key, + condition, + value: (val as { ref: string }).ref, + }); + } else { + conditionValues.push({ + attr: key, + condition, + value: val as string, + }); + } + }); + } + }); + } + }; + traverse(conditions); + return { conditionValues, conditionRefs }; + }; + + const buildAttributesFromConditions = useCallback( + ({ + conditionValues, + _conditionRefs: conditionRefs, + }: { + conditionValues: ConditionValue[]; + _conditionRefs: ConditionRef[]; // add support for refs later + }) => { + const attributes: Record = {}; + conditionValues.forEach(condition => { + const key = condition.attr.split('.')[1] ?? ''; + attributes[key] = attributeBuilder({ + value: condition.value, + condition: condition.condition, + }); + }); + const conditionMap: Record = {}; + conditionRefs.forEach(conditionRef => { + const key = conditionRef.attr.split('.')[1] ?? ''; + conditionMap[key] = conditionRef; + }); + Object.keys(conditionMap).forEach(key => { + const ref = conditionMap[key]?.value.split('.')[1] ?? ''; + attributes[key] = getRefValue({ attributes, conditionMap, ref }); + }); + return attributes; + }, + [], + ); + + const buildUserSetResourceCombination = useCallback(async () => { + const userKey = (await createNewUser()) ?? ''; + + const userSetResourcePermissions = refSetPermissions.current.filter( + permission => + refUserConditionSets.current[permission.user_set] !== undefined && + refResourceConditionSets.current[permission.resource_set] === undefined, + ); + + const userSetResourceConfig: ABACConfig[] = userSetResourcePermissions.map( + permission => { + const currUserSet = refUserConditionSets.current[permission.user_set]; + const [resource, currAction] = permission.permission.split(':'); + const { conditionValues, conditionRefs } = extractAllConditions( + currUserSet?.conditions ?? {}, + ); + const userAttributes = buildAttributesFromConditions({ + conditionValues, + _conditionRefs: conditionRefs, + }); + + return { + user: { + key: userKey, + attributes: userAttributes, + }, + resource: resource ?? '', + action: currAction ?? '', + result: true, + }; + }, + ); + setFinalConfig(config => [...config, ...userSetResourceConfig]); + }, [buildAttributesFromConditions, createNewUser]); + + const buildResourceSetRolesCombination = useCallback(async () => { + const resourceSetRolesPermissions = refSetPermissions.current.filter( + permission => + refUserConditionSets.current[permission.user_set] === undefined && + refResourceConditionSets.current[permission.resource_set] !== undefined, + ); + + const ResourceSetRoleConfig: ABACConfig[] = await Promise.all( + resourceSetRolesPermissions.map(async permission => { + const userKey = + (await createNewUser([permission.user_set.substring(10)])) ?? ''; + const currResourceSet = + refResourceConditionSets.current[permission.resource_set]; + const [resource, currAction] = permission.permission.split(':'); + const { conditionValues, conditionRefs } = extractAllConditions( + currResourceSet?.conditions ?? {}, + ); + const resourceAttributes = buildAttributesFromConditions({ + conditionValues, + _conditionRefs: conditionRefs, + }); + return { + user: userKey, + resource: { + type: resource ?? '', + attributes: resourceAttributes, + tenant: refTenantId.current ?? '', + }, + action: currAction ?? '', + result: true, + }; + }), + ); + setFinalConfig(config => [...config, ...ResourceSetRoleConfig]); + }, [buildAttributesFromConditions, createNewUser]); + + const buildResourceSetUserSetCombination = useCallback(async () => { + const userKey = (await createNewUser()) ?? ''; + const resourceSetUserSetPermissions = refSetPermissions.current.filter( + permission => + refUserConditionSets.current[permission.user_set] !== undefined && + refResourceConditionSets.current[permission.resource_set] !== undefined, + ); + const resourceSetUserSetConfig: ABACConfig[] = await Promise.all( + resourceSetUserSetPermissions.map(async permission => { + const currResourceSet = + refResourceConditionSets.current[permission.resource_set]; + const [resource, currAction] = permission.permission.split(':'); + const { + conditionValues: resourceSetConditionValues, + conditionRefs: resourceSetConditionRefs, + } = extractAllConditions(currResourceSet?.conditions ?? {}); + const resourceAttributes = buildAttributesFromConditions({ + conditionValues: resourceSetConditionValues, + _conditionRefs: resourceSetConditionRefs, + }); + const currUserSet = refUserConditionSets.current[permission.user_set]; + + const { + conditionValues: userSetConditionValues, + conditionRefs: usersSetConditionRefs, + } = extractAllConditions(currUserSet?.conditions ?? {}); + const userAttributes = buildAttributesFromConditions({ + conditionValues: userSetConditionValues, + _conditionRefs: usersSetConditionRefs, + }); + return { + user: { + key: userKey, + attributes: userAttributes, + }, + resource: { + type: resource ?? '', + attributes: resourceAttributes, + tenant: refTenantId.current ?? '', + }, + action: currAction ?? '', + result: true, + }; + }), + ); + setFinalConfig(config => [...config, ...resourceSetUserSetConfig]); + }, [buildAttributesFromConditions, createNewUser]); + + const buildAllCombinations = useCallback(async () => { + await buildUserSetResourceCombination(); + await buildResourceSetRolesCombination(); + await buildResourceSetUserSetCombination(); + setState('done'); + }, [ + buildResourceSetRolesCombination, + buildResourceSetUserSetCombination, + buildUserSetResourceCombination, + ]); + + useEffect(() => { + if (!models.includes('ABAC')) return; + if (state === 'condition-sets') { + getUserResourceConditionSet(); + } else if (state === 'set-permissions') { + getAllSetPermissions(); + } else if (state === 'create-tenant') { + createNewTenant(); + } else if (state === 'create-users') { + buildAllCombinations(); + } + }, [ + state, + models, + getUserResourceConditionSet, + getAllSetPermissions, + createNewTenant, + buildAllCombinations, + ]); + + return { + state, + error, + finalConfig, + dryUsers, + }; +}; diff --git a/source/components/test/hooks/usePolicySnapshot.ts b/source/components/test/hooks/usePolicyRBACSnapshot.ts similarity index 65% rename from source/components/test/hooks/usePolicySnapshot.ts rename to source/components/test/hooks/usePolicyRBACSnapshot.ts index c20df244..103276d7 100644 --- a/source/components/test/hooks/usePolicySnapshot.ts +++ b/source/components/test/hooks/usePolicyRBACSnapshot.ts @@ -10,64 +10,27 @@ import { } from '../../../hooks/useResourcesApi.js'; import { useCallback, useEffect, useRef, useState } from 'react'; import randomName from '@scaleway/random-name'; -import { GeneratePolicySnapshotProps } from '../GeneratePolicySnapshot.js'; +import { + DryUser, + GeneratePolicySnapshotProps, +} from '../GeneratePolicySnapshot.js'; import { useUserApi } from '../../../hooks/useUserApi.js'; -import { saveFile } from '../../../utils/fileSaver.js'; type RBACResource = { type: string; tenant: string; }; -type RBACConfig = { +export type RBACConfig = { user: string; action: string; resource: RBACResource; result: boolean; }; -type ABACUser = { - key: string; - attributes: { - [key: string]: string; - }; -}; - -type ABACResource = { - key: string; - attributes: { - [key: string]: string; - }; -}; - -type ABACConfig = { - user: ABACUser; - action: string; - resource: ABACResource; - result: boolean; -}; - -type ReBACConfig = { - user: string; - action: string; - resource: string; - result: boolean; -}; - -type AccessControlConfig = RBACConfig | ABACConfig | ReBACConfig; - -type DryUser = { - key: string; - email: string; - firstName: string; - lastName: string; - roles: string[]; -}; - -export const useGeneratePolicySnapshot = ({ +export const useGeneratePolicyRBACSnapshot = ({ dryRun, models, - path, isTestTenant = true, }: GeneratePolicySnapshotProps) => { const { getRoles } = useRolesApi(); @@ -75,7 +38,6 @@ export const useGeneratePolicySnapshot = ({ const { getResources } = useResourcesApi(); const { createUser } = useUserApi(); - const [roles, setRoles] = useState([]); const [error, setError] = useState(null); const [state, setState] = useState< | 'roles' @@ -85,11 +47,13 @@ export const useGeneratePolicySnapshot = ({ | 'resources' | 'done' >('roles'); + const [dryUsers, setDryUsers] = useState([]); + const [finalConfig, setFinalConfig] = useState([]); + const [tenantId, setTenantId] = useState(undefined); - const [modelsGenerated, setModelsGenerated] = useState(0); - const [finalConfig, setFinalConfig] = useState([]); + const tenantIdRef = useRef(undefined); + const rolesRef = useRef([]); const resourcesRef = useRef([]); - const [dryUsers, setDryUsers] = useState([]); const generatedUsersRBACRef = useRef([]); const userRoleMappingRBACRef = useRef>({}); const [createdUsers, setCreatedUsers] = useState([]); @@ -103,33 +67,13 @@ export const useGeneratePolicySnapshot = ({ }; }, []); - const createDryUsers = useCallback( - (usernames: string[], userRoleMappings: Record) => { - const result: DryUser[] = []; - for (const user of usernames) { - const { key, email, firstName, lastName } = - buildUserInfoFromUsername(user); - const dryUser: DryUser = { - key: key, - firstName: firstName, - lastName: lastName, - email: email, - roles: userRoleMappings[user]?.map(role => role.key) ?? [], - }; - result.push(dryUser); - } - setDryUsers(result); - // eslint-disable-next-line sonarjs/no-duplicate-string - setState('rbac-generate'); - }, - [buildUserInfoFromUsername], - ); const createUserAndAttachRoles = useCallback( async ( usernames: string[], userRoleMappings: Record, ) => { if (usernames.length === 0) { + // eslint-disable-next-line sonarjs/no-duplicate-string setState('rbac-generate'); return; } @@ -149,16 +93,32 @@ export const useGeneratePolicySnapshot = ({ attributes: {}, role_assignments: roles.map(role => ({ role: role.key, - tenant: tenantId || 'default', + tenant: tenantIdRef.current || 'default', })), }; + if (dryRun) { + setDryUsers(prev => [ + ...prev, + { + key: body.key, + email: body.email ?? '', + firstName: body.first_name ?? ' ', + lastName: body.last_name ?? ' ', + roles: body.role_assignments?.map(role => role.role) ?? [], + }, + ]); + setState('rbac-generate'); + return; + } + const result = await createUser(body); if (result.error) { setError(result.error); return; } + setCreatedUsers(prev => [ ...prev, { @@ -175,7 +135,7 @@ export const useGeneratePolicySnapshot = ({ setError(err instanceof Error ? err.message : String(err)); } }, - [buildUserInfoFromUsername, createUser, tenantId], + [buildUserInfoFromUsername, createUser, dryRun], ); const fetchRoles = useCallback(async () => { @@ -189,7 +149,7 @@ export const useGeneratePolicySnapshot = ({ setError('Environment has no Roles present'); return; } - setRoles(data as RoleRead[]); + rolesRef.current = data as RoleRead[]; setState('rbac-tenant'); }, [getRoles]); @@ -205,13 +165,18 @@ export const useGeneratePolicySnapshot = ({ const createNewTenant = useCallback(async () => { if (!isTestTenant) { + tenantIdRef.current = 'default'; setTenantId('default'); setState('resources'); return; } const name = 'test-tenant-' + randomName('', ''); - + tenantIdRef.current = name; setTenantId(name); + if (dryRun) { + setState('resources'); + return; + } const body: CreateTenantBody = { key: name, name: name, @@ -225,7 +190,7 @@ export const useGeneratePolicySnapshot = ({ return; } setState('resources'); - }, [createTenant, isTestTenant]); + }, [createTenant, dryRun, isTestTenant]); const generateRBACConfig = useCallback(() => { const config: RBACConfig[] = generatedUsersRBACRef.current.flatMap(user => @@ -241,7 +206,7 @@ export const useGeneratePolicySnapshot = ({ return { user: key, action: action.key ?? '', - resource: { type: resource.key, tenant: tenantId ?? '' }, + resource: { type: resource.key, tenant: tenantIdRef.current ?? '' }, result: result, }; }), @@ -249,25 +214,8 @@ export const useGeneratePolicySnapshot = ({ ); setFinalConfig(prev => [...prev, ...config]); - setModelsGenerated(prev => prev + 1); - }, [buildUserInfoFromUsername, tenantId]); - - const saveConfigToPath = useCallback(async () => { - // Write config as pretty JSON - const json = JSON.stringify( - dryRun - ? { users: dryUsers, config: finalConfig } - : { config: finalConfig }, - null, - 2, - ); - const { error } = await saveFile(path ?? '', json); - if (error) { - setError(error); - return; - } setState('done'); - }, [dryRun, dryUsers, finalConfig, path]); + }, [buildUserInfoFromUsername]); const generateUsersAndRoleMapping = useCallback(() => { let generatedUsers: string[] = []; @@ -275,7 +223,7 @@ export const useGeneratePolicySnapshot = ({ const userNoAccess = randomName('', ' '); generatedUsers.push(userNoAccess); userRoleMappingRBAC[userNoAccess] = []; - roles.forEach(role => { + rolesRef.current.forEach(role => { const userAllAccess = randomName('', ' '); generatedUsers.push(userAllAccess); userRoleMappingRBAC[userAllAccess] = [role]; @@ -286,68 +234,43 @@ export const useGeneratePolicySnapshot = ({ ]; userRoleMappingRBACRef.current = userRoleMappingRBAC; return { generatedUsers, userRoleMappingRBAC }; - }, [roles]); - - // Check if we have generated all config. - useEffect(() => { - if (modelsGenerated === models.length) { - if (!path) { - setTimeout(() => { - setState('done'); - }, 1000); - } else { - saveConfigToPath(); - } - } - }, [models, modelsGenerated, path, saveConfigToPath]); + }, []); // Step 1 : Get all roles and resources useEffect(() => { if (!models.includes('RBAC')) return; - if (roles.length === 0 && state === 'roles') { + if (state === 'roles') { fetchRoles(); - } else if (tenantId === undefined && state === 'rbac-tenant') { + } else if (state === 'rbac-tenant') { createNewTenant(); - } else if (resourcesRef.current.length === 0 && state === 'resources') { + } else if (state === 'resources') { fetchResources(); - } else if ( - generatedUsersRBACRef.current.length === 0 && - state === 'rbac-users' - ) { + } else if (state === 'rbac-users') { const { generatedUsers, userRoleMappingRBAC } = generateUsersAndRoleMapping(); - if (dryRun) { - createDryUsers(generatedUsers, userRoleMappingRBAC); - } else { - createUserAndAttachRoles(generatedUsers, userRoleMappingRBAC); - } + createUserAndAttachRoles(generatedUsers, userRoleMappingRBAC); } else if (state === 'rbac-generate') { generateRBACConfig(); } }, [ createNewTenant, - createDryUsers, createUserAndAttachRoles, - dryRun, fetchResources, fetchRoles, generateRBACConfig, generateUsersAndRoleMapping, models, - roles.length, state, - tenantId, ]); return { state, error, - roles, - tenantId, finalConfig, dryUsers, + tenantId, createdUsers, }; }; diff --git a/source/hooks/useConditionSetApi.ts b/source/hooks/useConditionSetApi.ts new file mode 100644 index 00000000..264abff1 --- /dev/null +++ b/source/hooks/useConditionSetApi.ts @@ -0,0 +1,29 @@ +import useClient from './useClient.js'; +import { useCallback, useMemo } from 'react'; +import { components } from '../lib/api/v1.js'; + +export type ConditionSetType = components['schemas']['ConditionSetType']; +export type ConditionSetRead = components['schemas']['ConditionSetRead']; + +export const useConditionSetApi = () => { + const { authenticatedApiClient } = useClient(); + + const getConditionSets = useCallback( + async (type: ConditionSetType) => { + return await authenticatedApiClient().GET( + '/v2/schema/{proj_id}/{env_id}/condition_sets', + undefined, + undefined, + { type }, + ); + }, + [authenticatedApiClient], + ); + + return useMemo( + () => ({ + getConditionSets, + }), + [getConditionSets], + ); +}; diff --git a/source/hooks/useSetPermissions.ts b/source/hooks/useSetPermissions.ts new file mode 100644 index 00000000..0796e87b --- /dev/null +++ b/source/hooks/useSetPermissions.ts @@ -0,0 +1,23 @@ +import useClient from './useClient.js'; +import { useCallback, useMemo } from 'react'; +import { components } from '../lib/api/v1.js'; + +export type ConditionSetRuleRead = + components['schemas']['ConditionSetRuleRead']; + +export const useSetPermissionsApi = () => { + const { authenticatedApiClient } = useClient(); + + const getSetPermissions = useCallback(async () => { + return await authenticatedApiClient().GET( + '/v2/facts/{proj_id}/{env_id}/set_rules', + ); + }, [authenticatedApiClient]); + + return useMemo( + () => ({ + getSetPermissions, + }), + [getSetPermissions], + ); +}; diff --git a/source/utils/reverse-attributes.ts b/source/utils/reverse-attributes.ts new file mode 100644 index 00000000..f262382b --- /dev/null +++ b/source/utils/reverse-attributes.ts @@ -0,0 +1,57 @@ +import { ConditionRef } from '../components/test/hooks/usePolicyABACSnapshot.js'; +import { v4 as uuidv4 } from 'uuid'; + +export function attributeBuilder({ + value, + condition, +}: { + value: string | number | object; + condition: string; +}) { + if ( + [ + 'equals', + 'contains', + 'greater-than-equals', + 'less-than-equals', + 'matches', + 'object-match', + 'is-subset-of', + 'is-superset-of', + 'intersect-with', + ].includes(condition) + ) { + return value; + } else if (['not-equals'].includes(condition)) { + return `not-equals-${value}`; + } else if (['greater-than'].includes(condition)) { + return parseInt(value as string) + 1; + } else if (['less-than'].includes(condition)) { + return parseInt(value as string) - 1; + } // more to come array + return value; +} + +export function getRefValue({ + attributes, + conditionMap, + ref, +}: { + attributes: Record; + conditionMap: Record; + ref: string; +}): string | number | object { + if (attributes[ref] !== undefined) { + return attributes[ref]; + } else if (conditionMap[ref] !== undefined) { + const attr = ref.split('.')[1] ?? ''; + const value = getRefValue({ attributes, conditionMap, ref: attr }); + attributes[ref] = attributeBuilder({ + value, + condition: conditionMap[ref].condition, + }); + return attributes[ref]; + } + attributes[ref] = uuidv4(); + return attributes[ref]; +} diff --git a/tests/components/init/GenerateUsersComponent.test.tsx b/tests/components/init/GenerateUsersComponent.test.tsx index 162ade28..b8ef40a3 100644 --- a/tests/components/init/GenerateUsersComponent.test.tsx +++ b/tests/components/init/GenerateUsersComponent.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, cleanup } from 'ink-testing-library'; import GenerateUsersComponent from '../../../source/components/init/GenerateUsersComponent.js'; -import { useGeneratePolicySnapshot } from '../../../source/components/test/hooks/usePolicySnapshot.js'; +import { useGeneratePolicyRBACSnapshot } from '../../../source/components/test/hooks/usePolicyRBACSnapshot.js'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { type Mock } from 'vitest'; @@ -33,7 +33,7 @@ vi.mock('../../../source/components/test/hooks/usePolicySnapshot.js', () => ({ describe('GenerateUsersComponent', () => { const mockOnComplete = vi.fn(); const mockOnError = vi.fn(); - const mockUseGeneratePolicySnapshot = useGeneratePolicySnapshot as Mock; + const mockUseGeneratePolicySnapshot = useGeneratePolicyRBACSnapshot as Mock; beforeEach(() => { vi.resetAllMocks(); From 025ec96f3d37db4e43cde04a584b3b67328bc24e Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 19 May 2025 22:34:47 +0530 Subject: [PATCH 2/7] fix: fix tests --- .../components/init/GenerateUsersComponent.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/components/init/GenerateUsersComponent.test.tsx b/tests/components/init/GenerateUsersComponent.test.tsx index b8ef40a3..00a0af86 100644 --- a/tests/components/init/GenerateUsersComponent.test.tsx +++ b/tests/components/init/GenerateUsersComponent.test.tsx @@ -25,10 +25,13 @@ vi.mock('ink-select-input', () => ({ }, })); -// Mock the usePolicySnapshot hook -vi.mock('../../../source/components/test/hooks/usePolicySnapshot.js', () => ({ - useGeneratePolicySnapshot: vi.fn(), -})); +// Mock the useRBACPolicySnapshot hook +vi.mock( + '../../../source/components/test/hooks/usePolicyRBACSnapshot.js', + () => ({ + useGeneratePolicySnapshot: vi.fn(), + }), +); describe('GenerateUsersComponent', () => { const mockOnComplete = vi.fn(); @@ -314,7 +317,7 @@ describe('GenerateUsersComponent', () => { expect(mockOnComplete).toHaveBeenCalledTimes(1); }); - it('passes correct snapshot options to usePolicySnapshot', () => { + it('passes correct snapshot options to useRBACPolicySnapshot', () => { render( Date: Tue, 27 May 2025 21:24:50 +0530 Subject: [PATCH 3/7] feat: add tests --- .../test/hooks/usePolicyABACSnapshot.ts | 2 +- ...Permissions.ts => useSetPermissionsApi.ts} | 0 .../init/GenerateUsersComponent.test.tsx | 2 +- tests/test/e2e.test.tsx | 653 +++++++++++++++++- 4 files changed, 643 insertions(+), 14 deletions(-) rename source/hooks/{useSetPermissions.ts => useSetPermissionsApi.ts} (100%) diff --git a/source/components/test/hooks/usePolicyABACSnapshot.ts b/source/components/test/hooks/usePolicyABACSnapshot.ts index 51094e8b..e567107a 100644 --- a/source/components/test/hooks/usePolicyABACSnapshot.ts +++ b/source/components/test/hooks/usePolicyABACSnapshot.ts @@ -10,7 +10,7 @@ import { import { ConditionSetRuleRead, useSetPermissionsApi, -} from '../../../hooks/useSetPermissions.js'; +} from '../../../hooks/useSetPermissionsApi.js'; import randomName from '@scaleway/random-name'; import { CreateTenantBody, diff --git a/source/hooks/useSetPermissions.ts b/source/hooks/useSetPermissionsApi.ts similarity index 100% rename from source/hooks/useSetPermissions.ts rename to source/hooks/useSetPermissionsApi.ts diff --git a/tests/components/init/GenerateUsersComponent.test.tsx b/tests/components/init/GenerateUsersComponent.test.tsx index 00a0af86..91cfe58d 100644 --- a/tests/components/init/GenerateUsersComponent.test.tsx +++ b/tests/components/init/GenerateUsersComponent.test.tsx @@ -29,7 +29,7 @@ vi.mock('ink-select-input', () => ({ vi.mock( '../../../source/components/test/hooks/usePolicyRBACSnapshot.js', () => ({ - useGeneratePolicySnapshot: vi.fn(), + useGeneratePolicyRBACSnapshot: vi.fn(), }), ); diff --git a/tests/test/e2e.test.tsx b/tests/test/e2e.test.tsx index 9ab1ee6c..6949190d 100644 --- a/tests/test/e2e.test.tsx +++ b/tests/test/e2e.test.tsx @@ -5,6 +5,8 @@ import { useRolesApi } from '../../source/hooks/useRolesApi.js'; import { useTenantApi } from '../../source/hooks/useTenantApi.js'; import { useResourcesApi } from '../../source/hooks/useResourcesApi.js'; import { useUserApi } from '../../source/hooks/useUserApi.js'; +import { useConditionSetApi } from '../../source/hooks/useConditionSetApi.js'; +import { useSetPermissionsApi } from '../../source/hooks/useSetPermissionsApi.js'; import delay from 'delay'; import { GeneratePolicySnapshot } from '../../source/components/test/GeneratePolicySnapshot.js'; import * as keytar from 'keytar'; @@ -25,6 +27,14 @@ vi.mock('../../source/hooks/useUserApi.js', () => ({ useUserApi: vi.fn(), })); +vi.mock('../../source/hooks/useSetPermissionsApi.js', () => ({ + useSetPermissionsApi: vi.fn(), +})); + +vi.mock('../../source/hooks/useConditionSetApi.js', () => ({ + useConditionSetApi: vi.fn(), +})); + vi.mock('keytar', () => { const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); @@ -41,18 +51,18 @@ vi.mock('keytar', () => { }); beforeEach(() => { - vi.restoreAllMocks(); + // vi.restoreAllMocks(); vi.spyOn(process, 'exit').mockImplementation(code => { console.warn(`Mocked process.exit(${code}) called`); }); }); afterEach(() => { - vi.restoreAllMocks(); + // vi.restoreAllMocks(); }); describe('GeneratePolicySnapshot', () => { - it('should complete dry run flow successfully', async () => { + it('should complete dry run flow successfully for RBAC', async () => { vi.mocked(useRolesApi).mockReturnValue({ getRoles: vi.fn(() => Promise.resolve({ @@ -91,21 +101,328 @@ describe('GeneratePolicySnapshot', () => { createUser: vi.fn(() => Promise.resolve({ error: null })), }); + vi.mocked(useConditionSetApi).mockReturnValue({ + getConditionSets: vi.fn((type: string) => { + if (type === 'userset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'user.department': { + equals: 'Engineering', + }, + }, + { + 'user.training_status': { + equals: 'certified', + }, + }, + { + 'user.key': { + 'not-equals': 'jay', + }, + }, + ], + }, + { + allOf: [ + { + 'user.key': { + equals: { + ref: 'user.email', + }, + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + if (type === 'resourceset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'resource.document_type': { + equals: 'classified', + }, + }, + { + 'resource.priority_level': { + equals: 'high', + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + }), + }); + + vi.mocked(useSetPermissionsApi).mockReturnValue({ + getSetPermissions: vi.fn(() => + Promise.resolve({ + data: [ + { + id: '899ad6af72b54f6287d7ac41798c4b53', + key: 'RD_Certified_Employee_on_Document2_query_HIGH_PRIORITY', + user_set: 'RD_Certified_Employee', + permission: 'Document2:query', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:38:03+00:00', + updated_at: '2025-04-20T18:38:03+00:00', + }, + { + id: '988f997a847c467a8558b8c942aac443', + key: 'RD_Certified_Employee_on_Document2_create___autogen_Document2', + user_set: 'RD_Certified_Employee', + permission: 'Document2:create', + resource_set: '__autogen_Document2', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:03:55+00:00', + updated_at: '2025-04-20T18:03:55+00:00', + }, + { + id: 'caa08402394d4b73bd0819986195abf7', + key: 'RD_Certified_Employee_on_Document_delete___autogen_Document', + user_set: 'RD_Certified_Employee', + permission: 'Document:delete', + resource_set: '__autogen_Document', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:02:09+00:00', + updated_at: '2025-04-20T18:02:09+00:00', + }, + { + id: 'ea481d9c33494b6eac336092d440f6b7', + key: '__autogen_admin_on_Document2_delete_HIGH_PRIORITY', + user_set: '__autogen_admin', + permission: 'Document2:delete', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T19:11:51+00:00', + updated_at: '2025-04-20T19:11:51+00:00', + }, + ], + }), + ), + }); + const { lastFrame } = render( , ); await delay(1000); // Allow steps to process - expect(lastFrame()).toMatch(/Roles found: 1/); - expect(lastFrame()).toMatch(/Created a new test tenant/); - expect(lastFrame()).toMatch(/Dry run mode!/); - - await delay(1500); - expect(process.exit).toHaveBeenCalledWith(1); + expect(lastFrame()).toMatch(/Building Config/); + await delay(1000); + expect(lastFrame()).toMatch(/"config":/); }, 3000); - it('should complete non-dry run and save to path', async () => { + it('should complete dry run flow successfully for ABAC', async () => { + vi.mocked(useRolesApi).mockReturnValue({ + getRoles: vi.fn(() => + Promise.resolve({ + data: [ + { + key: 'role-1', + name: 'Admin', + permissions: ['res1:read', 'res1:write'], + }, + ], + }), + ), + }); + + vi.mocked(useTenantApi).mockReturnValue({ + createTenant: vi.fn(() => Promise.resolve({ error: null })), + }); + + vi.mocked(useResourcesApi).mockReturnValue({ + getResources: vi.fn(() => + Promise.resolve({ + data: [ + { + key: 'res1', + actions: { + read: { key: 'read' }, + write: { key: 'write' }, + }, + }, + ], + }), + ), + }); + + vi.mocked(useUserApi).mockReturnValue({ + createUser: vi.fn(() => Promise.resolve({ error: null })), + }); + + vi.mocked(useConditionSetApi).mockReturnValue({ + getConditionSets: vi.fn((type: string) => { + if (type === 'userset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'user.department': { + equals: 'Engineering', + }, + }, + { + 'user.training_status': { + equals: 'certified', + }, + }, + { + 'user.key': { + 'not-equals': 'jay', + }, + }, + ], + }, + { + allOf: [ + { + 'user.key': { + equals: { + ref: 'user.email', + }, + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + if (type === 'resourceset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'resource.document_type': { + equals: 'classified', + }, + }, + { + 'resource.priority_level': { + equals: 'high', + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + }), + }); + + vi.mocked(useSetPermissionsApi).mockReturnValue({ + getSetPermissions: vi.fn(() => + Promise.resolve({ + data: [ + { + id: '899ad6af72b54f6287d7ac41798c4b53', + key: 'RD_Certified_Employee_on_Document2_query_HIGH_PRIORITY', + user_set: 'RD_Certified_Employee', + permission: 'Document2:query', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:38:03+00:00', + updated_at: '2025-04-20T18:38:03+00:00', + }, + { + id: '988f997a847c467a8558b8c942aac443', + key: 'RD_Certified_Employee_on_Document2_create___autogen_Document2', + user_set: 'RD_Certified_Employee', + permission: 'Document2:create', + resource_set: '__autogen_Document2', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:03:55+00:00', + updated_at: '2025-04-20T18:03:55+00:00', + }, + { + id: 'caa08402394d4b73bd0819986195abf7', + key: 'RD_Certified_Employee_on_Document_delete___autogen_Document', + user_set: 'RD_Certified_Employee', + permission: 'Document:delete', + resource_set: '__autogen_Document', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:02:09+00:00', + updated_at: '2025-04-20T18:02:09+00:00', + }, + { + id: 'ea481d9c33494b6eac336092d440f6b7', + key: '__autogen_admin_on_Document2_delete_HIGH_PRIORITY', + user_set: '__autogen_admin', + permission: 'Document2:delete', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T19:11:51+00:00', + updated_at: '2025-04-20T19:11:51+00:00', + }, + ], + }), + ), + }); + + const { lastFrame } = render( + , + ); + + await delay(1000); // Allow steps to process + + expect(lastFrame()).toMatch(/Building Config/); + await delay(2000); + expect(lastFrame()).toMatch(/"config":/); + }, 5000); + + it('should complete non-dry run and save to path RBAC', async () => { vi.mocked(useRolesApi).mockReturnValue({ getRoles: vi.fn(() => Promise.resolve({ @@ -143,6 +460,136 @@ describe('GeneratePolicySnapshot', () => { createUser: vi.fn(() => Promise.resolve({ error: null })), }); + vi.mocked(useConditionSetApi).mockReturnValue({ + getConditionSets: vi.fn((type: string) => { + if (type === 'userset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'user.department': { + equals: 'Engineering', + }, + }, + { + 'user.training_status': { + equals: 'certified', + }, + }, + { + 'user.key': { + 'not-equals': 'jay', + }, + }, + ], + }, + { + allOf: [ + { + 'user.key': { + equals: { + ref: 'user.email', + }, + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + if (type === 'resourceset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'resource.document_type': { + equals: 'classified', + }, + }, + { + 'resource.priority_level': { + equals: 'high', + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + }), + }); + + vi.mocked(useSetPermissionsApi).mockReturnValue({ + getSetPermissions: vi.fn(() => + Promise.resolve({ + data: [ + { + id: '899ad6af72b54f6287d7ac41798c4b53', + key: 'RD_Certified_Employee_on_Document2_query_HIGH_PRIORITY', + user_set: 'RD_Certified_Employee', + permission: 'Document2:query', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:38:03+00:00', + updated_at: '2025-04-20T18:38:03+00:00', + }, + { + id: '988f997a847c467a8558b8c942aac443', + key: 'RD_Certified_Employee_on_Document2_create___autogen_Document2', + user_set: 'RD_Certified_Employee', + permission: 'Document2:create', + resource_set: '__autogen_Document2', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:03:55+00:00', + updated_at: '2025-04-20T18:03:55+00:00', + }, + { + id: 'caa08402394d4b73bd0819986195abf7', + key: 'RD_Certified_Employee_on_Document_delete___autogen_Document', + user_set: 'RD_Certified_Employee', + permission: 'Document:delete', + resource_set: '__autogen_Document', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:02:09+00:00', + updated_at: '2025-04-20T18:02:09+00:00', + }, + { + id: 'ea481d9c33494b6eac336092d440f6b7', + key: '__autogen_admin_on_Document2_delete_HIGH_PRIORITY', + user_set: '__autogen_admin', + permission: 'Document2:delete', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T19:11:51+00:00', + updated_at: '2025-04-20T19:11:51+00:00', + }, + ], + }), + ), + }); + const { lastFrame } = render( { await delay(1000); // Wait for config to be written + expect(lastFrame()).toMatch(/Building Config/); + await delay(150); + expect(lastFrame()).toMatch(/Config saved to .*test-output/); + }); + it('should complete non-dry run and save to path ABAC', async () => { + vi.mocked(useRolesApi).mockReturnValue({ + getRoles: vi.fn(() => + Promise.resolve({ + data: [ + { + key: 'role-1', + name: 'Admin', + permissions: ['res1:read'], + }, + ], + }), + ), + }); + + vi.mocked(useTenantApi).mockReturnValue({ + createTenant: vi.fn(() => Promise.resolve({ error: null })), + }); + + vi.mocked(useResourcesApi).mockReturnValue({ + getResources: vi.fn(() => + Promise.resolve({ + data: [ + { + key: 'res1', + actions: { + read: { key: 'read' }, + }, + }, + ], + }), + ), + }); + + vi.mocked(useUserApi).mockReturnValue({ + createUser: vi.fn(() => Promise.resolve({ error: null })), + }); + + vi.mocked(useConditionSetApi).mockReturnValue({ + getConditionSets: vi.fn((type: string) => { + if (type === 'userset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'user.department': { + equals: 'Engineering', + }, + }, + { + 'user.training_status': { + equals: 'certified', + }, + }, + { + 'user.key': { + 'not-equals': 'jay', + }, + }, + ], + }, + { + allOf: [ + { + 'user.key': { + equals: { + ref: 'user.email', + }, + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + if (type === 'resourceset') { + return Promise.resolve({ + data: [ + { + conditions: { + allOf: [ + { + allOf: [ + { + 'resource.document_type': { + equals: 'classified', + }, + }, + { + 'resource.priority_level': { + equals: 'high', + }, + }, + ], + }, + ], + }, + }, + ], + }); + } + }), + }); + + vi.mocked(useSetPermissionsApi).mockReturnValue({ + getSetPermissions: vi.fn(() => + Promise.resolve({ + data: [ + { + id: '899ad6af72b54f6287d7ac41798c4b53', + key: 'RD_Certified_Employee_on_Document2_query_HIGH_PRIORITY', + user_set: 'RD_Certified_Employee', + permission: 'Document2:query', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:38:03+00:00', + updated_at: '2025-04-20T18:38:03+00:00', + }, + { + id: '988f997a847c467a8558b8c942aac443', + key: 'RD_Certified_Employee_on_Document2_create___autogen_Document2', + user_set: 'RD_Certified_Employee', + permission: 'Document2:create', + resource_set: '__autogen_Document2', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:03:55+00:00', + updated_at: '2025-04-20T18:03:55+00:00', + }, + { + id: 'caa08402394d4b73bd0819986195abf7', + key: 'RD_Certified_Employee_on_Document_delete___autogen_Document', + user_set: 'RD_Certified_Employee', + permission: 'Document:delete', + resource_set: '__autogen_Document', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T18:02:09+00:00', + updated_at: '2025-04-20T18:02:09+00:00', + }, + { + id: 'ea481d9c33494b6eac336092d440f6b7', + key: '__autogen_admin_on_Document2_delete_HIGH_PRIORITY', + user_set: '__autogen_admin', + permission: 'Document2:delete', + resource_set: 'HIGH_PRIORITY', + organization_id: 'edb57edcdb3d44ff94186cbb970374cc', + project_id: '95fa59ff60d54bbfafc14375a614911a', + environment_id: '3bda38fe46654a83888574eeb0ddd493', + created_at: '2025-04-20T19:11:51+00:00', + updated_at: '2025-04-20T19:11:51+00:00', + }, + ], + }), + ), + }); + + const { lastFrame } = render( + , + ); + + await delay(1000); // Wait for config to be written + + expect(lastFrame()).toMatch(/Building Config/); + await delay(150); expect(lastFrame()).toMatch(/Config saved to .*test-output/); - await delay(1500); - expect(process.exit).toHaveBeenCalledWith(1); }); }); From 198fa0102b4c7544ce66378228a0a688a5572af8 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 15 Jun 2025 21:43:12 +0530 Subject: [PATCH 4/7] fix: add suggestions --- source/commands/test/generate/e2e.tsx | 2 +- source/components/test/GeneratePolicySnapshot.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/source/commands/test/generate/e2e.tsx b/source/commands/test/generate/e2e.tsx index 91463bd1..f8317277 100644 --- a/source/commands/test/generate/e2e.tsx +++ b/source/commands/test/generate/e2e.tsx @@ -28,7 +28,7 @@ export const options = zod.object({ models: zod .array(zod.string()) .optional() - .default(['RBAC']) + .default(['RBAC', 'ABAC']) .describe( option({ description: diff --git a/source/components/test/GeneratePolicySnapshot.tsx b/source/components/test/GeneratePolicySnapshot.tsx index b1e52076..dc98b74e 100644 --- a/source/components/test/GeneratePolicySnapshot.tsx +++ b/source/components/test/GeneratePolicySnapshot.tsx @@ -87,7 +87,6 @@ export function GeneratePolicySnapshot({ ); useEffect(() => { - // console.log('IM MAIN', [RBACState, ABACState], models); const configsGenerated = [RBACState, ABACState].filter( state => state === 'done', ); From e60ce59fe5ef2ec1d816b5a643cd80da8b6a1c17 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 16 Jun 2025 20:23:48 +0530 Subject: [PATCH 5/7] feat: add documentation --- README.md | 18 ++++++++++++++++-- source/commands/test/generate/e2e.tsx | 4 ++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c94a2c6c..ff2ad4db 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Below is a categorized overview of all available Permit CLI commands: - [`permit test run audit`](#permit-test-run-audit) – Audit your policy decisions against recent logs. - [E2E Tests](#execute-e2e-tests) - - [`permit test generate e2e`](#permit-test-generate-e2e) – Generate end-to-end policy test configurations and (optionally) test data. + - [`permit test generate e2e`](#permit-test-generate-e2e) – Generate end-to-end policy test configurations, and (optionally) test data & test-snippets. ### [API-First Authorization](#api-first-authorization-1) @@ -1180,8 +1180,10 @@ Generate end‑to‑end test configurations (and optionally test data) for your - `--api-key ` - API Key to be used for test generation. - `--dry-run ` - If set, generates test cases and mock data **without** making any changes in Permit. -- `--models ` - List of model names to generate tests for. `default: RBAC`. +- `--models ` - List of model names to generate tests for. `default: RBAC` `Allowed values: ("RBAC", "ABAC")[]`. - `--path ` - Filesystem path where the generated JSON config should be saved (recommended). +- `--snippet ` - If set, generates a code snippet for the test. The snippet will be printed to the terminal. `Allowed values: 'pytest' | 'jest', 'vitest'` +- `--snippet-path ` - If set, saves the generated code snippet to the specified file path. > Note: All flags are optional. If you omit `--models`, only the default RBAC model will be processed. If you omit `--dry-run`, real data and users will be created in Permit. @@ -1193,6 +1195,12 @@ Generate tests for the default RBAC model, and save the config to disk. Creates $ permit test generate e2e --models=RBAC --path=logb.json ``` +Generate tests for both RBAC and ABAC models, and save the config to disk. Creates end‑to‑end tests for the `RBAC` & `ABAC` model and writes the generated JSON config to `logb.json` + +```bash + $ permit test generate e2e --models=RBAC --models=ABAC --path=logb.json +``` + Generate tests for RBAC, save the config, but don't apply changes (dry run). This is the same as above, but in dry‑run mode, so no changes are made in Permit. ```bash @@ -1211,6 +1219,12 @@ Generate and apply tests for the RBAC model with default settings. Runs end‑to $ permit test generate e2e --models=RBAC ``` +Generate tests for the default RBAC model, and save the config to disk. Creates end‑to‑end tests for the `RBAC` model and writes the generated JSON config to `logb.json`. Also generates a code snippet for the test in `pytest` format and saves it to `test_policy.py`. + +```bash + $ permit test generate e2e --models=RBAC --path=logb.json --snippet=pytest --snippet-path=test_policy.py +``` + ## API-First Authorization Define and enforce API authorization policies using OpenAPI specifications for a smooth API integration. diff --git a/source/commands/test/generate/e2e.tsx b/source/commands/test/generate/e2e.tsx index f8317277..1c392b3a 100644 --- a/source/commands/test/generate/e2e.tsx +++ b/source/commands/test/generate/e2e.tsx @@ -26,9 +26,9 @@ export const options = zod.object({ }), ), models: zod - .array(zod.string()) + .array(zod.enum(['RBAC', 'ABAC'])) .optional() - .default(['RBAC', 'ABAC']) + .default(['RBAC']) .describe( option({ description: From 35a58d38b05e1f61430d0bff0f95bed6e9139d4b Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 16 Jun 2025 20:34:21 +0530 Subject: [PATCH 6/7] feat: add sample outputs --- README.md | 149 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ff2ad4db..2d2c8373 100644 --- a/README.md +++ b/README.md @@ -1225,37 +1225,136 @@ Generate tests for the default RBAC model, and save the config to disk. Creates $ permit test generate e2e --models=RBAC --path=logb.json --snippet=pytest --snippet-path=test_policy.py ``` -## API-First Authorization +### Example outputs: -Define and enforce API authorization policies using OpenAPI specifications for a smooth API integration. +#### For RBAC, the generated JSON config might look like this: -### URL-based Permissions - -Map API endpoints to policies using simple configurations and FastAPI decorators - -#### `permit pdp check-url` - -Check if a user has permission to access a specific URL. The command verifies URL-based permissions against the PDP using the Permit.io URL authorization API. - -**Arguments (Required):** - -- `--user ` - the user id to check permissions for (Required) -- `--url ` - the URL to check permissions for (Required) - -**Arguments (Optional):** +```json +{ + "config": [ + { + "user": "dreamyshannon", + "action": "read", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + }, + { + "user": "dreamyshannon", + "action": "create", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + } + ], + "users": [ + { + "key": "dreamyshannon", + "email": "dreamyshannon@gmail.com", + "firstName": "dreamy", + "lastName": "shannon", + "roles": [] + } + ] +} +``` -- `--method ` - the HTTP method to check permissions for (default: `GET`) -- `--tenant ` - the tenant to check permissions for (default: `default`) -- `--user-attributes ` - additional user attributes to enrich the authorization check in the format `key1:value1,key2:value2`. Can be specified multiple times. -- `--pdp-url ` - the PDP URL to check authorization against (default: Cloud PDP) -- `--api-key ` - the API key for the Permit env, project or Workspace +#### For ABAC, the generated JSON config might look like this: -**Examples:** +```json +{ + "config": [ + { + "user": { + "key": "angrygoodall", + "attributes": { + "department": "Engineering", + "training_status": "certified", + "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", + "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" + } + }, + "resource": { + "type": "Document2", + "attributes": { + "document_type": "classified", + "priority_level": "high" + }, + "tenant": "test-tenant-hardcorebose" + }, + "action": "query", + "result": true + } + ], + "users": [ + { + "key": "angrygoodall", + "email": "angrygoodall@gmail.com", + "firstName": "angry", + "lastName": "goodall", + "roles": [] + } + ] +} +``` -Basic URL permission check: +#### For combined (both RBAC and ABAC), the generated JSON config might look like this: -```bash -$ permit pdp check-url --user john@example.com --url https://api.example.com/orders +```json +{ + "config": [ + { + "user": { + "key": "angrygoodall", + "attributes": { + "department": "Engineering", + "training_status": "certified", + "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", + "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" + } + }, + "resource": { + "type": "Document2", + "attributes": { + "document_type": "classified", + "priority_level": "high" + }, + "tenant": "test-tenant-hardcorebose" + }, + "action": "query", + "result": true + }, + { + "user": "dreamyshannon", + "action": "read", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + } + ], + "users": [ + { + "key": "angrygoodall", + "email": "angrygoodall@gmail.com", + "firstName": "angry", + "lastName": "goodall", + "roles": [] + }, + { + "key": "dreamyshannon", + "email": "dreamyshannon@gmail.com", + "firstName": "dreamy", + "lastName": "shannon", + "roles": [] + } + ] +} ``` Check with specific HTTP method and tenant: From cdc9e7d27c4738e8139bacb3f0e08f199f10e4ee Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 16 Jun 2025 20:36:58 +0530 Subject: [PATCH 7/7] feat: lint --- README.md | 218 +++++++++++++++++++++++++++--------------------------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 2d2c8373..a4770b07 100644 --- a/README.md +++ b/README.md @@ -1231,35 +1231,35 @@ Generate tests for the default RBAC model, and save the config to disk. Creates ```json { - "config": [ - { - "user": "dreamyshannon", - "action": "read", - "resource": { - "type": "Document2", - "tenant": "test-tenant-modestritchie" - }, - "result": false - }, - { - "user": "dreamyshannon", - "action": "create", - "resource": { - "type": "Document2", - "tenant": "test-tenant-modestritchie" - }, - "result": false - } - ], - "users": [ - { - "key": "dreamyshannon", - "email": "dreamyshannon@gmail.com", - "firstName": "dreamy", - "lastName": "shannon", - "roles": [] - } - ] + "config": [ + { + "user": "dreamyshannon", + "action": "read", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + }, + { + "user": "dreamyshannon", + "action": "create", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + } + ], + "users": [ + { + "key": "dreamyshannon", + "email": "dreamyshannon@gmail.com", + "firstName": "dreamy", + "lastName": "shannon", + "roles": [] + } + ] } ``` @@ -1267,38 +1267,38 @@ Generate tests for the default RBAC model, and save the config to disk. Creates ```json { - "config": [ - { - "user": { - "key": "angrygoodall", - "attributes": { - "department": "Engineering", - "training_status": "certified", - "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", - "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" - } - }, - "resource": { - "type": "Document2", - "attributes": { - "document_type": "classified", - "priority_level": "high" - }, - "tenant": "test-tenant-hardcorebose" - }, - "action": "query", - "result": true - } - ], - "users": [ - { - "key": "angrygoodall", - "email": "angrygoodall@gmail.com", - "firstName": "angry", - "lastName": "goodall", - "roles": [] - } - ] + "config": [ + { + "user": { + "key": "angrygoodall", + "attributes": { + "department": "Engineering", + "training_status": "certified", + "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", + "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" + } + }, + "resource": { + "type": "Document2", + "attributes": { + "document_type": "classified", + "priority_level": "high" + }, + "tenant": "test-tenant-hardcorebose" + }, + "action": "query", + "result": true + } + ], + "users": [ + { + "key": "angrygoodall", + "email": "angrygoodall@gmail.com", + "firstName": "angry", + "lastName": "goodall", + "roles": [] + } + ] } ``` @@ -1306,54 +1306,54 @@ Generate tests for the default RBAC model, and save the config to disk. Creates ```json { - "config": [ - { - "user": { - "key": "angrygoodall", - "attributes": { - "department": "Engineering", - "training_status": "certified", - "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", - "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" - } - }, - "resource": { - "type": "Document2", - "attributes": { - "document_type": "classified", - "priority_level": "high" - }, - "tenant": "test-tenant-hardcorebose" - }, - "action": "query", - "result": true - }, - { - "user": "dreamyshannon", - "action": "read", - "resource": { - "type": "Document2", - "tenant": "test-tenant-modestritchie" - }, - "result": false - } - ], - "users": [ - { - "key": "angrygoodall", - "email": "angrygoodall@gmail.com", - "firstName": "angry", - "lastName": "goodall", - "roles": [] - }, - { - "key": "dreamyshannon", - "email": "dreamyshannon@gmail.com", - "firstName": "dreamy", - "lastName": "shannon", - "roles": [] - } - ] + "config": [ + { + "user": { + "key": "angrygoodall", + "attributes": { + "department": "Engineering", + "training_status": "certified", + "key": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f", + "email": "c65e70d8-d50b-4ac2-8f0c-ad14ae695d0f" + } + }, + "resource": { + "type": "Document2", + "attributes": { + "document_type": "classified", + "priority_level": "high" + }, + "tenant": "test-tenant-hardcorebose" + }, + "action": "query", + "result": true + }, + { + "user": "dreamyshannon", + "action": "read", + "resource": { + "type": "Document2", + "tenant": "test-tenant-modestritchie" + }, + "result": false + } + ], + "users": [ + { + "key": "angrygoodall", + "email": "angrygoodall@gmail.com", + "firstName": "angry", + "lastName": "goodall", + "roles": [] + }, + { + "key": "dreamyshannon", + "email": "dreamyshannon@gmail.com", + "firstName": "dreamy", + "lastName": "shannon", + "roles": [] + } + ] } ```