Implementing Cross-cut Concerns with Javascript Proxy: A Practical Example

ymc9 - Jan 28 '23 - - Dev Community

Proxy is not a new thing; it was released as part of the ECMAScript 6 spec in 2015. However, it stood out as a distinct new feature because, unlike most other additions, Proxy works on the fundamental level of object manipulation. It cannot be compiled down to previous versions of Javascript.

As a very powerful language feature, Proxy enables many exciting new programming patterns, but at the same time, it’s also something that’s not widely used in real applications.

This post gives an example of a real-world problem that Proxy solves elegantly.

Background

Proxies are objects that wrap around other objects. The wrapping is transparent, so from the user's point of view, using the proxied object feels exactly like the original object, except that some behaviors can be altered by the proxy.

Proxy works by allowing you to "trap" several fundamental operations on objects, e.g., getting a property, setting a property, calling a method, getting the object's prototype, etc. A simple example looks like this:

const target = { x: 1 };

const proxied = new Proxy(target, {
    get: (target, prop, receiver) => {
    console.log('Accessing property:', prop);
    return Reflect.get(target, prop, receiver);
  }
});
Enter fullscreen mode Exit fullscreen mode

A Proxy object is created with a target object and a handler. The handler contains "trap" implementations, and here we only intercepted the property getter. A line of log will be printed when you access proxied.x.

Accessing property: x
Enter fullscreen mode Exit fullscreen mode

Note: the Reflect.get method is a utility for passing through the intercepted operation to the underlying target object.

Proxies are most useful for implementing cross-cut concerns, like:

  • Logging
  • Authentication/Authorization
  • Function parameter validation
  • Data binding and observables
  • …

The Problem

We've been using ORM tools to handle databases during the development of numerous Node.js-based web applications in the past few years. Among all tools we tried, Prisma became our favorite, given its intuitive data modeling syntax and fantastic ability to generate type-safe CRUD APIs that are super pleasant to use. Here's a quick example of how data are modeled with Prisma if it's new to you.

model User {
    id String @id @default(uuid())
    email String
    password String
    posts Post[]
}

model Post {
    id String @id @default(uuid())
    title String
    published Boolean @default(false)
    author User @relation(fields: [authorId], references: [id])
    authorId String
}
Enter fullscreen mode Exit fullscreen mode

We loved how succinctly we could define our entities. However, an application's data model is more than just the shapes of its entities; there's a missing piece. Access control - who can do what to which data, is an equally important part, and we want it to stay together with the data model.

Ideally, we want the schema to look like this:

