feat: introduce role based access control

This commit is contained in:
Ali BARIN
2023-06-22 22:20:10 +00:00
parent ff774c2e8e
commit 399fb8312a
25 changed files with 376 additions and 19 deletions

View File

@@ -0,0 +1,32 @@
import { Knex } from 'knex';
import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('roles', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name').notNullable();
table.string('key').notNullable();
table.string('description');
table.timestamps(true, true);
});
const uniqueUserRoles = await knex('users')
.select('role')
.groupBy('role');
for (const { role } of uniqueUserRoles) {
// skip empty roles
if (!role) continue;
await knex('roles').insert({
name: capitalize(role),
key: lowerCase(role),
});
}
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles');
}

View File

@@ -0,0 +1,25 @@
import { Knex } from 'knex';
const getPermission = (subject: string, actions: string[]) => actions.map(action => ({ subject, action }));
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('action').notNullable();
table.string('subject').notNullable();
table.timestamps(true, true);
});
await knex('permissions').insert([
...getPermission('Connection', ['create', 'read', 'delete', 'update']),
...getPermission('Execution', ['read']),
...getPermission('Flow', ['create', 'delete', 'publish', 'read', 'update']),
...getPermission('Role', ['create', 'delete', 'read', 'update']),
...getPermission('User', ['create', 'delete', 'read', 'update']),
]);
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('permissions');
}

View File

@@ -0,0 +1,25 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('roles_permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('role_id').references('id').inTable('roles');
table.uuid('permission_id').references('id').inTable('permissions');
});
const roles = await knex('roles').select('id');
const permissions = await knex('permissions').select('id');
for (const role of roles) {
for (const permission of permissions) {
await knex('roles_permissions').insert({
role_id: role.id,
permission_id: permission.id,
});
}
}
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles_permissions');
}

View File

