generated from alphane/template
Initial commit
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# Policies
|
||||
|
||||
Policies are used to control access to data in a controller, before it is returned to the client.
|
||||
Polices can be used in the following ways:
|
||||
|
||||
1. Build a policy instance and check the controller action matching boolean function.
|
||||
Controller#update -> Policy#update
|
||||
|
||||
```ts
|
||||
export class AccessGrantsController extends BaseController {
|
||||
async update() {
|
||||
const accessGrant = await this.loadAccessGrant()
|
||||
if (isNil(accessGrant)) {
|
||||
return this.response.status(404).json({ message: "Access grant not found." })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(accessGrant)
|
||||
if (!policy.update()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to update access grants on this dataset." })
|
||||
}
|
||||
|
||||
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
|
||||
try {
|
||||
const updatedAccessGrant = await UpdateService.perform(
|
||||
accessGrant,
|
||||
permittedAttributes,
|
||||
this.currentUser
|
||||
)
|
||||
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
|
||||
} catch (error) {
|
||||
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccessGrant(): Promise<AccessGrant | null> {
|
||||
return AccessGrant.findByPk(this.params.accessGrantId)
|
||||
}
|
||||
|
||||
private buildPolicy(accessGrant: AccessGrant) {
|
||||
return new AccessGrantsPolicy(this.currentUser, accessGrant)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. The previous example also demostrates a second way of using policies. The "permitted attributes" pattern. A policy can also be used to provide an "allow list" of attributes that a user is allowed to submit for a given controller action.
|
||||
|
||||
```ts
|
||||
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
|
||||
permittedAttributes(): Path[] {
|
||||
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Policies can also be used to restrict the results of an "index" or list action in a controller.
|
||||
In this case a bunch of scoping conditions are built up, and then passed to the "apply scope" function. This produces a query that, when executed, will only return the records that the current user is allowed to see.
|
||||
|
||||
```ts
|
||||
export class AccessGrantsController extends BaseController<AccessGrant> {
|
||||
async index() {
|
||||
const where = this.buildWhere()
|
||||
const scopes = this.buildFilterScopes()
|
||||
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
|
||||
|
||||
const totalCount = await scopedAccessGrants.count({ where })
|
||||
const accessGrants = await scopedAccessGrants.findAll({
|
||||
where,
|
||||
limit: this.pagination.limit,
|
||||
offset: this.pagination.offset,
|
||||
})
|
||||
|
||||
return this.response.json({ accessGrants, totalCount })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Policy#policyScope
|
||||
|
||||
The `policyScope` method is used to add a scope to the given model. This scope is permanently added to the model, though it likely shouldn't be used outside of the policy.
|
||||
|
||||
i.e.
|
||||
|
||||
```ts
|
||||
export class AccessRequestsPolicy extends PolicyFactory(AccessRequest) {
|
||||
static policyScope(user: User): FindOptions<Attributes<AccessRequest>> {
|
||||
if (user.isSystemAdmin || user.isBusinessAnalyst) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (user.isDataOwner) {
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
association: "dataset",
|
||||
where: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
where: {
|
||||
requestorId: user.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
can be considered equivalent to
|
||||
|
||||
```ts
|
||||
AccessReqeuest.addScope("policyScope", (user: User) => {
|
||||
if (user.isSystemAdmin || user.isBusinessAnalyst) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (user.isDataOwner) {
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
association: "dataset",
|
||||
where: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
where: {
|
||||
requestorId: user.id,
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
# Full Example
|
||||
|
||||
Here is a simple example of a controller using a policy to control access to a resource.
|
||||
The full cases might be more complex, but the "policy" pattern leaves space for that complexity to exist without cluttering the controller.
|
||||
|
||||
```ts
|
||||
export class AccessGrantsController extends BaseController<AccessGrant> {
|
||||
async index() {
|
||||
const where = this.buildWhere()
|
||||
const scopes = this.buildFilterScopes()
|
||||
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
|
||||
|
||||
const totalCount = await scopedAccessGrants.count({ where })
|
||||
const accessGrants = await scopedAccessGrants.findAll({
|
||||
where,
|
||||
limit: this.pagination.limit,
|
||||
offset: this.pagination.offset,
|
||||
})
|
||||
|
||||
return this.response.json({ accessGrants, totalCount })
|
||||
}
|
||||
|
||||
async create() {
|
||||
const accessGrant = await this.buildAccessGrant()
|
||||
if (isNil(accessGrant)) {
|
||||
return this.response.status(404).json({ message: "Dataset not found." })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(accessGrant)
|
||||
if (!policy.create()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to add access grants for this dataset." })
|
||||
}
|
||||
|
||||
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
|
||||
try {
|
||||
const accessGrant = await CreateService.perform(permittedAttributes, this.currentUser)
|
||||
return this.response.status(201).json({ accessGrant })
|
||||
} catch (error) {
|
||||
return this.response.status(422).json({ message: `Access grant creation failed: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async update() {
|
||||
const accessGrant = await this.loadAccessGrant()
|
||||
if (isNil(accessGrant)) {
|
||||
return this.response.status(404).json({ message: "Access grant not found." })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(accessGrant)
|
||||
if (!policy.update()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to update access grants on this dataset." })
|
||||
}
|
||||
|
||||
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
|
||||
try {
|
||||
const updatedAccessGrant = await UpdateService.perform(
|
||||
accessGrant,
|
||||
permittedAttributes,
|
||||
this.currentUser
|
||||
)
|
||||
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
|
||||
} catch (error) {
|
||||
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAccessGrant(): Promise<AccessGrant> {
|
||||
return AccessGrant.build(this.request.body)
|
||||
}
|
||||
|
||||
private async loadAccessGrant(): Promise<AccessGrant | null> {
|
||||
return AccessGrant.findByPk(this.params.accessGrantId)
|
||||
}
|
||||
|
||||
private buildPolicy(accessGrant: AccessGrant) {
|
||||
return new AccessGrantsPolicy(this.currentUser, accessGrant)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
and the policy
|
||||
|
||||
```ts
|
||||
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
|
||||
create(): boolean {
|
||||
// some code that might returns true
|
||||
return false
|
||||
}
|
||||
|
||||
update(): boolean {
|
||||
// some code that might returns true
|
||||
return false
|
||||
}
|
||||
|
||||
destroy(): boolean {
|
||||
// some code that might returns true
|
||||
return false
|
||||
}
|
||||
|
||||
permittedAttributes(): Path[] {
|
||||
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
|
||||
}
|
||||
|
||||
permittedAttributesForCreate(): Path[] {
|
||||
return ["datasetId", ...this.permittedAttributes()]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ModelStatic, Model, Attributes, FindOptions, ScopeOptions, literal } from "@sequelize/core"
|
||||
|
||||
import { User } from "@/models"
|
||||
import { Path, deepPick } from "@/utils/deep-pick"
|
||||
import { isInteger, isNil } from "lodash"
|
||||
|
||||
export type Actions = "show" | "create" | "update" | "destroy"
|
||||
|
||||
export const NO_RECORDS_SCOPE = { where: literal("1 = 0") }
|
||||
export const ALL_RECORDS_SCOPE = {}
|
||||
|
||||
/**
|
||||
* See PolicyFactory below for policy with scope helpers
|
||||
*/
|
||||
export class BasePolicy<M extends Model> {
|
||||
protected user: User
|
||||
protected record: M
|
||||
|
||||
constructor(user: User, record: M) {
|
||||
this.user = user
|
||||
this.record = record
|
||||
}
|
||||
|
||||
show(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
create(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
update(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
destroy(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
static policyScope<M extends Model>(user: User, ...args: unknown[]): FindOptions<Attributes<M>> {
|
||||
throw new Error("Derived classes must implement policyScope method")
|
||||
}
|
||||
|
||||
permitAttributes(record: Partial<M>): Partial<M> {
|
||||
return deepPick(record, this.permittedAttributes())
|
||||
}
|
||||
|
||||
permitAttributesForCreate(record: Partial<M>): Partial<M> {
|
||||
if (this.permittedAttributesForCreate !== BasePolicy.prototype.permittedAttributesForCreate) {
|
||||
return deepPick(record, this.permittedAttributesForCreate())
|
||||
} else {
|
||||
return deepPick(record, this.permittedAttributes())
|
||||
}
|
||||
}
|
||||
|
||||
permitAttributesForUpdate(record: Partial<M>): Partial<M> {
|
||||
if (this.permittedAttributesForUpdate !== BasePolicy.prototype.permittedAttributesForUpdate) {
|
||||
return deepPick(record, this.permittedAttributesForUpdate())
|
||||
} else {
|
||||
return deepPick(record, this.permittedAttributes())
|
||||
}
|
||||
}
|
||||
|
||||
permittedAttributes(): Path[] {
|
||||
throw new Error("Not Implemented")
|
||||
}
|
||||
|
||||
permittedAttributesForCreate(): Path[] {
|
||||
throw new Error("Not Implemented")
|
||||
}
|
||||
|
||||
permittedAttributesForUpdate(): Path[] {
|
||||
throw new Error("Not Implemented")
|
||||
}
|
||||
|
||||
setNumberNullIfEmpty(input: number | null | undefined): number | null | undefined {
|
||||
let output = input
|
||||
if (!isNil(input) && !isInteger(input)) output = undefined
|
||||
if (!isNil(input) && input == 0) output = undefined
|
||||
return output
|
||||
}
|
||||
|
||||
setNumberZeroIfEmpty(input: number | null | undefined): number {
|
||||
let output = input
|
||||
if (!isNil(input) && !isInteger(input)) output = 0
|
||||
if (!isNil(input) && input == 0) output = 0
|
||||
return output ?? 0
|
||||
}
|
||||
/**
|
||||
* Add to support return policy information via this.reponse.json({ someObject, policy })
|
||||
*
|
||||
* If this method becomes complex, it should be broken out into a serializer.
|
||||
*
|
||||
* @returns a JSON representation of the policy
|
||||
*/
|
||||
toJSON(): Record<Actions, boolean> {
|
||||
return {
|
||||
show: this.show(),
|
||||
create: this.create(),
|
||||
update: this.update(),
|
||||
destroy: this.destroy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See api/node_modules/sequelize/types/model.d.ts -> Model -> scope
|
||||
export type BaseScopeOptions = string | ScopeOptions
|
||||
|
||||
export const POLICY_SCOPE_NAME = "policyScope"
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AllArgsButFirstOne<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never
|
||||
|
||||
export function PolicyFactory<M extends Model, T extends Model = M>(modelClass: ModelStatic<M>) {
|
||||
const policyClass = class Policy extends BasePolicy<T> {
|
||||
static applyScope<P extends typeof Policy>(
|
||||
this: P,
|
||||
scopes: BaseScopeOptions[],
|
||||
user: User,
|
||||
...extraPolicyScopeArgs: AllArgsButFirstOne<Parameters<P["policyScope"]>>
|
||||
): ModelStatic<M> {
|
||||
this.ensurePolicyScope()
|
||||
return modelClass.withScope([
|
||||
...scopes,
|
||||
{ method: [POLICY_SCOPE_NAME, user, ...extraPolicyScopeArgs] },
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Just in time scope creation for model class.
|
||||
* TODO: to have scope creation occur at definition time, instead of execution time.
|
||||
*/
|
||||
static ensurePolicyScope() {
|
||||
if (Object.prototype.hasOwnProperty.call(modelClass.options.scopes, POLICY_SCOPE_NAME)) {
|
||||
return
|
||||
}
|
||||
|
||||
modelClass.addScope(POLICY_SCOPE_NAME, this.policyScope.bind(modelClass))
|
||||
}
|
||||
}
|
||||
|
||||
return policyClass
|
||||
}
|
||||
|
||||
export default PolicyFactory
|
||||
@@ -0,0 +1,3 @@
|
||||
// Policy Bundles
|
||||
export { type BaseScopeOptions } from "./base-policy"
|
||||
export { UsersPolicy } from "./users-policy"
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Attributes, FindOptions } from "@sequelize/core"
|
||||
|
||||
import { Path } from "@/utils/deep-pick"
|
||||
import { User } from "@/models"
|
||||
import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy"
|
||||
|
||||
export class UsersPolicy extends PolicyFactory(User) {
|
||||
show(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.user.id === this.record.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
create(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
update(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.user.id === this.record.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
destroy(): boolean {
|
||||
if (this.user.id === this.record.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
permittedAttributes(): Path[] {
|
||||
const attributes: (keyof Attributes<User>)[] = [
|
||||
"email",
|
||||
"auth0Subject",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"displayName",
|
||||
]
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
permittedAttributesForCreate(): Path[] {
|
||||
return [...this.permittedAttributes()]
|
||||
}
|
||||
|
||||
permittedAttributesForUpdate(): Path[] {
|
||||
return [...this.permittedAttributes()]
|
||||
}
|
||||
|
||||
static policyScope(user: User): FindOptions<Attributes<User>> {
|
||||
if (user.isSystemAdmin) return ALL_RECORDS_SCOPE
|
||||
|
||||
return { where: { id: user.id } }
|
||||
}
|
||||
}
|
||||
|
||||
export default UsersPolicy
|
||||
Reference in New Issue
Block a user