feat(rbac): implemented backend base apis for rbac permission system

This commit is contained in:
Akhil Mohan
2023-08-14 20:44:55 +05:30
parent 6bca7dcc58
commit 1bf9041ac9
19 changed files with 833 additions and 327 deletions

View File

@@ -10,6 +10,8 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.319.0",
"@casl/ability": "^6.5.0",
"@casl/mongoose": "^7.2.1",
"@godaddy/terminus": "^4.12.0",
"@node-saml/passport-saml": "^4.0.4",
"@octokit/rest": "^19.0.5",
@@ -59,7 +61,8 @@
"typescript": "^4.9.3",
"utility-types": "^3.10.0",
"winston": "^3.8.2",
"winston-loki": "^6.0.7"
"winston-loki": "^6.0.6",
"zod": "^3.21.4"
},
"devDependencies": {
"@jest/globals": "^29.3.1",
@@ -3339,6 +3342,26 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@casl/ability": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz",
"integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==",
"dependencies": {
"@ucast/mongo2js": "^1.3.0"
},
"funding": {
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
}
},
"node_modules/@casl/mongoose": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz",
"integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==",
"peerDependencies": {
"@casl/ability": "^6.3.2",
"mongoose": "^6.0.13 || ^7.0.0"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -6174,6 +6197,37 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@ucast/core": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz",
"integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g=="
},
"node_modules/@ucast/js": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz",
"integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==",
"dependencies": {
"@ucast/core": "^1.0.0"
}
},
"node_modules/@ucast/mongo": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz",
"integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==",
"dependencies": {
"@ucast/core": "^1.4.1"
}
},
"node_modules/@ucast/mongo2js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz",
"integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==",
"dependencies": {
"@ucast/core": "^1.6.1",
"@ucast/js": "^3.0.0",
"@ucast/mongo": "^2.4.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -16626,6 +16680,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
},
"dependencies": {
@@ -19324,6 +19386,20 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@casl/ability": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz",
"integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==",
"requires": {
"@ucast/mongo2js": "^1.3.0"
}
},
"@casl/mongoose": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz",
"integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==",
"requires": {}
},
"@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -21575,6 +21651,37 @@
"eslint-visitor-keys": "^3.3.0"
}
},
"@ucast/core": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz",
"integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g=="
},
"@ucast/js": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz",
"integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==",
"requires": {
"@ucast/core": "^1.0.0"
}
},
"@ucast/mongo": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz",
"integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==",
"requires": {
"@ucast/core": "^1.4.1"
}
},
"@ucast/mongo2js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz",
"integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==",
"requires": {
"@ucast/core": "^1.6.1",
"@ucast/js": "^3.0.0",
"@ucast/mongo": "^2.4.0"
}
},
"@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -29273,6 +29380,11 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
},
"zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw=="
}
}
}

View File

@@ -1,6 +1,8 @@
{
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.319.0",
"@casl/ability": "^6.5.0",
"@casl/mongoose": "^7.2.1",
"@godaddy/terminus": "^4.12.0",
"@node-saml/passport-saml": "^4.0.4",
"@octokit/rest": "^19.0.5",
@@ -50,7 +52,8 @@
"typescript": "^4.9.3",
"utility-types": "^3.10.0",
"winston": "^3.8.2",
"winston-loki": "^6.0.7"
"winston-loki": "^6.0.6",
"zod": "^3.21.4"
},
"name": "infisical-api",
"version": "1.0.0",

View File

