Skip to content
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
59 changes: 59 additions & 0 deletions demo-application/public/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,65 @@ paths:
security:
- bearerHttpAuthentication: [ ]
- cookieAuthentication: [ ]
patch:
summary: Benutzer updaten
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: Name des Benutzers
example: Max Mustermann
id:
type: string
description: ID des Benutzers
x-act:
resource-name: User
resource-access: update
password:
type: string
format: password
description: Passwort des Benutzers
example: secretpassword
responses:
'201':
description: Benutzer erfolgreich erstellt
content:
application/json:
schema:
type: object
properties:
id:
type: integer
example: 1
name:
type: string
example: Max Mustermann
email:
type: string
example: max@beispiel.de
'400':
description: Ungültige Eingabe
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: object
properties:
message:
type: string
example: "Ungültige E-Mail-Adresse"
security:
- bearerHttpAuthentication: [ ]
- cookieAuthentication: [ ]
/admin/users/{id}:
get:
summary: Einzelner Benutzer
Expand Down
13 changes: 13 additions & 0 deletions demo-application/start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ router
return User.query()
})

router.patch('/users', async ({ request, response }) => {
// no check if user can be accessed
// for now just a dummy endpoint for changing some user data

const { id } = request.body()

if (!id) {
return response.status(400).send({})
}

return User.findOrFail(id)
})

router.get('/users/:id', async ({ params }) => {
return User.findOrFail(params.id)
})
Expand Down
98 changes: 76 additions & 22 deletions src/core/parsers/openapi-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@
type SpecificationUrl = ConstructorParameters<typeof OASNormalize>[0];
type SecurityScheme = KeyedSecuritySchemeObject;

export type ResourceLocationDescriptor = {
resourceName: string;
resourceAccess: string;
parameterName?: string;
parameterLocation?: ParameterLocation;
};

export class OpenAPIParser {
private constructor(
private readonly openApiSource: Oas,
private readonly apiBaseUrl: string,
) {} // private specificationPath: ConstructorParameters<typeof OASNormalize>[0],
) {}

