Skip to content
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

Add policies layer in between roles and permissions #2495

Merged
merged 25 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fac854a
Add policies layer in between roles and permissions
jdorn May 8, 2024
5c4e782
Define policies, role->policies map, and policy->permissions map
jdorn May 8, 2024
b57773b
Hook up new policy-based roles to existing permissions flow
jdorn May 9, 2024
b2e6d92
Add new fields to OrganizationModel
jdorn May 9, 2024
818657e
Remove test suite (part of different PR)
jdorn May 9, 2024
1788cb1
Add isRoleValid checks throughout back-end
jdorn May 9, 2024
7450d68
Merges in main and resolves conflict.
mknowlton89 May 10, 2024
2d4950f
Fixes broken import paths
mknowlton89 May 10, 2024
e430fd9
Merge remote-tracking branch 'origin/main' into permission-policies
jdorn May 13, 2024
7895224
Fix tests, add metadata about policies
jdorn May 13, 2024
4b40c2f
Move isRoleValid and areProjectRolesValid to shared
jdorn May 13, 2024
5f133b4
Helper function to get default role
jdorn May 13, 2024
9f950ac
Move permission constants to new file, add displayName to policies
jdorn May 13, 2024
4a23b7f
Remove old permission-constants file that's not being used
jdorn May 13, 2024
4e29865
Change getRoles logic to match design mocks
jdorn May 13, 2024
db8b73a
Use getDefaultRole helper throughout codebase
jdorn May 13, 2024
f9e31a7
Fix type export
jdorn May 13, 2024
974af56
Add new permission, commercial feature, and back-end routes for manag…
jdorn May 13, 2024
160fcca
Merges in main and resolves conflicts.
mknowlton89 May 13, 2024
9bebefb
Fixes lint issues and updates policies.
mknowlton89 May 13, 2024
b8d0fed
Adds missing 'manageSDKWebhooks' permission to SDKConnectionsFullAcce…
mknowlton89 May 13, 2024
be07356
Merges in main and resolves import conflict.
mknowlton89 May 14, 2024
8a0d605
Removes FactFiltersFullAccess policy
mknowlton89 May 14, 2024
d58484e
Merge branch 'main' into permission-policies
mknowlton89 May 15, 2024
dfc398f
Merge branch 'main' into permission-policies
mknowlton89 May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import { getOrganizationById } from "../services/organizations";
import { getCustomLogProps } from "../util/logger";
import { EventAuditUserApiKey } from "../events/event-types";
import { isApiKeyForUserInOrganization } from "../util/api-key.util";
import {
MemberRole,
OrganizationInterface,
Permission,
} from "../../types/organization";
import { OrganizationInterface, Permission } from "../../types/organization";
import {
getUserPermissions,
roleToPermissionMap,
Expand Down Expand Up @@ -126,7 +122,7 @@ export default function authenticateApiRequestMiddleware(
auditUser: eventAudit,
teams,
user: req.user,
role: role as MemberRole | undefined,
role: role,
apiKey: id,
req,
});
Expand Down Expand Up @@ -252,7 +248,7 @@ export function verifyApiKeyPermission({
// Because of the JIT migration, `role` will always be set here, even for old secret keys
// This will check a valid role is provided.
const rolePermissions = roleToPermissionMap(
apiKey.role as MemberRole,
apiKey.role as string,
organization
);

Expand Down
109 changes: 109 additions & 0 deletions packages/back-end/src/models/OrganizationModel.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import mongoose from "mongoose";
import uniqid from "uniqid";
import { cloneDeep } from "lodash";
import { POLICIES, RESERVED_ROLE_IDS } from "shared/permissions";
import { z } from "zod";
import { TeamInterface } from "@back-end/types/team";
import {
Invite,
Member,
MemberRoleWithProjects,
OrganizationInterface,
OrganizationMessage,
Role,
} from "../../types/organization";
import { upgradeOrganizationDoc } from "../util/migrations";
import { ApiOrganization } from "../../types/openapi";
Expand Down Expand Up @@ -117,6 +122,7 @@ const organizationSchema = new mongoose.Schema({
},
},
settings: {},
customRoles: {},
});

organizationSchema.index({ "members.id": 1 });
Expand Down Expand Up @@ -434,3 +440,106 @@ export async function updateMember(
}),
});
}