@@ -16,6 +16,7 @@ import * as workspaceController from "./workspaceController";
import * as secretScanningController from "./secretScanningController";
import * as webhookController from "./webhookController";
import * as secretImportController from "./secretImportController";
import * as roleController from "./roleController";
export {
authController,
@@ -35,5 +36,6 @@ export {
workspaceController,
secretScanningController,
webhookController,
roleController,
secretImportController
};

View File

@@ -0,0 +1,159 @@
import { Request, Response } from "express";
import { BadRequestError } from "../../utils/errors";
import Role from "../../models/role";
import {
GeneralPermissionActions,
OrgPermissionSubjects,
adminPermissions,
getUserOrgPermissions,
memberPermissions
} from "../../services/RoleService";
import { validateRequest } from "../../helpers/validation";
import {
CreateRoleSchema,
DeleteRoleSchema,
GetRoleSchema,
UpdateRoleSchema
} from "../../validation";
export const createRole = async (req: Request, res: Response) => {
const {
body: { workspaceId, name, description, slug, permissions, orgId }
} = await validateRequest(CreateRoleSchema, req);
const orgPermission = await getUserOrgPermissions(req.user.id, orgId);
if (orgPermission.cannot(GeneralPermissionActions.Create, OrgPermissionSubjects.Role)) {
throw BadRequestError({ message: "User doesn't have the permission." });
}
const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule
const existingRole = await Role.findOne({ organization: orgId, workspace: workspaceId, slug });
if (existingRole) {
throw BadRequestError({ message: "Role already exist" });
}
const role = new Role({
organization: orgId,
workspace: workspaceId,
isOrgRole,
name,
slug,
permissions,
description
});
await role.save();
res.status(200).json({
message: "Successfully created role",
data: {
role
}
});
};
export const updateRole = async (req: Request, res: Response) => {
const {
params: { id },
body: { name, description, slug, permissions, workspaceId, orgId }
} = await validateRequest(UpdateRoleSchema, req);
const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule
const orgPermission = await getUserOrgPermissions(req.user.id, orgId);
if (orgPermission.cannot(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role)) {
throw BadRequestError({ message: "User doesn't have the permission." });
}
if (slug) {
const existingRole = await Role.findOne({
organization: orgId,
slug,
isOrgRole,
workspace: workspaceId
});
if (existingRole && existingRole.id !== id) {
throw BadRequestError({ message: "Role already exist" });
}
}
const role = await Role.findByIdAndUpdate(
id,
{ name, description, slug, permissions },
{ returnDocument: "after" }
);
if (!role) {
throw BadRequestError({ message: "Role not found" });
}
res.status(200).json({
message: "Successfully updated role",
data: {
role
}
});
};
export const deleteRole = async (req: Request, res: Response) => {
const {
params: { id }
} = await validateRequest(DeleteRoleSchema, req);
const role = await Role.findById(id);
if (!role) {
throw BadRequestError({ message: "Role not found" });
}
const orgPermission = await getUserOrgPermissions(req.user.id, role.organization.toString());
if (orgPermission.cannot(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role)) {
throw BadRequestError({ message: "User doesn't have the permission." });
}
await Role.findByIdAndDelete(role.id);
res.status(200).json({
message: "Successfully deleted role",
data: {
role
}
});
};
export const getRoles = async (req: Request, res: Response) => {
const {
query: { workspaceId, orgId }
} = await validateRequest(GetRoleSchema, req);
const isOrgRole = !workspaceId;
const orgPermission = await getUserOrgPermissions(req.user.id, orgId);
if (orgPermission.cannot(GeneralPermissionActions.Read, OrgPermissionSubjects.Role)) {
throw BadRequestError({ message: "User doesn't have the permission." });
}
const roles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId });
res.status(200).json({
message: "Successfully fetched role list",
data: {
roles: [
{
name: "Owner",
slug: "owner",
description: "Complete administration access over the organization.",
permissions: adminPermissions.rules
},
{
name: "Admin",
slug: "admin",
description: "Complete administration access over the organization",
permissions: adminPermissions.rules
},
{
name: "Member",
slug: "member",
description: "Non-administrative role in an organization",
permissions: memberPermissions.rules
},
...roles
]
}
});
};

View File