@@ -0,0 +1,29 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('users', async (table) => {
table.uuid('role_id').references('id').inTable('roles');
});
const theRole = await knex('roles').select('id').limit(1).first();
const roles = await knex('roles').select('id', 'key');
for (const role of roles) {
await knex('users')
.where({
role: role.key
})
.update({
role_id: role.id
});
}
// backfill not-migratables
await knex('users').whereNull('role_id').update({ role_id: theRole.id });
}
export async function down(knex: Knex): Promise<void> {
return await knex.schema.table('users', (table) => {
table.dropColumn('role_id');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('users', async (table) => {
table.dropColumn('role');
});
}
export async function down(knex: Knex): Promise<void> {
return await knex.schema.table('users', (table) => {
table.string('role').defaultTo('user');
});
}

View File

@@ -1,4 +1,5 @@
import User from '../../models/user';
import Role from '../../models/role';
type Params = {
input: {
@@ -17,11 +18,13 @@ const createUser = async (_parent: unknown, params: Params) => {
throw new Error('User already exists!');
}
const role = await Role.query().findOne({ key: 'user' });
const user = await User.query().insert({
fullName,
email,
password,
role: 'user',
roleId: role.id,
});
return user;

View File

@@ -18,6 +18,8 @@ const updateFlowStatus = async (
params: Params,
context: Context
) => {
context.currentUser.can('publish', 'Flow');
let flow = await context.currentUser
.$relatedQuery('flows')
.findOne({
@@ -55,7 +57,7 @@ const updateFlowStatus = async (
} else {
if (newActiveValue) {
flow = await flow.$query().patchAndFetch({
published_at: new Date().toISOString(),
publishedAt: new Date().toISOString(),
});
const jobName = `${JOB_NAME}-${flow.id}`;
@@ -78,9 +80,12 @@ const updateFlowStatus = async (
}
}
flow = await flow.$query().withGraphFetched('steps').patchAndFetch({
active: newActiveValue,
});
flow = await flow
.$query()
.withGraphFetched('steps')
.patchAndFetch({
active: newActiveValue,
});
return flow;
};

View File

@@ -10,6 +10,8 @@ type Params = {
};
const getFlows = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'Flow');
const flowsQuery = context.currentUser
.$relatedQuery('flows')
.joinRelated({

View File

@@ -12,7 +12,15 @@ const isAuthenticated = rule()(async (_parent, _args, req) => {
const { userId } = jwt.verify(token, appConfig.appSecretKey) as {
userId: string;
};
req.currentUser = await User.query().findById(userId).throwIfNotFound();
req.currentUser = await User
.query()
.findById(userId)
.joinRelated({
permissions: true,
})
.withGraphFetched({
permissions: true,
});
return true;
} catch (error) {

View File

@@ -1,10 +1,11 @@
import { Model } from 'objection';
import ExtendedQueryBuilder from '../models/query-builder';
import type Base from '../models/base';
const paginate = async (
query: ExtendedQueryBuilder<Model, Model[]>,
limit: number,
offset: number
offset: number,
) => {
if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100');
@@ -20,11 +21,9 @@ const paginate = async (
currentPage: Math.ceil(offset / limit + 1),
totalPages: Math.ceil(count / limit),
},
edges: records.map((record: Model) => {
return {
node: record,
};
}),
edges: records.map((record: Base) => ({
node: record,
})),
};
};

View File

@@ -40,6 +40,9 @@ class Connection extends Base {
userId: { type: 'string', format: 'uuid' },
verified: { type: 'boolean', default: false },
draft: { type: 'boolean' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -31,6 +31,9 @@ class ExecutionStep extends Base {
dataOut: { type: ['object', 'null'] },
status: { type: 'string', enum: ['success', 'failure'] },
errorDetails: { type: ['object', 'null'] },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -22,6 +22,9 @@ class Execution extends Base {
flowId: { type: 'string', format: 'uuid' },
testRun: { type: 'boolean', default: false },
internalId: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -19,7 +19,7 @@ class Flow extends Base {
status: 'paused' | 'published' | 'draft';
steps: Step[];
triggerStep: Step;
published_at: string;
publishedAt: string;
remoteWebhookId: string;
executions?: Execution[];
lastExecution?: Execution;
@@ -37,6 +37,10 @@ class Flow extends Base {
userId: { type: 'string', format: 'uuid' },
remoteWebhookId: { type: 'string' },
active: { type: 'boolean' },
publishedAt: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -0,0 +1,24 @@
import Base from './base';
class Permission extends Base {
id: string;
action: string;
subject: string;
static tableName = 'permissions';
static jsonSchema = {
type: 'object',
required: ['action', 'subject'],
properties: {
id: { type: 'string', format: 'uuid' },
action: { type: 'string', minLength: 1 },
subject: { type: 'string', minLength: 1 },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
}
export default Permission;

View File

@@ -15,7 +15,7 @@ const buildQueryBuidlerForClass = (): ForClassMethod => {
modelClass
);
qb.onBuild((builder) => {
if (!builder.context().withSoftDeleted) {
if (!builder.context().withSoftDeleted && qb.modelClass().jsonSchema.properties.deletedAt) {
builder.whereNull(
`${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}`
);

View File

@@ -0,0 +1,39 @@
import Base from './base';
import User from './user';
class Role extends Base {
id!: string;
name!: string;
key: string;
description: string;
users?: User[];
static tableName = 'roles';
static jsonSchema = {
type: 'object',
required: ['name', 'key'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1 },
description: { type: ['string', 'null'], minLength: 1, maxLength: 255 },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
static relationMappings = () => ({
users: {
relation: Base.HasManyRelation,
modelClass: User,
join: {
from: 'roles.id',
to: 'users.role_id',
},
},
});
}
export default Role;

View File

@@ -46,6 +46,9 @@ class Step extends Base {
position: { type: 'integer' },
parameters: { type: 'object' },
webhookPath: { type: ['string', 'null'] },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -46,6 +46,9 @@ class Subscription extends Base {
nextBillDate: { type: 'string' },
lastBillDate: { type: 'string' },
cancellationEffectiveDate: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
@@ -84,7 +87,7 @@ class Subscription extends Base {
return (
this.status === 'deleted' &&
Number(this.cancellationEffectiveDate) >
DateTime.now().startOf('day').toMillis()
DateTime.now().startOf('day').toMillis()
);
}

View File

@@ -24,6 +24,9 @@ class UsageData extends Base {
subscriptionId: { type: 'string', format: 'uuid' },
consumedTaskCount: { type: 'integer' },
nextResetAt: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};

View File

@@ -1,13 +1,18 @@
import crypto from 'node:crypto';
import { QueryContext, ModelOptions } from 'objection';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { Ability } from '@casl/ability';
import type { Subject } from '@casl/ability';
import appConfig from '../config/app';
import Base from './base';
import ExtendedQueryBuilder from './query-builder';
import Connection from './connection';
import Flow from './flow';
import Step from './step';
import Role from './role';
import Permission from './permission';
import Execution from './execution';
import UsageData from './usage-data.ee';
import Subscription from './subscription.ee';
@@ -16,8 +21,8 @@ class User extends Base {
id!: string;
fullName!: string;
email!: string;
roleId: string;
password!: string;
role: string;
resetPasswordToken: string;
resetPasswordTokenSentAt: string;
trialExpiryDate: string;
@@ -29,6 +34,8 @@ class User extends Base {
currentUsageData?: UsageData;
subscriptions?: Subscription[];
currentSubscription?: Subscription;
role: Role;
permissions: Permission[];
static tableName = 'users';
@@ -41,7 +48,13 @@ class User extends Base {
fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string', minLength: 1, maxLength: 255 },
role: { type: 'string', enum: ['admin', 'user'] },
resetPasswordToken: { type: 'string' },
resetPasswordTokenSentAt: { type: 'string' },
trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
@@ -124,6 +137,26 @@ class User extends Base {
builder.orderBy('created_at', 'desc').limit(1).first();
},
},
role: {
relation: Base.HasOneRelation,
modelClass: Role,
join: {
from: 'roles.id',
to: 'users.role_id',
},
},
permissions: {
relation: Base.ManyToManyRelation,
modelClass: Permission,
join: {
from: 'users.role_id',
through: {
from: 'roles_permissions.role_id',
to: 'roles_permissions.permission_id',
},
to: 'permissions.id',
},
},
});
login(password: string) {
@@ -248,6 +281,30 @@ class User extends Base {
});
}
}
get ability() {
if (!this.permissions) {
throw new Error('User.permissions must be fetched!');
}
return new Ability(this.permissions);
}
can(action: string, subject: Subject) {
const can = this.ability.can(action, subject);
if (!can) throw new Error('Not authorized!');
return can;
}
cannot(action: string, subject: Subject) {
const cannot = this.ability.cannot(action, subject);
if (cannot) throw new Error('Not authorized!');
return cannot;
}
}
export default User;