Skip to content

(WIP) feat: add list permission #1921

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: dev
Choose a base branch
from
Draft
12 changes: 10 additions & 2 deletions packages/runtime/src/enhancements/node/policy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
}

findMany(args?: any) {
return createDeferredPromise<unknown[]>(() => this.doFind(args, 'findMany', () => []));
return createDeferredPromise<unknown[]>(() => this.doFind(args, 'findMany', () => [], true));
}

// make a find query promise with fluent API call stubs installed
Expand All @@ -130,7 +130,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
);
}

private async doFind(args: any, actionName: FindOperations, handleRejection: () => any) {
private async doFind(args: any, actionName: FindOperations, handleRejection: () => any, isList: boolean = false) {
const origArgs = args;
const _args = this.policyUtils.safeClone(args);
if (!this.policyUtils.injectForRead(this.prisma, this.model, _args)) {
Expand All @@ -140,6 +140,14 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
return handleRejection();
}

if (isList && !this.policyUtils.injectForList(this.prisma, this.model, _args)) {
if (this.shouldLogQuery) {
this.logger.info(`[policy] \`${actionName}\` ${this.model}: unconditionally denied`);
}

return handleRejection();
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this is the kind of check we need to do here?

this.policyUtils.injectReadCheckSelect(this.model, _args);

if (this.shouldLogQuery) {
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime/src/enhancements/node/policy/policy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export class PolicyUtil extends QueryUtils {
create: { guard: true, inputChecker: true },
update: { guard: true },
delete: { guard: true },
list: { guard: true },
postUpdate: { guard: true },
},
};
Expand Down Expand Up @@ -611,6 +612,11 @@ export class PolicyUtil extends QueryUtils {
return false;
}

if (!this.injectAuthGuardAsWhere(db, injected, model, 'list')) {
args.where = this.makeFalse();
return false;
}

if (args.where) {
// inject into fields:
// to-many: some/none/every
Expand Down Expand Up @@ -646,6 +652,14 @@ export class PolicyUtil extends QueryUtils {
return true;
}

/**
* Injects auth guard for read operations.
*/
injectForList(_db: CrudContract, _model: string, _args: any) {
// make select and include visible to the injection
return true;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ymc9
I'm not exactly sure if this is needed. Should the list logic go inside of the injectForRead?

The isList flag is on the handler doFind I'm a little confused on how the flow is.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree this list logic should go inside injectForRead. Maybe we just rename it to injectFoReadOrList and pass in the flag there. I think the only difference is the call to this.injectAuthGuardAsWhere, for read the arg is "read", and "list" for list.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!this.injectAuthGuardAsWhere(db, injected, model, 'read')) {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That part of code is not entirely easy to comprehend 😄. Please feel free to pass the PR to me to finish when you reach a point where you feel the basics are working.


//#endregion

//#region Checker
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/enhancements/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export type ModelCrudDef = {
create: ModelCreateDef;
update: ModelUpdateDef;
delete: ModelDeleteDef;
list: ModelListDef;
postUpdate: ModelPostUpdateDef;
};

Expand Down Expand Up @@ -207,6 +208,11 @@ type ModelUpdateDef = ModelCrudCommon;
*/
type ModelDeleteDef = ModelCrudCommon;

/**
* Policy definition for listing a model
*/
type ModelListDef = ModelCrudCommon;

/**
* Policy definition for post-update checking a model
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface DbOperations {
*/
export type PolicyKind = 'allow' | 'deny';

export type PolicyCrudKind = 'read' | 'create' | 'update' | 'delete';
export type PolicyCrudKind = 'read' | 'create' | 'update' | 'delete' | 'list';

/**
* Kinds of operations controlled by access policies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
accept('error', `expects a string literal`, { node: attr.args[0] });
return;
}
this.validatePolicyKinds(kind, ['create', 'read', 'update', 'delete', 'all'], attr, accept);
this.validatePolicyKinds(kind, ['create', 'read', 'update', 'delete', 'all', 'list'], attr, accept);

// @encrypted fields cannot be used in policy rules
this.rejectEncryptedFields(attr, accept);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export class PolicyGenerator {
this.writeModelUpdateDef(model, policies, writer, sourceFile);
this.writeModelPostUpdateDef(model, policies, writer, sourceFile);
this.writeModelDeleteDef(model, policies, writer, sourceFile);
this.writeModelListDef(model, policies, writer, sourceFile);
});
writer.writeLine(',');
}
Expand Down Expand Up @@ -347,6 +348,21 @@ export class PolicyGenerator {
writer.inlineBlock(() => {
this.writeCommonModelDef(model, 'delete', policies, writer, sourceFile);
});
writer.writeLine(',');
}

// writes `list: ...` for a given model
private writeModelListDef(
model: DataModel,
policies: PolicyAnalysisResult,
writer: CodeBlockWriter,
sourceFile: SourceFile
) {
writer.write(`list:`);
writer.inlineBlock(() => {
this.writeCommonModelDef(model, 'list', policies, writer, sourceFile);
});
writer.writeLine(',');
}

// writes `[kind]: ...` for a given model
Expand Down
4 changes: 2 additions & 2 deletions packages/schema/src/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ attribute @@schema(_ name: String) @@@prisma
* @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations.
* @param condition: a boolean expression that controls if the operation should be allowed.
*/
attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)
attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'list'", "'all'"]), _ condition: Boolean)

/**
* Defines an access policy that allows the annotated field to be read or updated.
Expand All @@ -545,7 +545,7 @@ attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'
* @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations.
* @param condition: a boolean expression that controls if the operation should be denied.
*/
attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)
attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'list'", "'all'"]), _ condition: Boolean)

/**
* Defines an access policy that denies the annotated field to be read or updated.
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function analyzePolicies(dataModel: DataModel) {
const read = toStaticPolicy('read', allows, denies);
const update = toStaticPolicy('update', allows, denies);
const del = toStaticPolicy('delete', allows, denies);
const list = toStaticPolicy('list', allows, denies);
const hasFieldValidation = hasValidationAttributes(dataModel);

return {
Expand All @@ -21,6 +22,7 @@ export function analyzePolicies(dataModel: DataModel) {
read,
update,
delete: del,
list,
allowAll: create === true && read === true && update === true && del === true,
denyAll: create === false && read === false && update === false && del === false,
hasFieldValidation,
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/typescript-expression-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type Options = {
thisExprContext?: string;
futureRefContext?: string;
context: ExpressionContext;
operationContext?: 'read' | 'create' | 'update' | 'postUpdate' | 'delete';
operationContext?: 'read' | 'create' | 'update' | 'postUpdate' | 'delete' | 'list';
};

type Casing = 'original' | 'upper' | 'lower' | 'capitalize' | 'uncapitalize';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,38 @@ describe('With Policy: toplevel operations', () => {

expect(await db.model.deleteMany()).toEqual(expect.objectContaining({ count: 0 }));
});

it('list tests', async () => {
const { enhance, prisma } = await loadSchema(
`
model Model {
id String @id @default(uuid())
value Int

@@allow('create', true)
@@allow('read', true)
@@allow('list', false)
}
`
);

const db = enhance();

// Create some items
await db.model.createMany({
data: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
});

const fromPrisma = await prisma.model.findMany();
expect(fromPrisma).toHaveLength(4);

const fromDb = await db.model.findMany();
console.log(fromDb);
// const firstItem = await db.model.findFirst();

// expect(firstItem).toBeTruthy();

// listing denied
// expect(fromDb).toHaveLength(0);
});
});
Loading