@@ -1,13 +1,11 @@
import { Request, Response } from "express";
import { Types } from "mongoose";
import {
Membership,
MembershipOrg,
ServiceAccount,
Workspace,
} from "../../models";
import { Membership, MembershipOrg, ServiceAccount, Workspace } from "../../models";
import { deleteMembershipOrg } from "../../helpers/membershipOrg";
import { updateSubscriptionOrgQuantity } from "../../helpers/organization";
import Role from "../../models/role";
import { BadRequestError } from "../../utils/errors";
import { CUSTOM } from "../../variables";
/**
* Return memberships for organization with id [organizationId]
@@ -51,13 +49,13 @@ export const getOrganizationMemberships = async (req: Request, res: Response) =>
const { organizationId } = req.params;
const memberships = await MembershipOrg.find({
organization: organizationId,
organization: organizationId
}).populate("user", "+publicKey");
return res.status(200).send({
memberships,
memberships
});
}
};
/**
* Update role of membership with id [membershipId] to role [role]
@@ -121,19 +119,34 @@ export const updateOrganizationMembership = async (req: Request, res: Response)
const { membershipId } = req.params;
const { role } = req.body;
const isCustomRole = !["admin", "member", "owner"].includes(role);
if (isCustomRole) {
const orgRole = await Role.findOne({ slug: role, isOrgRole: true });
if (!orgRole) throw BadRequestError({ message: "Role not found" });
const membership = await MembershipOrg.findByIdAndUpdate(membershipId, {
role: CUSTOM,
customRole: orgRole
});
return res.status(200).send({
membership
});
}
const membership = await MembershipOrg.findByIdAndUpdate(
membershipId,
{
role,
}, {
new: true,
role
},
{
new: true
}
);
return res.status(200).send({
membership,
membership
});
}
};
/**
* Delete organization membership with id [membershipId]
@@ -182,17 +195,17 @@ export const deleteOrganizationMembership = async (req: Request, res: Response)
// delete organization membership
const membership = await deleteMembershipOrg({
membershipOrgId: membershipId,
membershipOrgId: membershipId
});
await updateSubscriptionOrgQuantity({
organizationId: membership.organization.toString(),
organizationId: membership.organization.toString()
});
return res.status(200).send({
membership,
membership
});
}
};
/**
* Return workspaces for organization with id [organizationId] that user has
@@ -240,7 +253,7 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) =>
(
await Workspace.find(
{
organization: organizationId,
organization: organizationId
},
"_id"
)
@@ -249,16 +262,16 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) =>
const workspaces = (
await Membership.find({
user: req.user._id,
user: req.user._id
}).populate("workspace")
)
.filter((m) => workspacesSet.has(m.workspace._id.toString()))
.map((m) => m.workspace);
return res.status(200).send({
workspaces,
return res.status(200).send({
workspaces
});
}
};
/**
* Return service accounts for organization with id [organizationId]
@@ -269,10 +282,10 @@ export const getOrganizationServiceAccounts = async (req: Request, res: Response
const { organizationId } = req.params;
const serviceAccounts = await ServiceAccount.find({
organization: new Types.ObjectId(organizationId),
organization: new Types.ObjectId(organizationId)
});
return res.status(200).send({
serviceAccounts,
serviceAccounts
});
}
};

View File

@@ -13,27 +13,27 @@ import { BadRequestError, MembershipNotFoundError } from "../utils/errors";
export const validateMembership = async ({
userId,
workspaceId,
acceptedRoles,
acceptedRoles
}: {
userId: Types.ObjectId | string;
workspaceId: Types.ObjectId | string;
acceptedRoles?: Array<"admin" | "member">;
acceptedRoles?: Array<"admin" | "member" | "custom">;
}) => {
const membership = await Membership.findOne({
user: userId,
workspace: workspaceId,
workspace: workspaceId
}).populate("workspace");
if (!membership) {
throw MembershipNotFoundError({
message: "Failed to find workspace membership",
message: "Failed to find workspace membership"
});
}
if (acceptedRoles) {
if (!acceptedRoles.includes(membership.role)) {
throw BadRequestError({
message: "Failed authorization for membership role",
message: "Failed authorization for membership role"
});
}
}
@@ -62,7 +62,7 @@ export const findMembership = async (queryObj: any) => {
export const addMemberships = async ({
userIds,
workspaceId,
roles,
roles
}: {
userIds: string[];
workspaceId: string;
@@ -74,15 +74,15 @@ export const addMemberships = async ({
filter: {
user: userId,
workspace: workspaceId,
role: roles[idx],
role: roles[idx]
},
update: {
user: userId,
workspace: workspaceId,
role: roles[idx],
},
upsert: true,
role: roles[idx]
},
upsert: true
}
};
});
await Membership.bulkWrite(operations as any);
@@ -95,7 +95,7 @@ export const addMemberships = async ({
*/
export const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
const deletedMembership = await Membership.findOneAndDelete({
_id: membershipId,
_id: membershipId
});
// delete keys associated with the membership
@@ -103,7 +103,7 @@ export const deleteMembership = async ({ membershipId }: { membershipId: string
// case: membership had a registered user
await Key.deleteMany({
receiver: deletedMembership.user,
workspace: deletedMembership.workspace,
workspace: deletedMembership.workspace
});
}

View File

@@ -1,14 +1,6 @@
import { Types } from "mongoose";
import {
Key,
Membership,
MembershipOrg,
Workspace,
} from "../models";
import {
MembershipOrgNotFoundError,
UnauthorizedRequestError,
} from "../utils/errors";
import { Key, Membership, MembershipOrg, Workspace } from "../models";
import { MembershipOrgNotFoundError, UnauthorizedRequestError } from "../utils/errors";
/**
* Validate that user with id [userId] is a member of organization with id [organizationId]
@@ -22,16 +14,16 @@ export const validateMembershipOrg = async ({
userId,
organizationId,
acceptedRoles,
acceptedStatuses,
acceptedStatuses
}: {
userId: Types.ObjectId;
organizationId: Types.ObjectId;
acceptedRoles?: Array<"owner" | "admin" | "member">;
acceptedRoles?: Array<"owner" | "admin" | "member" | "custom">;
acceptedStatuses?: Array<"invited" | "accepted">;
}) => {
const membershipOrg = await MembershipOrg.findOne({
user: userId,
organization: organizationId,
organization: organizationId
});
if (!membershipOrg) {
@@ -40,18 +32,22 @@ export const validateMembershipOrg = async ({
if (acceptedRoles) {
if (!acceptedRoles.includes(membershipOrg.role)) {
throw UnauthorizedRequestError({ message: "Failed to validate organization membership role" });
throw UnauthorizedRequestError({
message: "Failed to validate organization membership role"
});
}
}
if (acceptedStatuses) {
if (!acceptedStatuses.includes(membershipOrg.status)) {
throw UnauthorizedRequestError({ message: "Failed to validate organization membership status" });
throw UnauthorizedRequestError({
message: "Failed to validate organization membership status"
});
}
}
return membershipOrg;
}
};
/**
* Return organization membership matching criteria specified in
@@ -76,7 +72,7 @@ export const addMembershipsOrg = async ({
userIds,
organizationId,
roles,
statuses,
statuses
}: {
userIds: string[];
organizationId: string;
@@ -90,16 +86,16 @@ export const addMembershipsOrg = async ({
user: userId,
organization: organizationId,
role: roles[idx],
status: statuses[idx],
status: statuses[idx]
},
update: {
user: userId,
organization: organizationId,
role: roles[idx],
status: statuses[idx],
},
upsert: true,
status: statuses[idx]
},
upsert: true
}
};
});
@@ -111,13 +107,9 @@ export const addMembershipsOrg = async ({
* @param {Object} obj
* @param {String} obj.membershipOrgId - id of organization membership to delete
*/
export const deleteMembershipOrg = async ({
membershipOrgId,
}: {
membershipOrgId: string;
}) => {
export const deleteMembershipOrg = async ({ membershipOrgId }: { membershipOrgId: string }) => {
const deletedMembershipOrg = await MembershipOrg.findOneAndDelete({
_id: membershipOrgId,
_id: membershipOrgId
});
if (!deletedMembershipOrg) throw new Error("Failed to delete organization membership");
@@ -128,22 +120,22 @@ export const deleteMembershipOrg = async ({
const workspaces = (
await Workspace.find({
organization: deletedMembershipOrg.organization,
organization: deletedMembershipOrg.organization
})
).map((w) => w._id.toString());
await Membership.deleteMany({
user: deletedMembershipOrg.user,
workspace: {
$in: workspaces,
},
$in: workspaces
}
});
await Key.deleteMany({
receiver: deletedMembershipOrg.user,
workspace: {
$in: workspaces,
},
$in: workspaces
}
});
}

View File

@@ -0,0 +1,17 @@
import type { Request } from "express";
import { AnyZodObject, ZodError, z } from "zod";
import { BadRequestError } from "../utils/errors";
export async function validateRequest<T extends AnyZodObject>(
schema: T,
req: Request
): Promise<z.infer<T>> {
try {
return schema.parseAsync(req);
} catch (error) {
if (error instanceof ZodError) {
throw BadRequestError({ message: error.message });
}
return BadRequestError({ message: JSON.stringify(error) });
}
}

View File

@@ -37,6 +37,7 @@ import {
membership as v1MembershipRouter,
organization as v1OrganizationRouter,
password as v1PasswordRouter,
role as v1RoleRouter,
secretImport as v1SecretImportRouter,
secret as v1SecretRouter,
secretsFolder as v1SecretsFolder,
@@ -173,6 +174,7 @@ const main = async () => {
app.use("/api/v1/secret-scanning", v1SecretScanningRouter);
app.use("/api/v1/webhooks", v1WebhooksRouter);
app.use("/api/v1/secret-imports", v1SecretImportRouter);
app.use("/api/v1/roles", v1RoleRouter);
// v2 routes (improvements)
app.use("/api/v2/signup", v2SignupRouter);

View File

@@ -1,9 +1,9 @@
import { Schema, Types, model } from "mongoose";
import { ADMIN, MEMBER } from "../variables";
import { ADMIN, CUSTOM, MEMBER } from "../variables";
export interface IMembershipPermission {
environmentSlug: string,
ability: string
environmentSlug: string;
ability: string;
}
export interface IMembership {
@@ -11,23 +11,24 @@ export interface IMembership {
user: Types.ObjectId;
inviteEmail?: string;
workspace: Types.ObjectId;
role: "admin" | "member";
deniedPermissions: IMembershipPermission[]
role: "admin" | "member" | "custom";
customRole: Types.ObjectId;
deniedPermissions: IMembershipPermission[];
}
const membershipSchema = new Schema<IMembership>(
{
user: {
type: Schema.Types.ObjectId,
ref: "User",
ref: "User"
},
inviteEmail: {
type: String,
type: String
},
workspace: {
type: Schema.Types.ObjectId,
ref: "Workspace",
required: true,
required: true
},
deniedPermissions: {
type: [
@@ -35,20 +36,24 @@ const membershipSchema = new Schema<IMembership>(
environmentSlug: String,
ability: {
type: String,
enum: ["read", "write"],
},
},
enum: ["read", "write"]
}
}
],
default: [],
default: []
},
role: {
type: String,
enum: [ADMIN, MEMBER],
required: true,
enum: [ADMIN, MEMBER, CUSTOM],
required: true
},
customRole: {
type: Schema.Types.ObjectId,
ref: "Role"
}
},
{
timestamps: true,
timestamps: true
}
);

View File

@@ -1,12 +1,13 @@
import { Document, Schema, Types, model } from "mongoose";
import { ACCEPTED, ADMIN, INVITED, MEMBER, OWNER } from "../variables";
import { ACCEPTED, ADMIN, CUSTOM, INVITED, MEMBER, OWNER } from "../variables";
export interface IMembershipOrg extends Document {
_id: Types.ObjectId;
user: Types.ObjectId;
inviteEmail: string;
organization: Types.ObjectId;
role: "owner" | "admin" | "member";
role: "owner" | "admin" | "member" | "custom";
customRole: Types.ObjectId;
status: "invited" | "accepted";
}
@@ -14,32 +15,35 @@ const membershipOrgSchema = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: "User",
ref: "User"
},
inviteEmail: {
type: String,
type: String
},
organization: {
type: Schema.Types.ObjectId,
ref: "Organization",
ref: "Organization"
},
role: {
type: String,
enum: [OWNER, ADMIN, MEMBER],
required: true,
enum: [OWNER, ADMIN, MEMBER, CUSTOM],
required: true
},
status: {
type: String,
enum: [INVITED, ACCEPTED],
required: true,
required: true
},
customRole: {
type: Schema.Types.ObjectId,
ref: "Role"
}
},
{
timestamps: true,
timestamps: true
}
);
export const MembershipOrg = model<IMembershipOrg>(
"MembershipOrg",
membershipOrgSchema
);
const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);
export default MembershipOrg;

