Service account checkpoint

This commit is contained in:
Tuan Dang
2023-03-19 17:01:36 +07:00
parent 8fb473c57c
commit 3a0ce7c084
15 changed files with 356 additions and 136 deletions

View File

@@ -6,8 +6,17 @@ import {
ServiceAccountPermission
} from '../../models';
import {
CreateServiceAccountDto
validateCreateServiceAccountPermission
} from '../../helpers/serviceAccount';
import {
CreateServiceAccountDto,
AddServiceAccountPermissionDto
} from '../../interfaces/serviceAccounts/dto';
import {
PERMISSION_SA_WORKSPACE_SET,
PERMISSION_SA_SET
} from '../../variables';
import { ServiceAccountKeyNotFoundError, ValidationError } from '../../utils/errors';
/**
* Create a new service account under organization with id [organizationId]
@@ -70,22 +79,39 @@ export const addServiceAccountKey = async (req: Request, res: Response) => {
/**
* Add a permission to service account with id [serviceAccountId]
* @param req
* @param res
* @param req
* @param res
*/
export const addServiceAccountPermission = async (req: Request, res: Response) => {
const {
name,
workspaceId,
environment
} = req.body; // TODO: add DTO
}: AddServiceAccountPermissionDto = req.body;
// TODO: validation?
if (PERMISSION_SA_WORKSPACE_SET.has(name)) {
// case: permission named [name] is workspace-related
// some such permissions require workspaceId and environment to be present.
if (!workspaceId || !environment) {
throw ValidationError({
message: 'Failed validation that is workspace-related permission must specify a workspace and environment'
});
} else {
const serviceAccountKey = await ServiceAccountKey.findOne({
serviceAccount: req.serviceAccount._id,
workspace: new Types.ObjectId(workspaceId)
});
if (!serviceAccountKey) throw ServiceAccountKeyNotFoundError({ message: 'Failed to find service account key' });
}
}
const serviceAccountPermission = await new ServiceAccountPermission({
serviceAccount: req.serviceAccount._id,
name,
workspace: new Types.ObjectId(workspaceId),
workspace: workspaceId ? new Types.ObjectId(workspaceId) : undefined,
environment
});
@@ -100,19 +126,15 @@ export const addServiceAccountPermission = async (req: Request, res: Response) =
* @param res
*/
export const deleteServiceAccountPermission = async (req: Request, res: Response) => {
const {
name,
workspaceId,
environment
} = req.body; // TODO: DTO
const { serviceAccountPermissionId } = req.params;
// user must either be an admin/owner of the organization or they must
// have created the service account in the first place to be able to delete it
// TODO: how to delete just 1 permission?
const serviceAccountPermission = await ServiceAccountPermission.findOneAndDelete({
serviceAccount: req.serviceAccount._id,
name,
workspace: new Types.ObjectId(workspaceId),
environment
});
const serviceAccountPermission = await ServiceAccountPermission.findByIdAndDelete(serviceAccountPermissionId);
return res.status(200).send({
serviceAccountPermission
@@ -130,40 +152,19 @@ export const deleteServiceAccount = async (req: Request, res: Response) => {
const serviceAccount = await ServiceAccount.findByIdAndDelete(serviceAccountId);
await ServiceAccountKey.deleteMany({
serviceAccount: new Types.ObjectId(serviceAccountId)
});
if (serviceAccount) {
// case: service account with id [serviceAccountId] was deleted
await ServiceAccountKey.deleteMany({
serviceAccount: serviceAccount?._id
});
await ServiceAccountPermission.deleteMany({
serviceAccount: new Types.ObjectId(serviceAccountId)
});
}
await ServiceAccountPermission.deleteMany({
serviceAccount: new Types.ObjectId(serviceAccountId)
});
return res.status(200).send({
serviceAccount
});
}
// /**
// * Add a service account key to service account with id [serviceAccountId]
// * for workspace with id [workspaceId]
// * @param req
// * @param res
// * @returns
// */
// export const addServiceAccountKey = async (req: Request, res: Response) => {
// const {
// workspaceId,
// encryptedKey,
// nonce
// } = req.body;
// const serviceAccountKey = await new ServiceAccountKey({
// encryptedKey,
// nonce,
// sender: req.user._id,
// serviceAccount: req.serviceAccount._d,
// workspace: new Types.ObjectId(workspaceId)
// }).save();
// return serviceAccountKey;
// }
}

View File

@@ -1,5 +1,9 @@
import * as Sentry from '@sentry/node';
import { Membership, Key } from '../models';
import {
MembershipNotFoundError,
BadRequestError
} from '../utils/errors';
/**
* Validate that user with id [userId] is a member of workspace with id [workspaceId]
@@ -19,23 +23,17 @@ const validateMembership = async ({
acceptedRoles: string[];
}) => {
let membership;
//TODO: Refactor code to take advantage of using RequestError. It's possible to create new types of errors for more detailed errors
try {
membership = await Membership.findOne({
user: userId,
workspace: workspaceId
}).populate("workspace");
if (!membership) throw new Error('Failed to find membership');
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate membership role');
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to validate membership');
const membership = await Membership.findOne({
user: userId,
workspace: workspaceId
}).populate("workspace");
if (!membership) {
throw MembershipNotFoundError({ message: 'Failed to find workspace membership' });
}
if (!acceptedRoles.includes(membership.role)) {
throw BadRequestError({ message: 'Failed to validate workspace membership role' });
}
return membership;

View File

@@ -1,40 +1,48 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import { MembershipOrg, Workspace, Membership, Key } from '../models';
import {
MembershipOrgNotFoundError,
BadRequestError
} from '../utils/errors';
/**
* Validate that user with id [userId] is a member of organization with id [organizationId]
* and has at least one of the roles in [acceptedRoles]
*
* @param {Object} obj
* @param {Types.ObjectId} obj.userId
* @param {Types.ObjectId} obj.organizationId
* @param {String[]} obj.acceptedRoles
*/
const validateMembership = async ({
const validateMembershipOrg = async ({
userId,
organizationId,
acceptedRoles
acceptedRoles,
acceptedStatuses
}: {
userId: string;
organizationId: string;
userId: Types.ObjectId;
organizationId: Types.ObjectId;
acceptedRoles: string[];
acceptedStatuses: string[];
}) => {
let membership;
try {
membership = await MembershipOrg.findOne({
user: new Types.ObjectId(userId),
organization: new Types.ObjectId(organizationId)
});
if (!membership) throw new Error('Failed to find organization membership');
if (!acceptedRoles.includes(membership.role)) {
throw new Error('Failed to validate organization membership role');
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to validate organization membership');
const membershipOrg = await MembershipOrg.findOne({
user: userId,
organization: organizationId
});
if (!membershipOrg) {
throw MembershipOrgNotFoundError({ message: 'Failed to find organization membership' });
}
return membership;
if (!acceptedRoles.includes(membershipOrg.role)) {
throw BadRequestError({ message: 'Failed to validate organization membership role' });
}
if (!acceptedStatuses.includes(membershipOrg.status)) {
throw BadRequestError({ message: 'Failed to validate organization membership status' });
}
return membershipOrg;
}
/**
@@ -156,7 +164,7 @@ const deleteMembershipOrg = async ({
};
export {
validateMembership,
validateMembershipOrg,
findMembershipOrg,
addMembershipsOrg,
deleteMembershipOrg

View File

@@ -0,0 +1,110 @@
import { Types } from 'mongoose';
import {
Workspace,
ServiceAccount,
ServiceAccountKey
} from '../models';
import {
WorkspaceNotFoundError,
ServiceAccountNotFoundError,
ServiceAccountKeyNotFoundError
} from '../utils/errors';
import {
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE,
PERMISSION_SA_SET
} from '../variables';
/**
* Validate that user with id [userId] can provision the permission
* named [name] for a service account with id [serviceAccountId] and
* optionally workspace with id [workspaceId] and environment [environment]
* @param {Object} obj
* @param {String} obj.name - name of permission to create
* @param {Types.ObjectId} userId - id of user creating the permission
* @param {Types.ObjectId} serviceAccountId - id of service account that permission will be bound to
* @param {Types.ObjectId} workspaceId - id of workspace that permission concerns
* @param {Types.ObjectId} workspaceId - id of service account that permission will be bound to
*/
const validateCreateServiceAccountPermission = async ({
name,
userId,
serviceAccountId,
workspaceId,
environment
}: {
name: string;
userId: Types.ObjectId;
serviceAccountId: Types.ObjectId,
workspaceId?: Types.ObjectId;
environment?: string;
}) => {
// TODO: as we upgrade user permissions to be more global, then we should take into account
// the user's permissions as it concerns to being able to interact with service accounts
if (!PERMISSION_SA_SET.has(name)) throw new Error(`${name} is not a valid permission name`);
if ([
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE
].includes(name)) {
if (workspaceId && environment) {
// case: either workspace id [workspaceId] or environment name [environment] is being passed in
// (i.e. validating a service account permission concerning a workspace and/or environment)
const workspace = await Workspace.findById(workspaceId);
if (!workspace) {
// case: workspace does not exist
throw WorkspaceNotFoundError({ message: 'Failed to locate workspace' });
}
if (!workspace.environments.some((env) => env.slug === environment)) {
// case: environment name [environment] is not a valid environment slug in workspace
throw Error('Failed to locate environment in workspace');
}
const serviceAccount = await ServiceAccount.findById(serviceAccountId);
if (!serviceAccount) {
// case: service account does not exist
throw ServiceAccountNotFoundError({ message: 'Failed to locate service account' });
}
const serviceAccountKey = await ServiceAccountKey.findOne({
serviceAccount: serviceAccount._id,
workspace: workspaceId
});
if (!serviceAccountKey) {
// case: service account key does not exist
throw ServiceAccountKeyNotFoundError({ message: 'Failed to locate service account key' });
}
} else {
throw new Error('Failed to validate workspace and environment for workspace-related permission');
}
}
}
const validateDeleteServiceAccountPermission = async ({
userId,
serviceAccountId,
name,
workspaceId,
environment
}: {
userId: Types.ObjectId;
serviceAccountId: Types.ObjectId;
name: string;
workspaceId: Types.ObjectId;
environment: string;
}) => {
// does the user have the authority to delete the permission?
// does the service account permission exist?
}
export {
validateCreateServiceAccountPermission
}

View File

@@ -0,0 +1,7 @@
interface AddServiceAccountPermissionDto {
name: string;
workspaceId?: string;
environment?: string;
}
export default AddServiceAccountPermissionDto;

View File

@@ -1,5 +1,7 @@
import CreateServiceAccountDto from './CreateServiceAccountDto';
import AddServiceAccountPermissionDto from './AddServiceAccountPermissionDto';
export {
CreateServiceAccountDto
CreateServiceAccountDto,
AddServiceAccountPermissionDto
}

View File

@@ -3,7 +3,7 @@ import { UnauthorizedRequestError } from '../utils/errors';
import {
MembershipOrg
} from '../models';
import { validateMembership } from '../helpers/membershipOrg';
import { validateMembershipOrg } from '../helpers/membershipOrg';
type req = 'params' | 'body' | 'query';
@@ -17,9 +17,11 @@ type req = 'params' | 'body' | 'query';
*/
const requireMembershipOrgAuth = ({
acceptedRoles,
acceptedStatuses,
location = 'params'
}: {
acceptedRoles: string[];
acceptedStatuses: string[];
location?: req;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
@@ -29,14 +31,13 @@ const requireMembershipOrgAuth = ({
if (!membershipOrg) throw new Error('Failed to find target organization membership');
const targetMembership = await validateMembership({
userId: req.user._id.toString(),
organizationId: membershipOrg.organization.toString(),
acceptedRoles
req.targetMembership = await validateMembershipOrg({
userId: req.user._id,
organizationId: membershipOrg.organization,
acceptedRoles,
acceptedStatuses
});
req.targetMembership = targetMembership;
return next();
} catch (err) {
return next(UnauthorizedRequestError({

View File

@@ -1,6 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { Types } from 'mongoose';
import { IOrganization, MembershipOrg } from '../models';
import { UnauthorizedRequestError, ValidationError } from '../utils/errors';
import { validateMembershipOrg } from '../helpers/membershipOrg';
type req = 'params' | 'body' | 'query';
@@ -9,7 +11,7 @@ type req = 'params' | 'body' | 'query';
* on request params.
* @param {Object} obj
* @param {String[]} obj.acceptedRoles - accepted organization roles
* @param {String[]} obj.acceptedStatuses - accepted organization statuses
* @param {String[]} obj.accepteStatuses - accepted organization statuses
*/
const requireOrganizationAuth = ({
acceptedRoles,
@@ -21,30 +23,13 @@ const requireOrganizationAuth = ({
location?: req;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
// organization authorization middleware
const { organizationId } = req[location];
// validate organization membership
const membershipOrg = await MembershipOrg.findOne({
user: req.user._id,
organization: organizationId
}).populate<{ organization: IOrganization }>('organization');
if (!membershipOrg) {
return next(UnauthorizedRequestError({message: "You're not a member of this Organization."}))
}
//TODO is this important to validate? I mean is it possible to save wrong role to database or get wrong role from databse? - Zamion101
if (!acceptedRoles.includes(membershipOrg.role)) {
return next(ValidationError({message: 'Failed to validate Organization Membership Role'}))
}
if (!acceptedStatuses.includes(membershipOrg.status)) {
return next(ValidationError({message: 'Failed to validate Organization Membership Status'}))
}
req.membershipOrg = membershipOrg;
req.membershipOrg = await validateMembershipOrg({
userId: req.user._id,
organizationId: new Types.ObjectId(organizationId),
acceptedRoles,
acceptedStatuses
});
return next();
};

View File

@@ -1,9 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import { ServiceAccount } from '../models';
import {
AccountNotFoundError,
UnauthorizedRequestError
ServiceAccountNotFoundError
} from '../utils/errors';
import {
validateMembershipOrg
} from '../helpers/membershipOrg';
type req = 'params' | 'body' | 'query';
@@ -20,14 +22,19 @@ const requireServiceAccountAuth = ({
const serviceAccountId = req[location].serviceAccountId;
const serviceAccount = await ServiceAccount.findById(serviceAccountId);
// TODO: acceptedRoles and acceptedStatuses
if (!serviceAccount) {
return next(AccountNotFoundError({ message: 'Failed to locate Service Account' }));
return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account' }));
}
if (serviceAccount.user.toString() !== req.user.id.toString()) {
return next(UnauthorizedRequestError({ message: 'Failed to authenticate the Service Account' }));
// case: creator of the service account is different from
// the user on the request -> apply middleware role/status validation
await validateMembershipOrg({
userId: req.user._id,
organizationId: serviceAccount.organization,
acceptedRoles,
acceptedStatuses
});
}
req.serviceAccount = serviceAccount;

View File

@@ -22,9 +22,11 @@ const serviceAccountPermissionSchema = new Schema<IServiceAccountPermission>(
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
default: null
},
environment: {
type: 'String'
type: 'String',
default: null
}
},
{

View File

@@ -40,7 +40,8 @@ router.patch(
acceptedStatuses: [ACCEPTED]
}),
requireMembershipOrgAuth({
acceptedRoles: [OWNER, ADMIN]
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.updateOrganizationMembership
);
@@ -58,7 +59,8 @@ router.delete(
acceptedStatuses: [ACCEPTED]
}),
requireMembershipOrgAuth({
acceptedRoles: [OWNER, ADMIN]
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.deleteOrganizationMembership
);

View File

@@ -2,14 +2,17 @@ import express from 'express';
const router = express.Router();
import {
requireOrganizationAuth,
requireServiceAccountAuth
requireWorkspaceAuth,
requireServiceAccountAuth,
validateRequest
} from '../../middleware';
import { body } from 'express-validator';
import {
OWNER,
ADMIN,
MEMBER,
ACCEPTED
ACCEPTED,
PERMISSION_SA_SET
} from '../../variables';
import { serviceAccountsController } from '../../controllers/v2';
@@ -27,6 +30,19 @@ router.post(
serviceAccountsController.createServiceAccount
);
router.post(
'/serviceAccountId/:serviceAccountId/permissions',
body('name').exists().isString().trim().custom((value) => PERMISSION_SA_SET.has(value)),
body('workspaceId').optional().isMongoId(),
body('environment').optional(),
validateRequest,
requireServiceAccountAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED]
}),
serviceAccountsController.addServiceAccountPermission
);
// router.post(
// '/:serviceAccountId/key',
// body('workspaceId').exists().isString().trim(),
@@ -42,7 +58,7 @@ router.post(
router.delete(
'/:serviceAccountId/key/:serviceAccountKeyId',
requireServiceAccountAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED]
}),
async (req, res) => {
@@ -50,4 +66,17 @@ router.delete(
}
);
// TODO: create service account permission
// router.post(
// );
// TODO: delete service account permission
router.delete(
'/:serviceAccountId/service-account-permission/:serviceAccountPermissionId',
)
export default router;

View File

@@ -93,6 +93,16 @@ export const WorkspaceNotFoundError = (error?: Partial<RequestErrorContext>) =>
stack: error?.stack
});
//* ----->[WORKSPACE MEMBERSHIP ERRORS]<-----
export const MembershipNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'workspace_membership_not_found_error',
message: error?.message ?? 'The requested membership was not found',
context: error?.context,
stack: error?.stack
});
//* ----->[ORGANIZATION ERRORS]<-----
export const OrganizationNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
@@ -103,6 +113,16 @@ export const OrganizationNotFoundError = (error?: Partial<RequestErrorContext>)
stack: error?.stack
});
//* ----->[MEMBERSHIP ORGANIZATION ERRORS]<-----
export const MembershipOrgNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'organization_membership_not_found_error',
message: error?.message ?? 'The requested organization membership was not found',
context: error?.context,
stack: error?.stack
});
//* ----->[ACCOUNT ERRORS]<-----
export const AccountNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
@@ -157,10 +177,29 @@ export const ServiceTokenDataNotFoundError = (error?: Partial<RequestErrorContex
export const APIKeyDataNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'service_token_data_not_found_error',
type: error?.type ?? 'api_key_data_not_found_error',
message: error?.message ?? 'The requested service token data was not found',
context: error?.context,
stack: error?.stack
});
//* ----->[SERVICE_ACCOUNT ERRORS]<-----
export const ServiceAccountNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'service_account_not_found_error',
message: error?.message ?? 'The requested service account was not found',
context: error?.context,
stack: error?.stack
});
export const ServiceAccountKeyNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'service_account_key_not_found_error',
message: error?.message ?? 'The requested service account key was not found',
context: error?.context,
stack: error?.stack
})
//* ----->[MISC ERRORS]<-----

View File

@@ -63,6 +63,12 @@ import {
TOKEN_EMAIL_ORG_INVITATION,
TOKEN_EMAIL_PASSWORD_RESET
} from './token';
import {
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE,
PERMISSION_SA_WORKSPACE_SET,
PERMISSION_SA_SET
} from './permissions';
export {
OWNER,
@@ -124,5 +130,9 @@ export {
TOKEN_EMAIL_CONFIRMATION,
TOKEN_EMAIL_MFA,
TOKEN_EMAIL_ORG_INVITATION,
TOKEN_EMAIL_PASSWORD_RESET
TOKEN_EMAIL_PASSWORD_RESET,
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE,
PERMISSION_SA_WORKSPACE_SET,
PERMISSION_SA_SET
};

View File

@@ -0,0 +1,19 @@
const PERMISSION_SA_WORKSPACE_READ = 'read';
const PERMISSION_SA_WORKSPACE_WRITE = 'write';
const PERMISSION_SA_WORKSPACE_SET = new Set([
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE
]);
const PERMISSION_SA_SET = new Set([
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE
]);
export {
PERMISSION_SA_WORKSPACE_READ,
PERMISSION_SA_WORKSPACE_WRITE,
PERMISSION_SA_WORKSPACE_SET,
PERMISSION_SA_SET
}