diff --git a/src/sbvr-api/permissions.ts b/src/sbvr-api/permissions.ts index cac682797..1cb6d2a8d 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -123,6 +123,12 @@ const methodPermissions: { DELETE: 'delete', }; +const stringifiedMethodPermissions = Object.fromEntries( + Object.entries(methodPermissions) + .filter((p) => p[1] != null) + .map((p) => [p[0], JSON.stringify(p[1])]), +) as Record; + const $parsePermissions = env.createCache( 'parsePermissions', (filter: string) => { @@ -742,6 +748,38 @@ const createBypassDefinition = (definition: Definition) => } }); +const createVersionSpecificPermissionDefinition = ( + { abstractSql, ...restDefinition }: Definition, + permissionsJSON: string, +) => { + return { + ..._.cloneDeep(restDefinition), + abstractSql: _.cloneDeepWith(abstractSql, (abstractSqlNode) => { + if (!Array.isArray(abstractSqlNode)) { + // Don't affect how non-array nodes get cloned. + return; + } + if (abstractSqlNode[0] === 'Select' || abstractSqlNode[0] === 'Where') { + // We only want to rewrite Resources that are under a From node, so we use + // cloneDeep so that cloneDeepWith is not called again for their sub-tree. + // Eg: when we have computed terms under a Select node, we don't rewrite them + // so that the read permissions are still used even in PATCH/DELETEs. + return _.cloneDeep(abstractSqlNode); + } + if ( + abstractSqlNode[0] === 'Resource' && + typeof abstractSqlNode[1] === 'string' && + // Do not rewrite Resources that already specify which permissions need to be used (or bypassed). + !abstractSqlNode[1].includes('$permissions') && + !abstractSqlNode[1].endsWith('$bypass') + ) { + const resource = abstractSqlNode[1]; + return ['Resource', `${resource}$permissions${permissionsJSON}`]; + } + }), + }; +}; + const getAlias = (name: string) => { // TODO-MAJOR: Change $bypass to $permissionbypass or similar if (name.endsWith('$bypass')) { @@ -955,7 +993,6 @@ const rewriteRelationships = ( return newRelationships; }; -const stringifiedGetPermissions = JSON.stringify(methodPermissions.GET); const getBoundConstrainedMemoizer = memoizeWeak( (abstractSqlModel: AbstractSqlModel) => memoizeWeak( @@ -1002,8 +1039,48 @@ const getBoundConstrainedMemoizer = memoizeWeak( // If the table is definition based then just make the bypass version match but pointing to the equivalent bypassed resources constrainedAbstractSqlModel.tables[bypassResourceName].definition = createBypassDefinition(table.definition); + + // Make clear to TS that table.definition isn't going to become undefined by the time we use it inside the onceGetter below. + const tableDefinition = table.definition; + + // For all translated resources, we create virtual resources suffixed with `$permissions""`, + // and also replace their nested `Resource` references to also point to that parallel `$permissions""` suffixed model. + // This way, when we are resolving an UPDATE/DELETE/... query, the resolution recursively goes through a model chain like: + // student$permissions"update" -> student$vX$permissions"update" -> student$vX+1$permissions"update" -> student$vDB$permissions"update" + // * Resolving each intermediate level results expanding the nested FROM statement(s) to the next translation layer. + // * When the resolution reaches the last model in the chain (`$vDB$permissions""`) and tries to resolve it, + // the `generateConstrainedAbstractSql()` that's in the `onceGetter(permissionsTable, 'definition'` below get called, which reads + // the `$permissions""` suffix of the resource name and add a filter with the respective permissions to that last/deepest model in the chain. + for (const stringifiedPermission of _.uniq( + Object.values(stringifiedMethodPermissions), + )) { + if (stringifiedPermission === stringifiedMethodPermissions.GET) { + // The read permissions (for GET requests) are resolved using the plain resources in the constrainedAbstractSqlModel + // (those without a `$permissions` suffix, which get generated by the memoizedGetConstrainedModel), + // so we don't need to re-create a set of `$permissions"read"` resources. + continue; + } + // If the permission is not a GET then we need to create a permissions table + const permissionResourceName = `${resourceName}$permissions${stringifiedPermission}`; + constrainedAbstractSqlModel.tables[permissionResourceName] = { + ...table, + resourceName: permissionResourceName, + }; + onceGetter( + constrainedAbstractSqlModel.tables[permissionResourceName], + 'definition', + () => + createVersionSpecificPermissionDefinition( + tableDefinition, + stringifiedPermission, + ), + ); + } } else { // Otherwise constrain the non-bypass table + // When the table doesn't have a definition, then it's the last translation layer, + // in which case we define it as the virtual `resource$vDB$permissions"read"` resource, + // which invokes the generateConstrainedAbstractSql() that's in the `onceGetter(permissionsTable, 'definition'` below onceGetter( table, 'definition', @@ -1011,7 +1088,7 @@ const getBoundConstrainedMemoizer = memoizeWeak( // For $filter on eg a DELETE you need read permissions on the sub-resources, // you only need delete permissions on the resource being deleted constrainedAbstractSqlModel.tables[ - `${resourceName}$permissions${stringifiedGetPermissions}` + `${resourceName}$permissions${stringifiedMethodPermissions.GET}` ].definition, ); }