View File

@@ -0,0 +1,53 @@
import { Schema, Types, model } from "mongoose";
export interface IRole {
_id: Types.ObjectId;
name: string;
description: string;
slug: string;
permissions: Array<unknown>;
workspace: Types.ObjectId;
organization: Types.ObjectId;
isOrgRole: boolean;
}
const roleSchema = new Schema<IRole>(
{
name: {
type: String,
required: true
},
organization: {
type: Schema.Types.ObjectId,
ref: "Organization",
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: "Workspace"
},
isOrgRole: {
type: Boolean,
required: true,
select: false
},
description: {
type: String
},
slug: {
type: String,
required: true
},
permissions: {
type: Array,
required: true
}
},
{
timestamps: true
}
);
const Role = model<IRole>("Role", roleSchema);
export default Role;

View File

@@ -17,6 +17,7 @@ import integrationAuth from "./integrationAuth";
import secretsFolder from "./secretsFolder";
import webhooks from "./webhook";
import secretImport from "./secretImport";
import role from "./role";
export {
signup,
@@ -37,5 +38,6 @@ export {
integrationAuth,
secretsFolder,
webhooks,
secretImport
secretImport,
role
};

View File

@@ -0,0 +1,20 @@
import express from "express";
import { roleController } from "../../controllers/v1";
import { requireAuth } from "../../middleware";
import { AuthMode } from "../../variables";
const router = express.Router();
router.post("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.createRole);
router.patch("/:id", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.updateRole);
router.delete(
"/:id",
requireAuth({ acceptedAuthModes: [AuthMode.JWT] }),
roleController.deleteRole
);
router.get("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.getRoles);
export default router;

View File

@@ -4,16 +4,10 @@ import {
requireAuth,
requireMembershipOrgAuth,
requireOrganizationAuth,
validateRequest,
validateRequest
} from "../../middleware";
import { body, param } from "express-validator";
import {
ACCEPTED,
ADMIN,
AuthMode,
MEMBER,
OWNER
} from "../../variables";
import { ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER } from "../../variables";
import { organizationsController } from "../../controllers/v2";
// TODO: /POST to create membership
@@ -23,11 +17,11 @@ router.get(
param("organizationId").exists().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY]
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.getOrganizationMemberships
);
@@ -36,18 +30,18 @@ router.patch(
"/:organizationId/memberships/:membershipId",
param("organizationId").exists().trim(),
param("membershipId").exists().trim(),
body("role").exists().isString().trim().isIn([OWNER, ADMIN, MEMBER]),
body("role").exists().isString().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY]
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
requireMembershipOrgAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.updateOrganizationMembership
);
@@ -58,15 +52,15 @@ router.delete(
param("membershipId").exists().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY]
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
requireMembershipOrgAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.deleteOrganizationMembership
);
@@ -76,11 +70,11 @@ router.get(
param("organizationId").exists().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY]
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.getOrganizationWorkspaces
);

