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

Add SCIM V2 PATCH compilePath support; fix bug in ABNF file. #7

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const results = [{ userName: "somebody123" }, { userName: "somebody456" }]

## Description

This implements a parser and compiler for the [filtering](https://tools.ietf.org/html/rfc7644#section-3.4.2.2) and [sorting](https://tools.ietf.org/html/rfc7644#section-3.4.2.3) features defined in System for Cross-Domain Identity Management (SCIM) Protocol 2.0. It was originally built for use by [AuthX](https://github.com/the-control-group/authx);
This implements a parser and compiler for the [filtering](https://tools.ietf.org/html/rfc7644#section-3.4.2.2), [sorting](https://tools.ietf.org/html/rfc7644#section-3.4.2.3), and [path](https://tools.ietf.org/html/rfc7644#section-3.5.2) features defined in System for Cross-Domain Identity Management (SCIM) Protocol 2.0. It was originally built for use by [AuthX](https://github.com/the-control-group/authx);

## Methods & Properties

Expand All @@ -23,3 +23,7 @@ Compile a SCIM filter expression into a function.
### `compileSorter(input: string): (a: any, b: any) => -1 | 0 | 1`

Compile a SCIM sort expression into a function.

### `compilePath(input: string): { path: string, filter?: Expression, subpath?: string }`

Compile a SCIM PATCH path into a path, with an optional subpath and filter expression function. The subpath will only be present if there's a filter separating it from the path. Otherwise, the path includes the subpath. The compiled path may then be used to differentiate paths with or without filters, subpaths, etc.
4 changes: 3 additions & 1 deletion grammar.abnf
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
patchPath = (attributeGroup *1("." attributePathSegment)) / attributePath
Copy link
Author

@jaylattice jaylattice Feb 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used parenthesis here because apparently apg-lib's precedence rules are contradictory to RFC 5234 3.10.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fascinating and great catch on the precedence bug.


filter = infixLogicalExpression / expression

expression = precedenceGroup / attributeGroup / prefixLogicalExpression / postfixAssertion / infixAssertion
Expand All @@ -20,7 +22,7 @@ infixAssertion = attributePath SP infixAssertionOperator SP infixAssertionValue
infixAssertionOperator = "eq" / "ne" / "co" / "sw" / "ew" / "gt" / "lt" / "ge" / "le"
infixAssertionValue = null / true / false / number / string

attributePath = [URI ":"] attributePathSegment *("." attributePathSegment)
attributePath = [URI ":"] attributePathSegment *1("." attributePathSegment)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the bug; please see RFC 7644 3.4.2.2. In short, we need an upper bound of 1 on the number of allowed attributePathSegment matches.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, this was actually a deliberate deviation from the spec for an internal use-case, and I should have documented the difference. However, the use-case has since disappeared so it's probably appropriate to revert to spec-compliant behavior (even if I personally dislike the arbitrary depth limit).

Because this deviation was never documented I'll consider this as a "bug fix" and not a major breaking change, as we shouldn't have been depending on this behavior.

attributePathSegment = ALPHA *("-" / "_" / DIGIT / ALPHA)

; rfc7159
Expand Down
1,252 changes: 633 additions & 619 deletions grammar.js

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "scim-query-filter-parser",
"version": "2.0.4",
"description": "Parser for SCIM Filter Query Strings",
"description": "Parser for SCIM V2 filters and paths",
"main": "dist/index.js",
"scripts": {
"format": "prettier --list-different --write '**/*.{json,yml,md,ts}'",
Expand All @@ -21,7 +21,12 @@
"scim",
"scimplecloud",
"parser",
"filter"
"filter",
"path",
"patch",
"scimv2",
"rfc7644",
"rfc7643"
],
"author": "Mike Marcacci <[email protected]>",
"license": "MIT",
Expand Down
19 changes: 17 additions & 2 deletions src/Yard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
type Expression = (data: any) => boolean;
export type Expression = (data: any) => boolean;

export type SimplePath = {
path: string;
filter: null;
subpath: null;
};

export type FilterPath = {
path: string;
filter: Expression;
subpath: string | null;
};
Comment on lines +3 to +13
Copy link
Member

@mike-marcacci mike-marcacci Feb 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to have to think about this one. Version 2 of this library is built around the paradigm of returning code that "executes the query" rather than returning "information about the query".

The reason for this is to maximize the amount of spec compliance this library can provide (traversing the object correctly and safely, for example, is not exactly trivial).

Instinctually, I would rather return set and get functions which could be used by your code to modify the object (or perhaps return a modified copy of the object)... but I also don't want to force the pattern here when doing so makes the library less usable.

Would this kind of strategy work for your use-case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you feel about an API that compiled a PATCH path into the following:

compilePatch(input: string): function<T=unknown, M=unknown>(
	data: T,
	modifier: (before: M, path: string[]) => M
): T

This would allow your modifier to make any path-specific decisions without having to actually traverse the object. The library could also enforce a copy-on-write policy such that the original data object is never modified.

Copy link
Author

@jaylattice jaylattice Feb 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to have to think about this one. Version 2 of this library is built around the paradigm of returning code that "executes the query" rather than returning "information about the query".

V3 "Pluggable compilers" could be built around the paradigm of supporting arbitrary paradigms! We could do a minor refactor, then ship this library with some out-of-the-box compilers including:

// "safe, avoid footgun, at the expense of performance" paradigm.
compileFilterToExpression() // what V2 does
// less safe, but more versatile
compileFilterToAST() // what V1 did
compileAttributePathToFieldArray() // what V2's parseAttributePath does

This would not only support all existing functionality in V1 and V2, but also allow devs like me to implement custom compilers without submitting PRs!

Copy link
Author

@jaylattice jaylattice Feb 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for your example, I'm not sure what you mean with modifier. Do you mind elaborating with a simple example of how that would be used by the caller? I would imagine, following the same "executable" pattern of compilerFilter, compilePatch would be used like:

// simplified for brevity
const myUserResource = getSCIMUserResource(...);
const patchOps  = req.body.Operations;
for (const op of patchOps){
  try {
    const applyPatchOp = compilePatch(op)
    applyPatchOp(myUserResource, true) // true to mutate
  } catch (err) {
    res.status(400).json(SCIM_INVALID_PATCH_OP_ERR)
    return;
  }
};
// Cool! Our user resource is now patched.
// Update DB, send result to client
updateDB(myUserResource);
res.status(200).json(myUserResource);

I'm not sure how much extra work that would be on this PR, but my intuition says it is not trivial because of all of the little rules around what constitutes valid SCIM patch ops, and how they get applied. My current solution is:

  1. Parse the patch ops using compilePatchPath in this PR.
  2. Apply SCIM-centric validations.
  3. Transform it into something that can be consumed by a JSON patch library.
  4. Apply the JSON patch to the document.


class TrackMap {
filter = [] as Expression[];
patchPath = [] as (SimplePath | FilterPath)[];
expression = [] as Expression[];
precedenceGroup = [] as Expression[];
attributeGroup = [] as Expression[];
attributeGroup = [] as [string[][], Expression][];
prefixLogicalExpression = [] as Expression[];
prefixLogicalExpressionOperator = [] as ((
e: Expression,
Expand All @@ -28,6 +41,7 @@ class TrackMap {

class Stat {
filter = 0 as number;
patchPath = 0 as number;
expression = 0 as number;
precedenceGroup = 0 as number;
attributeGroup = 0 as number;
Expand All @@ -47,6 +61,7 @@ class Stat {

class StatsMap {
filter = [] as Stat[];
patchPath = [] as Stat[];
expression = [] as Stat[];
precedenceGroup = [] as Stat[];
attributeGroup = [] as Stat[];
Expand Down
7 changes: 4 additions & 3 deletions src/attributeGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ export function attributeGroup(
);
}

yard.tracks.attributeGroup.push((data: any) =>
traverse(attributePath[0], data).some(x => filter[0](x))
);
yard.tracks.attributeGroup.push([
attributePath,
(data: any) => traverse(attributePath[0], data).some(x => filter[0](x))
]);

break;
}
Expand Down
5 changes: 4 additions & 1 deletion src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@ export function expression(

const children = [
...precedenceGroup,
...attributeGroup,
...prefixLogicalExpression,
...postfixAssertion,
...infixAssertion
];

if (attributeGroup.length) {
children.push(attributeGroup[0][1]);
}

if (children.length !== 1) {
throw new Error(
`INVARIANT: Expected 1 expression, but got ${children.length};`
Expand Down
37 changes: 36 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test from "ava";
import { compileFilter, compileSorter } from "./index";
import { compileFilter, compileSorter, compilePath } from "./index";

// Specification and examples are defined here:
// https://tools.ietf.org/html/rfc7644#section-3.4.2.2
Expand Down Expand Up @@ -299,3 +299,38 @@ test("compileSorter() - ", t => {
[2, 1, 3, 5, 10, 4, 6, 8, 9, 7]
);
});

test("compilePath() - extracts path", t => {
const pathPath = compilePath(
"some.nestedMultiValuedField[type eq 5].subField"
);
t.is(pathPath.path, "some.nestedMultiValuedField");
});

test("compilePath() - extracts subpaths", t => {
const pathPath = compilePath(
"some.nestedMultiValuedField[type eq 5].subField"
);
t.is(pathPath.subpath, "subField");
});

test("compilePath() - does not extract non-existent subpaths", t => {
const pathPath = compilePath("someTopLevelField");
t.is(pathPath.subpath, null);
});
test("compilePath() - does not extract non-existent subpaths with filters", t => {
const pathPath = compilePath("someTopLevelField.something[type eq 5]");
t.is(pathPath.subpath, null);
});

test("compilePath() - does not extract non-existent filter if there is no filter", t => {
const pathPath = compilePath(
"some.nestedMultiValuedField[type eq 5].subField"
);
t.is(typeof pathPath.filter, "function");
});

test("compilePath() - does not extract non-existent filter", t => {
const pathPath = compilePath("some.subField");
t.is(pathPath.filter, null);
});
35 changes: 35 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Yard } from "./Yard";
import { extractSortValue } from "./util";

import { filter } from "./filter";
import { patchPath } from "./patchPath";
import { expression } from "./expression";
import { precedenceGroup } from "./precedenceGroup";
import { attributeGroup } from "./attributeGroup";
Expand All @@ -24,8 +25,22 @@ const grammar = new Grammar();
const parser = new Parser();
parser.ast = new Ast();

type Expression = (data: any) => boolean;
type SimplePath = {
path: string;
filter: null;
subpath: null;
};

type FilterPath = {
path: string;
filter: Expression;
subpath: string | null;
};

parser.ast.callbacks = {
filter,
patchPath,
expression,
precedenceGroup,
attributeGroup,
Expand All @@ -42,6 +57,26 @@ parser.ast.callbacks = {
attributePathSegment
};

export function compilePath(input: string): SimplePath | FilterPath {
// Parse the patchPath
const parseResult = parser.parse(grammar, "patchPath", input);
if (!parseResult.success) {
throw new Error("Failed to parse!");
}

// Compile the patchPath
const yard = new Yard();
parser.ast.translate(yard);

if (yard.tracks.patchPath.length !== 1) {
throw new Error(
`INVARIANT: Expected 1 patchPath, but got ${yard.tracks.patchPath.length};`
);
}

return yard.tracks.patchPath[0];
}

export function compileFilter(input: string): (data: any) => boolean {
// Parse the filter
const parseResult = parser.parse(grammar, "filter", input);
Expand Down
50 changes: 50 additions & 0 deletions src/patchPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ids } from "apg-lib";
import { Yard } from "./Yard";

export function patchPath(
state: typeof ids.SEM_PRE | typeof ids.SEM_POST,
chars: number[],
phraseIndex: number,
phraseLength: number,
yard: Yard
): typeof ids.SEM_OK | typeof ids.SEM_SKIP {
switch (state) {
case ids.SEM_PRE:
yard.pre("patchPath");
break;

case ids.SEM_POST:
const { attributePath, attributeGroup, attributePathSegment } = yard.post(
"patchPath"
);

const children = [...attributePath, ...attributeGroup];

if (children.length !== 1) {
throw new Error(
`INVARIANT: Expected 1 patchPath, but got ${children.length};`
);
}

if (attributePath.length) {
yard.tracks.patchPath.push({
path: attributePath[0].join("."),
subpath: null,
filter: null
});
} else {
const nestedAttributePath = attributeGroup[0][0];
yard.tracks.patchPath.push({
path: nestedAttributePath[0].join("."),
filter: attributeGroup[0][1],
subpath: attributePathSegment.length
? attributePathSegment.reverse().join(".")
: null
});
}

break;
}

return ids.SEM_OK;
}