export const customRoleValidator = z
.object({
id: z.string().min(2).max(64),
description: z.string().max(100),
policies: z.array(z.enum(POLICIES)),
})
.strict();

export async function addCustomRole(org: OrganizationInterface, role: Role) {
// Basic Validation
role = customRoleValidator.parse(role);

// Make sure role id is not reserved
if (RESERVED_ROLE_IDS.includes(role.id)) {
throw new Error("That role id is reserved and cannot be used");
}

// Make sure role id is not already in use
if (org.customRoles?.find((r) => r.id === role.id)) {
throw new Error("That role id already exists");
}

// Validate custom role id format
if (!/^[a-zA-Z0-9_]+$/.test(role.id)) {
throw new Error(
"Role id must only include letters, numbers, and underscores."
);
}

const customRoles = [...(org.customRoles || [])];
customRoles.push(role);

await updateOrganization(org.id, { customRoles });
}

export async function editCustomRole(
org: OrganizationInterface,
id: string,
updates: Omit<Role, "id">
) {
// Validation
updates = customRoleValidator.omit({ id: true }).parse(updates);

let found = false;
const newCustomRoles = (org.customRoles || []).map((role) => {
if (role.id === id) {
found = true;
return {
...role,
...updates,
};
}
return role;
});

if (!found) {
throw new Error("Role not found");
}

await updateOrganization(org.id, { customRoles: newCustomRoles });
}

function usingRole(member: MemberRoleWithProjects, role: string): boolean {
return (
member.role === role ||
(member.projectRoles || []).some((pr) => pr.role === role)
);
}

export async function removeCustomRole(
org: OrganizationInterface,
teams: TeamInterface[],
id: string
) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we'll want to add one more check - to prevent deleting a role that is the org's default.

  // Make sure the id isn't the org's default
  if (org.settings?.defaultRole?.role === id) {
    throw new Error(
      "Cannot delete role. This role is set as the organization's default role."
    );
  }

Copy link
Collaborator

Choose a reason for hiding this comment

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

// Make sure no members, invites, pending members, or teams are using the role
if (org.members.some((m) => usingRole(m, id))) {
throw new Error("Role is currently being used by at least one member");
}
if (org.pendingMembers?.some((m) => usingRole(m, id))) {
throw new Error(
"Role is currently being used by at least one pending member"
);
}
if (org.invites?.some((m) => usingRole(m, id))) {
throw new Error(
"Role is currently being used by at least one invited member"
);
}
if (teams.some((team) => usingRole(team, id))) {
throw new Error("Role is currently being used by at least one team");
}

const newCustomRoles = (org.customRoles || []).filter(
(role) => role.id !== id
);

if (newCustomRoles.length === (org.customRoles || []).length) {
throw new Error("Role not found");
}