View File

@@ -0,0 +1,74 @@
import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability";
import { MembershipOrg } from "../models";
import { IRole } from "../models/role";
import { BadRequestError, UnauthorizedRequestError } from "../utils/errors";
export enum GeneralPermissionActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete"
}
export enum OrgPermissionSubjects {
Workspace = "workspace",
Role = "role"
}
export type OrgPermissionSet =
| [GeneralPermissionActions, OrgPermissionSubjects.Workspace]
| [GeneralPermissionActions, OrgPermissionSubjects.Role];
const buildAdminPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(GeneralPermissionActions.Create, OrgPermissionSubjects.Workspace);
can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Workspace);
can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Workspace);
// role permission
can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role);
can(GeneralPermissionActions.Create, OrgPermissionSubjects.Role);
can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role);
can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role);
return build();
};
export const adminPermissions = buildAdminPermission();
const buildMemberPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role);
return build();
};
export const memberPermissions = buildMemberPermission();
export const getUserOrgPermissions = async (userId: string, orgId: string) => {
// TODO(akhilmhdh): speed this up by pulling from cache later
const orgMembership = await MembershipOrg.findOne({ user: userId, organization: orgId })
.populate<{ customRole: IRole & { permissions: RawRuleOf<MongoAbility<OrgPermissionSet>>[] } }>(
"customRole"
)
.exec();
if (!orgMembership || (orgMembership.role === "custom" && !orgMembership.customRole)) {
throw UnauthorizedRequestError({ message: "User doesn't belong to organization" });
}
if (orgMembership.role === "admin" || orgMembership.role === "owner") return adminPermissions;
if (orgMembership.role === "member") return memberPermissions;
if (orgMembership.role === "custom") {
const permission = createMongoAbility<OrgPermissionSet>(orgMembership.customRole.permissions);
return permission;
}
throw BadRequestError({ message: "User role not found" });
};