model Post {
    id String @id
    title String
    published Boolean @default(false)
    author User @relation(fields: [authorId], references: [id])
    authorId String

    // πŸ” author can read his own posts; other users can read published ones
    @@allow('read', auth() == author || published)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Note: auth() is a function for getting the current user in session.

With the access control policies in place, we wished to stretch Prisma and let it enforce the policy rules when conducting database CRUD operations. As a result, it could bring us two benefits:

  • Simpler code

    It can eliminate the need to write imperative authorization logic and greatly simplify our backend code.

  • Fewer bugs

    A single source of truth helps avoid the kind of bugs where you change access control rules but forget to update some parts of your services.

The Solution

Implementing the idea was a challenging job. It involves two main tasks:

  1. Building a DSL that extends Prisma's schema language
  2. Augmenting Prisma's behavior at runtime to enforce access control

The first task by itself is a big topic, and I'll save it for another post. So instead, let's focus on #2 here. It's a typical cross-cut problem: the access control rules are attached to data models, and they should apply horizontally across your entire backend: all API endpoints where the models are used.

The desired behavior

With a regular PrismaClient, if you execute:

// return all "Post" entities in the db
const posts = await prisma.post.findMany();
Enter fullscreen mode Exit fullscreen mode

, you'll get all the Post entities in the database. With an augmented client, we should only get the posts that are supposed to be readable to the current user. According to our policy rules, that would be all posts owned by the user plus all published posts.

// augment Prisma with access policy support
const policyClient = withPolicy(prisma);

// return all "Post" owned by the current user and all published posts
const posts = await policyClient.post.findMany();
Enter fullscreen mode Exit fullscreen mode

We need to create a wrapper around a regular PrismaClient, preserve its APIs, and intercept its method calls so we can inject access policy checks.

Yes, interception, that's what Proxy is all about!

Let Proxy work its magic

Since a PrismaClient manages multiple data models, we need to do a two-level proxying: the first level is to intercept data model property access, i.e., prisma.post and prisma.user, and the second level is the actual database APIs: findMany, update, etc.

Let's set up our code structure first for the two levels of proxying. For simplicity, I hard-coded the model name post and also only dealt with the findMany method.

const prisma = new PrismaClient();

function createCrudProxy(model: string, db: any) {
    return new Proxy(db, {
        get: (target, prop, receiver) => {
            if (prop === 'findMany') {
                return (args: any) => {
                    console.log(
                        `Calling ${model}.${prop} with args: ${JSON.stringify(
                            args
                        )}`
                    );
                    return Reflect.get(target, prop, receiver)(args);
                };
            } else {
                return Reflect.get(target, prop, receiver);
            }
        },
    });
}

const policyClient = new Proxy(prisma, {
        get: (target, prop, receiver) => {
            const propValue = Reflect.get(target, prop, receiver);
            if (prop === 'post') {
                return createCrudProxy(prop, propValue);
            } else {
                return propValue;
            }
        },
    });
Enter fullscreen mode Exit fullscreen mode

The code above simply logs the API call without changing its behavior. We can verify if it works:

const posts = await policyClient.post.findMany({
        orderBy: { createdAt: 'desc' },
    });
console.log('Posts:', posts);
Enter fullscreen mode Exit fullscreen mode

It gives output like this:

Calling post.findMany with args: {"orderBy":{"createdAt":"desc"}}
Posts: [
  {
    id: '966d02eb-199b-45a0-b372-2b6a57b3b307',
    createdAt: 2023-01-28T09:10:59.455Z,
    updatedAt: 2023-01-28T09:10:59.455Z,
    published: false,
    title: 'We build ZenStack',
    authorId: '1ec7b38e-a377-4947-a295-005a7b3f22c6'
  },
  {
    id: '283b7f07-0ab2-401a-8b55-93d56ac20399',
    createdAt: 2023-01-28T09:10:59.447Z,
    updatedAt: 2023-01-28T09:10:59.447Z,
    published: true,
    title: 'Prisma is awesome',
    authorId: 'db54ec74-3d21-4332-bdac-44dd1685ab36'
  }
]
Enter fullscreen mode Exit fullscreen mode

Now we're ready to add access policy check logic into our proxy code:

function getSessionUser() {
    // This is just a stub for the sake of the example
    // In a real application, get the current user from the session
    return { id: 'user1' };
}

function getQueryGuard(model: string) {
    // policy: @@allow('read', auth() == author || published)
    return {
        OR: [
            {
                author: {
                    id: getSessionUser().id,
                },
            },
            { published: true },
        ],
    };
}

function createCrudProxy(model: string, db: any) {
    return new Proxy(db, {
        get: (target, prop, receiver) => {
            if (prop === 'findMany') {
                return (args: any) => {
                    const guard = getQueryGuard(model);
                    const augmentedArgs = {
                        ...args,
                        where: args.where
                            ? { AND: [args.where, guard] }
                            : guard,
                    };
                    console.log(
                        'Augmented args:',
                        JSON.stringify(augmentedArgs)
                    );
                    return Reflect.get(target, prop, receiver)(augmentedArgs);
                };
            } else {
                return Reflect.get(target, prop, receiver);
            }
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the main thing we did was augment the query argument passed to the findMany method and inject the policy conditions into the where clause. In the sample code, the getQueryGuard method is hard-coded, but in the real world, it should be generated by the schema compiler from the policy expression @@allow('read', auth() == author || published).

Now, if you rerun the query:

const posts = await policyClient.post.findMany({
        orderBy: { createdAt: 'desc' },
    });
console.log('Posts:', posts);
Enter fullscreen mode Exit fullscreen mode

Thanks to the injected filters, you should see the result got filtered (only published posts are returned).

Augmented args: {
    "orderBy":{"createdAt":"desc"},
    "where":{"OR":[{"author":{"id":"user1"}},{"published":true}]}
}
Posts: [
  {
    id: '283b7f07-0ab2-401a-8b55-93d56ac20399',
    createdAt: 2023-01-28T09:10:59.447Z,
    updatedAt: 2023-01-28T09:10:59.447Z,
    published: true,
    title: 'Prisma is awesome',
    authorId: 'db54ec74-3d21-4332-bdac-44dd1685ab36'
  }
]
Enter fullscreen mode Exit fullscreen mode

This is a greatly simplified version of the full implementation, but I hope you get the idea of how straightforward it is to use Proxy to create transparent wrappers around objects and alter their runtime behaviour and, at the same time, fully preserve the original typing.

The final outcome of our work on enhancing Prisma ORM is the ZenStack project. The toolkit helped us greatly reduce the backend code and resulted in more robust products.

Performance

Proxies add a level of indirection, which will incur some performance penalty. Let's evaluate how much that is:

const loopCount = 100000;
console.log('Looping for', loopCount, 'times...');

console.time('Regular prisma');
for (let i = 0; i < loopCount; i++) {
    const posts = await prisma.post.findMany({
        orderBy: { createdAt: 'desc' },
    });
}
console.timeEnd('Regular prisma');

console.time('Proxied prisma');
for (let i = 0; i < loopCount; i++) {
    const posts = await policyClient.post.findMany({
        orderBy: { createdAt: 'desc' },
    });
}
console.timeEnd('Proxied prisma');
Enter fullscreen mode Exit fullscreen mode

The result is as follows on my MacBook Pro with M1 chip:

Looping for 100000 times...
Regular prisma: 21.835s
Proxied prisma: 23.448s
Enter fullscreen mode Exit fullscreen mode

The difference is around 7%. Not bad. My benchmark used SQLite database, so the data query part is very fast. In reality, if you use a remote database, the overhead induced by Proxy will be an even smaller fragment and likely negligible.

Proxies also have memory overhead. For my scenario, it's not essential because I will not have tons of wrappers created. However, this can be something worth evaluating if your situation differs.

More Things to Study

Although Proxy is not an everyday feature to use for Javascript, there're some excellent projects built upon it. Check these examples if you're interested in knowing more about its potential:

  • Vue.js 3.+

    Vue3's reactive state is implemented with Proxy.

  • SolidJS

    SolidJS is a reactive Javascript library. It uses Proxy to implement its reactive stores.

  • ImmerJS

    Immer is a package that allows you to work with immutable state in a more convenient way. It uses Proxy to track "updates" to immutable states when running in an ES6 environment.

  • ZenStack

    ZenStack supercharges Prisma ORM with a powerful access control layer. Its enhancement to Prisma is implemented with Proxy.

These projects show that Javascript is much more fun than just DOM manipulation and API calls πŸ˜„.


You can find the full sample code here.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .