Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Attribute-Based Access Control (ABAC) in Permit Check #24

Merged
merged 6 commits into from
Nov 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 106 additions & 41 deletions source/commands/pdp/check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,83 @@ import Spinner from 'ink-spinner';
import { keyAccountOption } from '../../options/keychain.js';
import * as keytar from 'keytar';
import { inspect } from 'util';
import { parseAttributes } from '../../utils/attributes.js';

export const options = zod.object({
user: zod
.string()
.min(1, 'User identifier cannot be empty')
.describe(
option({ description: 'Unique Identity to check for', alias: 'u' }),
option({
description: 'Unique Identity to check for (Required)',
alias: 'u',
}),
),
userAttributes: zod
.string()
.optional()
.describe(
option({
description:
'User attributes in format key1:value1,key2:value2 (Optional)',
alias: 'ua',
}),
),
resource: zod
.string()
.describe(option({ description: 'Resource being accessed', alias: 'r' })),
action: zod.string().describe(
option({
description: 'Action being performed on the resource by the user',
alias: 'a',
}),
),
.min(1, 'Resource cannot be empty')
.describe(
option({
description: 'Resource being accessed (Required)',
alias: 'r',
}),
),
resourceAttributes: zod
.string()
.optional()
.describe(
option({
description:
'Resource attributes in format key1:value1,key2:value2 (Optional)',
alias: 'ra',
}),
),
action: zod
.string()
.min(1, 'Action cannot be empty')
.describe(
option({
description:
'Action being performed on the resource by the user (Required)',
alias: 'a',
}),
),
tenant: zod
.string()
.optional()
.default('default')
.describe(
option({
description: 'the tenant the resource belongs to',
description:
'The tenant the resource belongs to (Optional, defaults to "default")',
alias: 't',
}),
),
pdpurl: string()
.optional()
.describe(
option({
description: 'The URL of the PDP service. Default to the cloud PDP.',
description:
'The URL of the PDP service. Default to the cloud PDP. (Optional)',
}),
),
apiKey: zod
.string()
.optional()
.describe(
option({
description: 'The API key for the Permit env, project or Workspace',
description:
'The API key for the Permit env, project or Workspace (Optional)',
}),
),
keyAccount: keyAccountOption,
Expand All @@ -61,37 +99,59 @@ interface AllowedResult {

export default function Check({ options }: Props) {
const [error, setError] = React.useState('');
// result of API
const [res, setRes] = React.useState<AllowedResult>({ allow: undefined });

const queryPDP = async (apiKey: string) => {
const response = await fetch(`${options.pdpurl || CLOUD_PDP_URL}/allowed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
user: { key: options.user },
resource: {
type: options.resource.includes(':')
? options.resource.split(':')[0]
: options.resource,
key: options.resource.includes(':')
? options.resource.split(':')[1]
: '',
tenant: options.tenant,
try {
const userAttrs = options.userAttributes
? parseAttributes(options.userAttributes)
: {};
const resourceAttrs = options.resourceAttributes
? parseAttributes(options.resourceAttributes)
: {};

const response = await fetch(
`${options.pdpurl || CLOUD_PDP_URL}/allowed`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
user: {
key: options.user,
...userAttrs,
},
resource: {
type: options.resource.includes(':')
? options.resource.split(':')[0]
: options.resource,
key: options.resource.includes(':')
? options.resource.split(':')[1]
: '',
tenant: options.tenant,
...resourceAttrs,
},
action: options.action,
}),
},
action: options.action,
}),
});
);

if (!response.ok) {
setError(await response.text());
return;
}
if (!response.ok) {
const errorText = await response.text();
setError(errorText);
return;
}

setRes(await response.json());
setRes(await response.json());
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(String(err));
}
}
};

React.useEffect(() => {
Expand All @@ -109,10 +169,15 @@ export default function Check({ options }: Props) {

return (
<>
{/* The following text adheres to react/no-unescaped-entities rule */}
<Text>
Checking user=&quot;{options.user}&quot; action={options.action}{' '}
resource=
{options.resource} at tenant={options.tenant}
Checking user=&quot;{options.user}&quot;
{options.userAttributes && ` with attributes=${options.userAttributes}`}
action={options.action} resource=
{options.resource}
{options.resourceAttributes &&
` with attributes=${options.resourceAttributes}`}
at tenant={options.tenant}
</Text>
{res.allow === true && (
<>
Expand All @@ -129,10 +194,10 @@ export default function Check({ options }: Props) {
</>
)}
{res.allow === false && <Text color={'red'}> DENIED</Text>}
{res.allow === undefined && error === null && <Spinner type="dots" />}
{res.allow === undefined && error === '' && <Spinner type="dots" />}
{error && (
<Box>
<Text color="red">Request failed: {JSON.stringify(error)}</Text>
<Text color="red">Request failed: {error}</Text>
<Newline />
<Text>{JSON.stringify(res)}</Text>
</Box>
Expand Down
61 changes: 61 additions & 0 deletions source/utils/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// source/utils/attributes.ts

class AttributeParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'AttributeParseError';
}
}

export function parseAttributes(
attrString: string,
): Record<string, string | number | boolean> {
if (!attrString || attrString.trim() === '') {
return {};
}

const attributes: Record<string, string | number | boolean> = {};

const pairs = attrString.split(',');
for (const pair of pairs) {
const parts = pair.split(':');

// Validate the pair format
if (parts.length !== 2) {
throw new AttributeParseError(
`Invalid attribute format: "${pair}". Expected format "key:value"`,
);
}

const [key, value] = parts.map(s => s.trim());

// Validate key
if (!key) {
throw new AttributeParseError('Attribute key cannot be empty');
}

// Validate value
if (value === undefined || value === '') {
throw new AttributeParseError(`Value for key "${key}" cannot be empty`);
}

// Parse the value into appropriate type
try {
if (value.toLowerCase() === 'true') {
attributes[key] = true;
} else if (value.toLowerCase() === 'false') {
attributes[key] = false;
} else if (!isNaN(Number(value)) && value.trim() !== '') {
attributes[key] = Number(value);
} else {
attributes[key] = value;
}
} catch (error) {
throw new AttributeParseError(
`Failed to parse value for key "${key}": ${(error as Error).message}`,
);
}
}

return attributes;
}
122 changes: 122 additions & 0 deletions tests/e2e/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// tests/e2e/check.test.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import { describe, it, expect } from 'vitest';

const execAsync = promisify(exec);
const CLI_COMMAND = 'npx tsx ./source/cli pdp check';

describe('pdp check command e2e', () => {
// Test original functionality remains intact
describe('backwards compatibility', () => {
it('should work with basic required parameters', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read`
);
expect(stdout).toContain('user="testUser"');
expect(stdout).toContain('action=read');
expect(stdout).toContain('resource=testResource');
},10000);

it('should work with optional tenant parameter', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read "tenant" "customTenant"`
);
expect(stdout).toContain('DENIED');
});

it('should work with resource type:key format', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r "document:doc123" -a read`
);
expect(stdout).toContain('resource=document:doc123');
});
});