View File

@@ -1,4 +1,5 @@
export * from "./user";
export * from "./role";
export * from "./workspace";
export * from "./bot";
export * from "./integration";

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
export const CreateRoleSchema = z.object({
body: z.object({
slug: z.string(),
name: z.string(),
description: z.string().optional(),
workspaceId: z.string().optional(),
orgId: z.string(),
permissions: z
.object({
subject: z.string(),
action: z.string(),
condition: z.record(z.union([z.string(), z.number()]))
})
.array()
})
});
export const UpdateRoleSchema = z.object({
params: z.object({
id: z.string()
}),
body: z.object({
slug: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
workspaceId: z.string().optional(),
orgId: z.string(),
permissions: z
.object({
subject: z.string(),
action: z.string(),
condition: z.record(z.union([z.string(), z.number()]))
})
.array()
.optional()
})
});
export const DeleteRoleSchema = z.object({
params: z.object({
id: z.string()
})
});
export const GetRoleSchema = z.object({
query: z.object({
workspaceId: z.string().optional(),
orgId: z.string()
})
});

View File

@@ -2,6 +2,7 @@
export const OWNER = "owner";
export const ADMIN = "admin";
export const MEMBER = "member";
export const CUSTOM = "custom";
// membership statuses
export const INVITED = "invited";