Skip to content

Commit

Permalink
refactor: enhance folder listing and permission checks (#4539)
Browse files Browse the repository at this point in the history
  • Loading branch information
leopuleo authored Feb 25, 2025
1 parent ba662b1 commit 7e27c50
Show file tree
Hide file tree
Showing 13 changed files with 337 additions and 39 deletions.
35 changes: 28 additions & 7 deletions packages/api-aco/src/createAcoContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { Tenant } from "@webiny/api-tenancy/types";
import { isHeadlessCmsReady } from "@webiny/api-headless-cms";
import { createAcoHooks } from "~/createAcoHooks";
import { createAcoStorageOperations } from "~/createAcoStorageOperations";
import { AcoContext, CreateAcoParams, Folder, IAcoAppRegisterParams } from "~/types";
import {
AcoContext,
CreateAcoParams,
Folder,
IAcoAppRegisterParams,
ListFoldersParams
} from "~/types";
import { createFolderCrudMethods } from "~/folder/folder.crud";
import { createSearchRecordCrudMethods } from "~/record/record.crud";
import { AcoApps } from "./apps";
Expand Down Expand Up @@ -79,7 +85,7 @@ const setupAcoContext = async (
});
},
listPermissions: () => security.listPermissions(),
listAllFolders: type => {
listAllFolders: (params: ListFoldersParams) => {
// When retrieving a list of all folders, we want to do it in the
// fastest way and that is by directly using CMS's storage operations.
const { withModel } = createOperationsWrapper({
Expand All @@ -91,18 +97,26 @@ const setupAcoContext = async (

return withModel(async model => {
try {
const results = await context.cms.storageOperations.entries.list(model, {
limit: 100_000,
const response = await context.cms.storageOperations.entries.list(model, {
limit: 10_000,
where: {
type,
...params.where,

// Folders always work with latest entries. We never publish them.
latest: true
},
after: params.after,
sort: ["title_ASC"]
});

return results.items.map(pickEntryFieldValues<Folder>);
return [
response.items.map(pickEntryFieldValues<Folder>),
{
cursor: response.cursor,
totalCount: response.totalCount,
hasMoreItems: response.hasMoreItems
}
];
} catch (ex) {
/**
* Skip throwing an error if the error is related to the search phase execution.
Expand All @@ -111,7 +125,14 @@ const setupAcoContext = async (
* TODO: figure out better way to handle this.
*/
if (ex.message === "search_phase_execution_exception") {
return [];
return [
[],
{
cursor: null,
totalCount: 0,
hasMoreItems: false
}
];
}
throw ex;
}
Expand Down
16 changes: 6 additions & 10 deletions packages/api-aco/src/folder/folder.crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,7 @@ export const createFolderCrudMethods = ({
// invalidating the permissions list cache for the folder type. We cannot rely on the cache
// to check if the user has access, because the cache is no longer up to date.
folderLevelPermissions.invalidateFoldersPermissionsListCache(folder.type);
folderLevelPermissions.updateFoldersCache(folder.type, cachedFolders => {
return [...cachedFolders, folder];
});
folderLevelPermissions.addFolderToCache(folder.type, folder);

// With caches updated and invalidated, we can now assign correct permissions to the folder.
await folderLevelPermissions.assignFolderPermissions(folder);
Expand Down Expand Up @@ -203,13 +201,11 @@ export const createFolderCrudMethods = ({
// internal cache with new folder data. Then, we're invalidating the permissions list
// cache for the folder type. We cannot rely on the cache to check if the user still
// has access, because the cache might no longer be up-to-date.
folderLevelPermissions.updateFoldersCache(original.type, cachedFolders => {
return cachedFolders.map(currentFolder => {
if (currentFolder.id !== id) {
return currentFolder;
}
return { ...currentFolder, ...data };
});
folderLevelPermissions.updateFoldersCache(original.type, cachedFolder => {
if (cachedFolder.id !== id) {
return cachedFolder;
}
return { ...cachedFolder, ...data };
});
folderLevelPermissions.invalidateFoldersPermissionsListCache(original.type);

Expand Down
38 changes: 38 additions & 0 deletions packages/api-aco/src/folder/folder.gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/plugins/GraphQLSche

import { ensureAuthentication } from "~/utils/ensureAuthentication";
import { resolve } from "~/utils/resolve";
import { compress } from "~/utils/compress";

import { AcoContext, Folder } from "~/types";

Expand All @@ -14,6 +15,11 @@ export const folderSchema = new GraphQLSchemaPlugin<AcoContext>({
inheritedFrom: ID
}
type CompressedResponse {
compression: String
value: String
}
input FolderPermissionInput {
target: String!
level: String!
Expand Down Expand Up @@ -80,6 +86,11 @@ export const folderSchema = new GraphQLSchemaPlugin<AcoContext>({
meta: AcoMeta
}
type FoldersListCompressedResponse {
data: CompressedResponse
error: AcoError
}
type FolderLevelPermissionsTarget {
id: ID!
type: String!
Expand All @@ -106,6 +117,12 @@ export const folderSchema = new GraphQLSchemaPlugin<AcoContext>({
after: String
sort: AcoSort
): FoldersListResponse
listFoldersCompressed(
where: FoldersListWhereInput!
limit: Int
after: String
sort: AcoSort
): FoldersListCompressedResponse
listFolderLevelPermissionsTargets: FolderLevelPermissionsTargetsListResponse
}
Expand Down Expand Up @@ -149,6 +166,27 @@ export const folderSchema = new GraphQLSchemaPlugin<AcoContext>({
return new ErrorResponse(e);
}
},
listFoldersCompressed: async (_, args: any, context) => {
return resolve(async () => {
const [entries] = await context.aco.folder.list(args);

const folders = entries.map(folder => ({
...folder,
hasNonInheritedPermissions:
context.aco.folderLevelPermissions.permissionsIncludeNonInheritedPermissions(
folder.permissions
),
canManageStructure:
context.aco.folderLevelPermissions.canManageFolderStructure(folder),
canManagePermissions:
context.aco.folderLevelPermissions.canManageFolderPermissions(folder),
canManageContent:
context.aco.folderLevelPermissions.canManageFolderContent(folder)
}));

return compress(folders);
});
},
listFolderLevelPermissionsTargets: async (_, args: any, context) => {
try {
ensureAuthentication(context);
Expand Down
42 changes: 23 additions & 19 deletions packages/api-aco/src/utils/FolderLevelPermissions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Authentication } from "@webiny/api-authentication/types";
import { SecurityPermission, Team } from "@webiny/api-security/types";
import { Folder } from "~/folder/folder.types";
import { Folder, ListFoldersParams } from "~/folder/folder.types";
import { NotAuthorizedError } from "@webiny/api-security";
import { ListFoldersRepository } from "~/utils/ListFoldersRepository";
import { ListMeta } from "~/types";
import { folderCacheFactory } from "~/utils/FoldersCacheFactory";

export type FolderAccessLevel = "owner" | "viewer" | "editor" | "public";

Expand Down Expand Up @@ -47,7 +50,7 @@ export interface FolderLevelPermissionsParams {
getIdentity: Authentication["getIdentity"];
listIdentityTeams: () => Promise<Team[]>;
listPermissions: () => Promise<SecurityPermission[]>;
listAllFolders: (folderType: string) => Promise<Folder[]>;
listAllFolders: (params: ListFoldersParams) => Promise<[Folder[], ListMeta]>;
canUseTeams: () => boolean;
canUseFolderLevelPermissions: () => boolean;
isAuthorizationEnabled: () => boolean;
Expand All @@ -59,17 +62,15 @@ export class FolderLevelPermissions {
private readonly getIdentity: Authentication["getIdentity"];
private readonly listIdentityTeams: () => Promise<Team[]>;
private readonly listPermissions: () => Promise<SecurityPermission[]>;
private readonly listAllFoldersCallback: (folderType: string) => Promise<Folder[]>;
private readonly canUseTeams: () => boolean;
private readonly isAuthorizationEnabled: () => boolean;
private allFolders: Record<string, Folder[]> = {};
private foldersPermissionsLists: Record<string, Promise<FolderPermissionsList> | null> = {};
private foldersLoader: ListFoldersRepository;

constructor(params: FolderLevelPermissionsParams) {
this.getIdentity = params.getIdentity;
this.listIdentityTeams = params.listIdentityTeams;
this.listPermissions = params.listPermissions;
this.listAllFoldersCallback = params.listAllFolders;
this.canUseTeams = params.canUseTeams;
this.canUseFolderLevelPermissions = () => {
const identity = this.getIdentity();
Expand All @@ -90,15 +91,17 @@ export class FolderLevelPermissions {
};

this.isAuthorizationEnabled = params.isAuthorizationEnabled;

// Delete the cache on constructor to avoid duplicated permissions lists,
// especially when calculating permissions based on folder parents.
folderCacheFactory.deleteCache();
this.foldersLoader = new ListFoldersRepository({
gateway: params.listAllFolders
});
}

async listAllFolders(folderType: string): Promise<Folder[]> {
if (folderType in this.allFolders) {
return structuredClone(this.allFolders[folderType]);
}

this.allFolders[folderType] = await this.listAllFoldersCallback(folderType);
return structuredClone(this.allFolders[folderType]);
return await this.foldersLoader.execute(folderType);
}

async listAllFoldersWithPermissions(folderType: string) {
Expand All @@ -117,11 +120,9 @@ export class FolderLevelPermissions {

invalidateFoldersCache(folderType?: string) {
if (folderType) {
if (folderType in this.allFolders) {
delete this.allFolders[folderType];
}
folderCacheFactory.getCache(folderType).clear();
} else {
this.allFolders = {};
folderCacheFactory.deleteCache();
}
}

Expand All @@ -131,13 +132,16 @@ export class FolderLevelPermissions {
delete this.foldersPermissionsLists[folderType];
}
} else {
this.allFolders = {};
folderCacheFactory.deleteCache();
}
}

updateFoldersCache(folderType: string, modifier: (folders: Folder[]) => Folder[]) {
const foldersClone = structuredClone(this.allFolders[folderType]) || [];
this.allFolders[folderType] = modifier(foldersClone);
addFolderToCache(folderType: string, folder: Folder) {
folderCacheFactory.getCache(folderType).addItems([folder]);
}

updateFoldersCache(folderType: string, modifier: (folder: Folder) => Folder) {
folderCacheFactory.getCache(folderType).updateItems(modifier);
}

async listFoldersPermissions(
Expand Down
31 changes: 31 additions & 0 deletions packages/api-aco/src/utils/FoldersCacheFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Folder } from "~/folder/folder.types";
import { ListCache } from "~/utils/ListCache";

export class FoldersCacheFactory {
private cache: Map<string, ListCache<Folder>> = new Map();

hasCache(namespace: string) {
const cacheKey = this.getCacheKey(namespace);
return this.cache.has(cacheKey);
}

getCache(namespace: string) {
const cacheKey = this.getCacheKey(namespace);

if (!this.cache.has(cacheKey)) {
this.cache.set(cacheKey, new ListCache<Folder>());
}

return this.cache.get(cacheKey) as ListCache<Folder>;
}

deleteCache() {
this.cache.clear();
}

private getCacheKey(namespace: string) {
return namespace;
}
}

export const folderCacheFactory = new FoldersCacheFactory();
47 changes: 47 additions & 0 deletions packages/api-aco/src/utils/ListCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import cloneDeep from "lodash/cloneDeep";

export type Constructor<T> = new (...args: any[]) => T;

export interface IListCachePredicate<T> {
(item: T): boolean;
}

export interface IListCacheItemUpdater<T> {
(item: T): T;
}

export interface IListCache<T> {
clear(): void;
hasItems(): boolean;
getItems(): T[];
addItems(items: T[]): void;
updateItems(updater: IListCacheItemUpdater<T>): void;
}

export class ListCache<T> implements IListCache<T> {
private state: T[];

constructor() {
this.state = [];
}

clear(): void {
this.state = [];
}

hasItems(): boolean {
return this.state.length > 0;
}

getItems(): T[] {
return cloneDeep(this.state);
}

addItems(items: T[]): void {
this.state.push(...items);
}

updateItems(updater: IListCacheItemUpdater<T>): void {
this.state = this.state.map(item => updater(item));
}
}
39 changes: 39 additions & 0 deletions packages/api-aco/src/utils/ListFoldersRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { folderCacheFactory } from "~/utils/FoldersCacheFactory";
import { Folder, ListFoldersParams } from "~/folder/folder.types";
import { ListMeta } from "~/types";

export interface ListFoldersRepositoryParams {
gateway: (params: ListFoldersParams) => Promise<[Folder[], ListMeta]>;
}

export class ListFoldersRepository {
private readonly gateway: (params: ListFoldersParams) => Promise<[Folder[], ListMeta]>;

constructor(params: ListFoldersRepositoryParams) {
this.gateway = params.gateway;
}

public async execute(folderType: string): Promise<Folder[]> {
if (folderCacheFactory.hasCache(folderType)) {
return folderCacheFactory.getCache(folderType).getItems();
}

let hasMoreItems: ListMeta["hasMoreItems"] = true;
let cursor: ListMeta["cursor"] = null;

while (hasMoreItems) {
const response: [Folder[], ListMeta] = await this.gateway({
where: { type: folderType },
after: cursor
});

const [folders, meta] = response;

folderCacheFactory.getCache(folderType).addItems(folders);
hasMoreItems = meta.hasMoreItems;
cursor = meta.cursor;
}

return folderCacheFactory.getCache(folderType).getItems();
}
}
Loading

0 comments on commit 7e27c50

Please sign in to comment.