/**
* Parses the OpenAPI specification and returns a new instance of the
Expand Down Expand Up @@ -197,21 +204,70 @@
: paths;

return filteredPaths.map((path) => {
const parameters = path.getParameters();

const parametrizedResources =
parameters.map((parameter) => ({
parameterName: parameter.name,
parameterLocation: parameter.in,
resourceName: getOpenApiField(
parameter,
OpenApiFieldNames.RESOURCE_NAME,
) as string, // todo: replace this with zod parsing
resourceAccess: getOpenApiField(
parameter,
OpenApiFieldNames.RESOURCE_ACCESS,
) as string,
})) ?? [];
// todo: getParametersAsJSONSchema seems to return null (which is not present in the type definition), open an issue

// unfortunately, getParametersAsJSONSchema strips x-act fields for other parameter types such as path or query
// this is why there are separate calls for requestBody and parameters
// todo: maybe find a better way to handle this/open an issue
const resourcesFromRequestBody = path.hasRequestBody()
? path.getParametersAsJSONSchema().flatMap((parameterType) => {
const parameterLocation = parameterType.type as ParameterLocation;

if (
!parameterType.schema.properties ||
parameterLocation !== "body"
) {
return [];
}

return Object.entries(parameterType.schema.properties).flatMap(
([parameterName, property]) => {
if (typeof property !== "object") {
return [];
}

const resourceName = getOpenApiField(
property,
OpenApiFieldNames.RESOURCE_NAME,
) as string;

const resourceAccess = getOpenApiField(
property,
OpenApiFieldNames.RESOURCE_ACCESS,
) as string;

if (!resourceName || !resourceAccess) {
return [];
}

return {
resourceName,
resourceAccess,
parameterName,
parameterLocation,
};
},
);
})
: [];

const resourcesFromParameters = path.getParameters().map((parameter) => ({
parameterName: parameter.name,
parameterLocation: parameter.in,
resourceName: getOpenApiField(
parameter,
OpenApiFieldNames.RESOURCE_NAME,
) as string, // todo: replace this with zod parsing
resourceAccess: getOpenApiField(
parameter,
OpenApiFieldNames.RESOURCE_ACCESS,
) as string,
}));

const parametrizedResources = [
...resourcesFromRequestBody,
...resourcesFromParameters,
];

// todo: at the moment it is considered that there can be at most one non-parametrized resource per path (e.g. /users)
const nonParametrizedResourceName = getOpenApiField(
Expand All @@ -235,12 +291,10 @@
const securityRequirements = path.getSecurity();
const isPublicPath = securityRequirements.length === 0;

const resources: Array<{
resourceName: string;
resourceAccess: string;
parameterName?: string;
parameterLocation?: string;
}> = [...parametrizedResources, ...nonParametrizedResources];
const resources: Array<ResourceLocationDescriptor> = [
...parametrizedResources,
...nonParametrizedResources,
];

return {
path: path.path,
Expand Down Expand Up @@ -361,7 +415,7 @@
}

if (
authenticatorType === AuthenticatorType.API_KEY_COOKIE &&

Check warning on line 418 in src/core/parsers/openapi-parser.ts

View workflow job for this annotation

GitHub Actions / Run all tests

Unnecessary conditional, comparison is always true, since `AuthenticatorType.API_KEY_COOKIE === AuthenticatorType.API_KEY_COOKIE` is true
"name" in securityScheme
) {
const authResponseParameterDescription = {
Expand All @@ -372,7 +426,7 @@
// todo: check if parameterLocation is of type ParameterLocation
if (
!authResponseParameterDescription.parameterName ||
!authResponseParameterDescription.parameterLocation

Check warning on line 429 in src/core/parsers/openapi-parser.ts

View workflow job for this annotation

GitHub Actions / Run all tests

Unnecessary conditional, value is always falsy
) {
throw new Error(
"Could not find parameter name (name) or parameter location (in) for Cookie Authentication",
Expand Down Expand Up @@ -442,7 +496,7 @@
httpMethod as HttpMethods,
);

if (!operation) {

Check warning on line 499 in src/core/parsers/openapi-parser.ts

View workflow job for this annotation

GitHub Actions / Run all tests

Unnecessary conditional, value is always falsy
throw new Error("Operation not found");
}

Expand Down
18 changes: 16 additions & 2 deletions src/core/tests/runner/test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import chalk from "chalk";
import CliTable3 from "cli-table3";
import type { User } from "../../policy/entities/user.ts";
import type { Route } from "../test-utils.ts";
import type { RequestBody, Route } from "../test-utils.ts";

export type AccessControlResult = "permitted" | "denied";

export type TestResult = {
user: User | null;
route: Route;
requestBody?: RequestBody;
expected: AccessControlResult;
actual?: AccessControlResult;
result?: "✅" | "❌" | "⏭️";
Expand Down Expand Up @@ -53,6 +54,14 @@ export abstract class TestRunner {

abstract run(testCases: Array<TestCase>): Promise<void>;

protected formatRequestBody(body: object): string {
try {
return chalk.cyan(JSON.stringify(body, null, 2));
} catch {
return chalk.red("Invalid JSON");
}
}

protected printReport(): void {
console.log("\n=== Test Report ===");

Expand All @@ -69,9 +78,14 @@ export abstract class TestRunner {
});

this.testResults.forEach((result) => {
const requestInfo =
typeof result.requestBody === "object"
? `${result.route}\n${this.formatRequestBody(result.requestBody)}`
: result.route.toString();

reportTable.push([
result.user?.toString() ?? "anonymous",
result.route.toString(),
requestInfo,
result.expected,
result.actual,
result.statusCode,
Expand Down
30 changes: 12 additions & 18 deletions src/core/tests/test-case-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import type {
} from "../policy/types.ts";
import { removeObjectDuplicatesFromArray } from "../utils.ts";
import type { TestCase } from "./runner/test-runner.ts";
import { Route } from "./test-utils.ts";
import { createRequestData, Route } from "./test-utils.ts";
import { TestCaseExecutor } from "./testcase-executor.ts";

export type TestCombination = {
user: User | null;
route: Route;
requestBody?: object;
expectedRequestToBeAllowed: boolean;
};

Expand Down Expand Up @@ -117,27 +118,19 @@ export class TestCaseGenerator {
path,
currentResource.parameterName,
) &&
resourceIdentifier == undefined;
resourceIdentifier === undefined;

if (resourceIdentifierRequiredButNotProvided) {
return [];
}

// todo: currently only parameterLocation path supported
// function should support parameterLocation, parameterName and parameterValue

// resourceIdentifier can be undefined when resource access is create for instance
// or when access for all resources of a type is described
const expandedPath =
resourceIdentifier == undefined ||
currentResource.parameterName === undefined ||
currentResource.parameterLocation === undefined
? path
: OpenAPIParser.expandUrlTemplate(path, {
[currentResource.parameterName]: resourceIdentifier,
}); // todo: for multiple resources and therefore parameters, multiple keys in object -> dynamic mapping required

const url = this.openApiParser.constructFullApiUrl(expandedPath);
const { route, requestBody } = createRequestData({
path,
method,
currentResource,
resourceIdentifier,
openApiParser: this.openApiParser,
});

const expectedRequestToBeAllowed = PolicyDecisionPoint.isAllowed(
user,
Expand All @@ -148,7 +141,8 @@ export class TestCaseGenerator {

return {
user,
route: new Route(url, method),
route,
requestBody,
expectedRequestToBeAllowed,
resourceAction,
};
Expand Down
Loading
Loading