// Test new attribute functionality
describe('user attributes', () => {
it('should handle single user attribute', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ua "role:admin"`
);
expect(stdout).toContain('DENIED');
});

it('should handle multiple user attributes', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ua "role:admin,department:IT,level:5"`
);
expect(stdout).toContain('DENIED');
});

it('should handle user attributes with different types', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ua "isAdmin:true,age:25,name:john"`
);
expect(stdout).toContain('DENIED');
});
});

describe('resource attributes', () => {
it('should handle single resource attribute', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ra "owner:john"`
);
expect(stdout).toContain('DENIED');
});

it('should handle multiple resource attributes', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ra "owner:john,status:active,priority:high"`
);
expect(stdout).toContain('DENIED');
});

it('should handle resource attributes with different types', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ra "isPublic:true,size:1024,type:document"`
);
expect(stdout).toContain('DENIED');
});
});

describe('combined scenarios', () => {
it('should handle both user and resource attributes', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r testResource -a read -ua "role:admin" -ra "status:active"`
);
expect(stdout).toContain('DENIED');
});

it('should work with all parameters combined', async () => {
const { stdout } = await execAsync(
`${CLI_COMMAND} -u testUser -r "document:doc123" -a write "tenant" customTenant -ua "role:admin,dept:IT" -ra "status:active,size:1024"`
);
expect(stdout).toContain('DENIED');
});
});

describe('error handling', () => {
it('should handle invalid user attribute format', async () => {
try{
const { stderr } = await execAsync(
`${CLI_COMMAND} -u johnexample.com -r "document"`,
{ encoding: 'utf8' }
);}
catch (error) {
expect(error.stderr).toContain('');
}
});

it('should handle invalid resource attribute format', async () => {
try {
await execAsync(
`${CLI_COMMAND} -u johnexample.com -r "document"`,

);
} catch (error) {
expect(error.stderr).toContain('');
}
},10000);
});
});
Loading