zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
2.07k stars 89 forks source link

[Feature Request] add @@filter annotation to enable/disable access #1421

Open bvkimball opened 5 months ago

bvkimball commented 5 months ago

Is your feature request related to a problem? Please describe.

Sometimes i want baked in logic like soft delete mechanism into my model access policy. But sometimes the user/client can still access the data but only in implicit circumstances. Most of this could be implemented through the access policy and passing the parameter to the auth() but then you would need to create a new enhanced client. i believe you might want to enable/disable the access at the transaction level.

To rephrase, sometimes a single user of elevated "role" wants to view the application data the same as other users but by toggling an "flag" they can now include rows previously excluded (ie. archived, deleted)

Describe the solution you'd like

    attribute @@filter(_  name: String, _ condition: Boolean, _ enabled: Boolean?)
Name Description Default
name  Key to be passed to 'enhanced' client to enabled/disable filter behavior  
condition Boolean expression to be evaluated/inject if the fitler is enabled  
enabled Boolean indicating if the filter is enabled by default, should not have "args()" false
model Post {
  id String @id @default(cuid())
  title String
  owner User @relation(fields: [ownerId], references: [id])
  ownerId Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  publishedAt DateTime
  archivedAt DateTime
  createdAt DateTime

  // Filter Annotation will always inject this where clause when accessing model,
  @@filter('excludeArchived', archivedAt != null, true)
  @@filter('existingAsOf', createdAt = args().date)
  @@filter('publishedBetween', publishedAt > args().min && publishedAt < args().max)

  @@deny('all', auth() == null)
  @@allow('all', auth() == owner)
  @@allow('read', auth() != null)
}

This could then be used as such:

import { PrismaClient } from '@prisma/client';
import { enhance } from '@zenstackhq/runtime';

const prisma = new PrismaClient();
const db = enhance(prisma);

// then elsewhere ...

// this will excludeArchived by default
await db.post.findMany();

// this will disable exclude archived logic
await db.post.findMany({
   filters: {
     excludeArchived: false
   }
});

// this will enable the other filters, multiple should be allowed
await db.post.findMany({
   filters: {
     existingAsOf: {date: new Date() },
     publishedBetween: { min: new Date(), max: new Date()}
   }
});

Describe alternatives you've considered

Now some the examples above are contrived and arguably pointless, for example the publishedBetween could/should just be written in the where clause and this layer of abstraction is complicated. I agree, the example was to illustrate the concept.

The real benefit here is for complex use-cases like effectiveDated/Versioned/Temporal models and "version" control, where a filter enabled at the top level will be enabled for relations that invoke the same filter name.

model Post {
  //...
  effectiveStart DateTime
  effectiveEnd DateTime
  comments Comment[]
  @@filter('asOf', effectiveStart < args().timestamp && (effectiveEnd > args().timestamp || effectiveEnd == null))
}

model Comment {
  //...
  effectiveStart DateTime
  effectiveEnd DateTime
  @@filter('asOf', effectiveStart < args().timestamp && (effectiveEnd > args().timestamp || effectiveEnd == null))
}

Then query the tree -->


await db.post.findMany({ 
  filter: { asOf: { timestamp: '2024-12-24' } },
  select: { 
    title: true, 
    comments: {
      select: {
        id: true,
        message: true,
      },
    },
  },
});

**Additional context**
Based on concept from Hibernate: https://thorben-janssen.com/hibernate-filter/

possibly related to #1402 and #520
ymc9 commented 4 months ago

Hi @bvkimball , thank you for filing this FR with a great explanation! If I'm understanding it correctly, the proposal covers two aspects:

  1. A way of passing extra variables to policy rules at runtime
  2. A way of conditionally enabling/disabling some rules (on multiple models)

# 1 is similar to #1402. We can probably start by allowing the args() construct at the client level and then extend it to the query level for better flexibility. The latter will involve extending PrismaClient's current TS interface, but we're already doing it in V2 anyway 😄.

# 2 may be related to another thing that I've been thinking about for a while. Let me try to explain it here.

The current way policies are modeled in ZenStack is probably not good enough for modeling applications with multiple "sides". For example, an EC app can have a storefront "side" and a fulfillment "side". Authoring their rather different authorization rules in a "flat" way can be quite cumbersome and hard to maintain. So maybe we should introduce something like "profile" to segregate different sets of rules. (Please ignore the syntax, as it's just for showing the idea).

model Order {
  ...

  @@profile('storefront', [
    allow('read', ...),
    deny('update', ...)
  ])

  @@profile('fulfillment', [
    allow('read', ...),
    deny('update', ...)
  ])

}

So at enhancement time we can do:

const storefrontDb = enhance(prisma, ..., { profiles: ['storefront'] });

We can also allow overriding profiles at query time.

Back to your proposal, the equivalent will probably be something like:

model Post {
  //...
  effectiveStart DateTime
  effectiveEnd DateTime
  comments Comment[]
  @@profile('asOf', [
    deny('read', !(effectiveStart < args().timestamp && (effectiveEnd > args().timestamp || effectiveEnd == null))))
  ])
}
await db.post.findMany({ 
  select: { 
    title: true, 
    comments: {
      select: {
        id: true,
        message: true,
      },
    },
  },
  profiles: [ 'asOf' ],
  args: { timestamp: '2024-12-24' }
});

I'm still not really sure to what extent this really overlaps with your original thoughts, but just wanted to throw out some wild ideas 😄.