diff --git a/server/package-lock.json b/server/package-lock.json index 99e6c5c..7bf85b7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.17", "license": "Apache-2.0", "dependencies": { - "@openfga/sdk": "^0.3.5", + "@openfga/sdk": "^0.4.0", "@openfga/syntax-transformer": "^0.2.0-beta.17", "ajv": "^8.12.0", "vscode-languageserver": "^9.0.1", @@ -25,11 +25,11 @@ } }, "node_modules/@openfga/sdk": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.3.5.tgz", - "integrity": "sha512-ChS/D9khwiy2nqffxTjUMwd9wkNvY34nHgk6BWjLfEaceahBZVEMCkvcPW1N/oCoA5CbhI1+AJ1/LgHZrZc93g==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.4.0.tgz", + "integrity": "sha512-1UnAcBdwCqLVAg8nuX72KMbJxBg3qsg3m2qf7FLZFwVAK6YHvzHYZ+gATCjZe4mMhr3qmZsb3dZRqE8hTbz9ng==", "dependencies": { - "axios": "^1.6.7", + "axios": "^1.6.8", "tiny-async-pool": "^2.1.0" }, "engines": { diff --git a/server/package.json b/server/package.json index 3cf26fa..6e37d04 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/openfga/vscode-ext" }, "dependencies": { - "@openfga/sdk": "^0.3.5", + "@openfga/sdk": "^0.4.0", "@openfga/syntax-transformer": "^0.2.0-beta.17", "ajv": "^8.12.0", "vscode-languageserver": "^9.0.1", diff --git a/server/src/openfga-yaml-schema.ts b/server/src/openfga-yaml-schema.ts index 7938592..8d02240 100644 --- a/server/src/openfga-yaml-schema.ts +++ b/server/src/openfga-yaml-schema.ts @@ -3,6 +3,7 @@ import { CheckRequestTupleKey, Condition, ListObjectsRequest, + ListUsersRequest, RelationReference, TupleKey, TypeDefinition, @@ -24,6 +25,7 @@ type Test = { tuples: TupleKey[]; check: CheckTest[]; list_objects: ListObjectTest[]; + list_users: ListUsersTest[]; }; type CheckTest = Omit & { @@ -32,7 +34,13 @@ type CheckTest = Omit & { }; type ListObjectTest = Omit & { - assertions: Record; + assertions: Record; +}; + +type ListUsersTest = Omit & { + object: string; + user_filter: Record; + assertions: Record; }; type BaseError = { keyword: string; message: string; instancePath: string }; @@ -93,6 +101,10 @@ const invalidTypeUser = (type: string, types: string[], instancePath: string) => return invalidStore(`invalid type '${type}'. Valid types are [${types}]`, instancePath); }; +const invalidRelationUser = (relation: string, relations: string[], instancePath: string) => { + return invalidStore(`invalid relation '${relation}'. Valid relations are [${relations}]`, instancePath); +}; + const nonMatchingRelationType = (relation: string, user: string, values: string[], instancePath: string) => { if (values.length) { return invalidStore( @@ -517,6 +529,102 @@ function validateListObject( return errors; } +// Validate List User +function validateListUsers( + model: AuthorizationModel, + listUsers: ListUsersTest, + tuples: TupleKey[], + params: string[], + instancePath: string, +) { + const errors = []; + + const types = model.type_definitions.map((d) => d.type); + + if (listUsers && isStringValue(listUsers.object)) { + const listUserObj = listUsers.object; + + const object = listUserObj.split(":")[0]; + + // Ensure valid type of object + if (!types.includes(object)) { + errors.push(invalidTypeUser(object, types, instancePath + "/object")); + } + + if (!errors.length) { + if (!tuples.map((tuple) => tuple.object).filter((object) => object === listUserObj).length) { + errors.push(undefinedTypeTuple(listUserObj, instancePath + "/object")); + } + } + } + + // Check user fileter + if (listUsers.user_filter) { + for (const typeNo in listUsers.user_filter) { + const listType = listUsers.user_filter[typeNo].type; + + if (listType && isStringValue(listType)) { + // Ensure valid type of object + if (!types.includes(listType)) { + errors.push(invalidTypeUser(listType, types, instancePath + `/user_filter/${typeNo}/type`)); + } + } + + // Check relations if present + const relation = listUsers.user_filter[typeNo].relation; + if (relation && isStringValue(relation)) { + const typeRelations = model.type_definitions.filter((rel) => rel.type === listType).map((rel) => rel.relations); + + if (typeRelations.length && typeRelations[0] && Object.keys(typeRelations[0]).length) { + if (!Object.keys(typeRelations[0]).includes(relation)) { + errors.push( + invalidRelationUser( + relation, + Object.keys(typeRelations[0]), + instancePath + `/user_filter/${typeNo}/relation`, + ), + ); + } + } else { + errors.push(relationMustExistOnType(relation, listType, instancePath + `/user_filter/${typeNo}/relation`)); + } + } + } + + // Check assertions + if (listUsers.assertions) { + for (const assertion of Object.keys(listUsers.assertions)) { + if (listUsers.assertions[assertion].users) { + for (const user of listUsers.assertions[assertion].users) { + if (!tuples.some((tuple) => tuple.user === user)) { + errors.push(undefinedTypeTuple(user, instancePath + `/assertions/${assertion}/users`)); + } + } + } + + if (listUsers.assertions[assertion].excluded_users) { + for (const excludedUser of listUsers.assertions[assertion].excluded_users) { + if (!tuples.some((tuple) => tuple.user === excludedUser)) { + errors.push(undefinedTypeTuple(excludedUser, instancePath + `/assertions/${assertion}/excluded_users`)); + } + } + } + } + } + } + + // Check context params + if (listUsers.context) { + for (const testParam in listUsers.context) { + if (!params.includes(testParam)) { + errors.push(unidentifiedTestParam(testParam, instancePath + `/context/${testParam}`)); + } + } + } + + return errors; +} + // Validation for types in check function validateTestTypes(store: Store, model: AuthorizationModel, instancePath: string): boolean { const errors = []; @@ -545,10 +653,6 @@ function validateTestTypes(store: Store, model: AuthorizationModel, instancePath tuples.push(...test.tuples); } - if (!test.check) { - continue; - } - // Validate check for (const checkNo in test.check) { if (!test.check[checkNo] || !test.check[checkNo].user || !test.check[checkNo].object) { @@ -565,10 +669,6 @@ function validateTestTypes(store: Store, model: AuthorizationModel, instancePath ); } - if (!test.list_objects) { - continue; - } - // Validate list objects for (const listNo in test.list_objects) { if (!test.list_objects[listNo] || !test.list_objects[listNo].user || !test.list_objects[listNo].type) { @@ -584,6 +684,21 @@ function validateTestTypes(store: Store, model: AuthorizationModel, instancePath ), ); } + + for (const listNo in test.list_users) { + if (!test.list_users[listNo] || !test.list_users[listNo].object || !test.list_users[listNo].user_filter) { + return false; + } + errors.push( + ...validateListUsers( + model, + test.list_users[listNo], + tuples, + params, + instancePath + `/tests/${testNo}/list_users/${listNo}`, + ), + ); + } } if (errors.length) { @@ -835,6 +950,63 @@ const OPENFGA_YAML_SCHEMA: Schema = { }, }, }, + list_users: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["object", "user_filter", "assertions"], + properties: { + object: { + type: "string", + format: "object", + }, + user_filter: { + type: "array", + items: { + type: "object", + required: ["type"], + properties: { + type: { + type: "string", + }, + relation: { + type: "string", + }, + }, + }, + }, + context: { + type: "object", + }, + assertions: { + type: "object", + patternProperties: { + ".*": { + type: "object", + additionalProperties: false, + properties: { + users: { + type: "array", + items: { + type: "string", + format: "user", + }, + }, + excluded_users: { + type: "array", + items: { + type: "string", + format: "user", + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, },