await updateOrganization(org.id, { customRoles: newCustomRoles });
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
getLicenseError,
} from "enterprise";
import { experimentHasLinkedChanges } from "shared/util";
import {
getRoles,
areProjectRolesValid,
isRoleValid,
getDefaultRole,
} from "shared/permissions";
import {
UpdateSdkWebhookProps,
deleteLegacySdkWebhookById,
Expand Down Expand Up @@ -46,11 +52,11 @@ import { getAllTags } from "../../models/TagModel";
import {
Environment,
Invite,
MemberRole,
MemberRoleWithProjects,
NamespaceUsage,
OrganizationInterface,
OrganizationSettings,
Role,
SDKAttribute,
} from "../../../types/organization";
import {
Expand Down Expand Up @@ -84,6 +90,9 @@ import {
findOrganizationsByMemberId,
hasOrganization,
updateOrganization,
addCustomRole,
editCustomRole,
removeCustomRole,
} from "../../models/OrganizationModel";
import { findAllProjectsByOrganization } from "../../models/ProjectModel";
import { ConfigFile } from "../../init/config";
Expand All @@ -99,11 +108,7 @@ import {
getApiKeyByIdOrKey,
getUnredactedSecretKey,
} from "../../models/ApiKeyModel";
import {
getDefaultRole,
getRoles,
getUserPermissions,
} from "../../util/organization.util";
import { getUserPermissions } from "../../util/organization.util";
import { deleteUser, findUserById, getAllUsers } from "../../models/UserModel";
import {
getAllExperiments,
Expand Down Expand Up @@ -326,6 +331,13 @@ export async function putMemberRole(
});
}

if (!isRoleValid(role, org) || !areProjectRolesValid(projectRoles, org)) {
return res.status(400).json({
status: 400,
message: "Invalid role",
});
}

let found = false;
org.members.forEach((m) => {
if (m.id === id) {
Expand Down Expand Up @@ -571,6 +583,13 @@ export async function putInviteRole(
const { key } = req.params;
const originalInvites: Invite[] = cloneDeep(org.invites);

if (!isRoleValid(role, org) || !areProjectRolesValid(projectRoles, org)) {
return res.status(400).json({
status: 400,
message: "Invalid role",
});
}

let found = false;

org.invites.forEach((m) => {
Expand Down Expand Up @@ -970,7 +989,7 @@ export async function deleteNamespace(

export async function getInviteInfo(
req: AuthRequest<unknown, { key: string }>,
res: ResponseWithStatusAndError<{ organization: string; role: MemberRole }>
res: ResponseWithStatusAndError<{ organization: string; role: string }>
) {
const { key } = req.params;

Expand Down Expand Up @@ -1049,6 +1068,14 @@ export async function postInvite(
projectRoles,
} = req.body;

// Make sure role is valid
if (!isRoleValid(role, org) || !areProjectRolesValid(projectRoles, org)) {
return res.status(400).json({
status: 400,
message: "Invalid role",
});
}

const license = getLicense();
if (
license &&
Expand Down Expand Up @@ -1848,6 +1875,14 @@ export async function addOrphanedUser(
});
}

// Make sure role is valid
if (!isRoleValid(role, org) || !areProjectRolesValid(projectRoles, org)) {
return res.status(400).json({
status: 400,
message: "Invalid role",
});
}

const license = getLicense();
if (
license &&
Expand Down Expand Up @@ -2019,7 +2054,7 @@ export async function putLicenseKey(
}

export async function putDefaultRole(
req: AuthRequest<{ defaultRole: MemberRole }>,
req: AuthRequest<{ defaultRole: string }>,
res: Response
) {
const context = getContextFromReq(req);
Expand All @@ -2034,6 +2069,10 @@ export async function putDefaultRole(
);
}

if (!isRoleValid(defaultRole, org)) {
throw new Error("Invalid role");
}

if (!context.permissions.canManageTeam()) {
context.permissions.throwPermissionError();
}
Expand All @@ -2053,3 +2092,67 @@ export async function putDefaultRole(
status: 200,
});
}

export async function postCustomRole(req: AuthRequest<Role>, res: Response) {
const context = getContextFromReq(req);
const roleToAdd = req.body;

if (!context.hasPremiumFeature("custom-roles")) {
throw new Error("Must have an Enterprise License Key to use custom roles.");
}

if (!context.permissions.canManageCustomRoles()) {
context.permissions.throwPermissionError();
}

await addCustomRole(context.org, roleToAdd);

res.status(200).json({
status: 200,
});
}

export async function putCustomRole(
req: AuthRequest<Omit<Role, "id">, { id: string }>,
res: Response
) {
const context = getContextFromReq(req);
const roleToUpdate = req.body;
const { id } = req.params;

if (!context.hasPremiumFeature("custom-roles")) {
throw new Error("Must have an Enterprise License Key to use custom roles.");
}

if (!context.permissions.canManageCustomRoles()) {
context.permissions.throwPermissionError();
}

await editCustomRole(context.org, id, roleToUpdate);

res.status(200).json({
status: 200,
});
}

export async function deleteCustomRole(
req: AuthRequest<null, { id: string }>,
res: Response
) {
const context = getContextFromReq(req);
const { id } = req.params;

if (!context.hasPremiumFeature("custom-roles")) {
throw new Error("Must have an Enterprise License Key to use custom roles.");
}

if (!context.permissions.canManageCustomRoles()) {
context.permissions.throwPermissionError();
}

await removeCustomRole(context.org, context.teams, id);

res.status(200).json({
status: 200,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,9 @@ if (!IS_CLOUD) {
);
}

// Custom Roles
router.post("/custom-roles", organizationsController.postCustomRole);
router.put("/custom-roles/:id", organizationsController.putCustomRole);
router.delete("/custom-roles/:id", organizationsController.deleteCustomRole);

export { router as organizationsRouter };