Checkpoint service accounts

This commit is contained in:
Tuan Dang
2023-03-18 13:34:06 +07:00
parent 273f4228d7
commit ebdcccb6ca
18 changed files with 404 additions and 64 deletions

View File

@@ -7,6 +7,7 @@ import * as serviceTokenDataController from './serviceTokenDataController';
import * as apiKeyDataController from './apiKeyDataController'; import * as apiKeyDataController from './apiKeyDataController';
import * as secretController from './secretController'; import * as secretController from './secretController';
import * as secretsController from './secretsController'; import * as secretsController from './secretsController';
import * as serviceAccountsController from './serviceAccountsController';
import * as environmentController from './environmentController'; import * as environmentController from './environmentController';
import * as tagController from './tagController'; import * as tagController from './tagController';
@@ -20,6 +21,7 @@ export {
apiKeyDataController, apiKeyDataController,
secretController, secretController,
secretsController, secretsController,
serviceAccountsController,
environmentController, environmentController,
tagController tagController
} }

View File

@@ -1,9 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import { import {
MembershipOrg, MembershipOrg,
Membership, Membership,
Workspace Workspace,
ServiceAccount
} from '../../models'; } from '../../models';
import { deleteMembershipOrg } from '../../helpers/membershipOrg'; import { deleteMembershipOrg } from '../../helpers/membershipOrg';
import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; import { updateSubscriptionOrgQuantity } from '../../helpers/organization';
@@ -260,37 +262,44 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) =>
} }
} }
*/ */
let workspaces; const { organizationId } = req.params;
try {
const { organizationId } = req.params;
const workspacesSet = new Set( const workspacesSet = new Set(
( (
await Workspace.find( await Workspace.find(
{ {
organization: organizationId organization: organizationId
}, },
'_id' '_id'
) )
).map((w) => w._id.toString()) ).map((w) => w._id.toString())
); );
workspaces = ( const workspaces = (
await Membership.find({ await Membership.find({
user: req.user._id user: req.user._id
}).populate('workspace') }).populate('workspace')
) )
.filter((m) => workspacesSet.has(m.workspace._id.toString())) .filter((m) => workspacesSet.has(m.workspace._id.toString()))
.map((m) => m.workspace); .map((m) => m.workspace);
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get organization workspaces'
});
}
return res.status(200).send({ return res.status(200).send({
workspaces workspaces
}); });
} }
/**
* Return service accounts for organization with id [organizationId]
* @param req
* @param res
*/
export const getOrganizationServiceAccounts = async (req: Request, res: Response) => {
const { organizationId } = req.params;
const serviceAccounts = await ServiceAccount.find({
organization: new Types.ObjectId(organizationId)
});
return res.status(200).send({
serviceAccounts
});
}

View File

@@ -0,0 +1,178 @@
import { Request, Response } from 'express';
import { Types } from 'mongoose';
import {
ServiceAccount,
ServiceAccountKey,
ServiceAccountPermission
} from '../../models';
import {
CreateServiceAccountDto
} from '../../interfaces/serviceAccounts/dto';
/**
* Create a new service account under organization with id [organizationId]
* that has access to workspaces [workspaces]
* @param req
* @param res
* @returns
*/
export const createServiceAccount = async (req: Request, res: Response) => {
const {
organizationId,
name,
publicKey,
expiresIn,
}: CreateServiceAccountDto = req.body;
let expiresAt;
if (expiresIn) {
expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
}
const serviceAccount = await new ServiceAccount({
name,
organization: new Types.ObjectId(organizationId),
user: req.user,
publicKey,
expiresAt
}).save();
// await Promise.all(
// workspaces.map(async ({
// workspaceId,
// environments,
// permissions,
// encryptedKey,
// nonce
// }: {
// workspaceId: string;
// environments: string[];
// permissions: string[];
// encryptedKey: string;
// nonce: string;
// }) => {
// const serviceAccountKey = await new ServiceAccountKey({
// encryptedKey,
// nonce,
// sender: req.user._id,
// serviceAccount: serviceAccount._id,
// workspace: new Types.ObjectId(workspaceId)
// });
// console.log('serviceAccountKey: ', serviceAccountKey);
// await Promise.all(
// permissions.map(async (name: string) => {
// const permission = await new ServiceAccountPermission({
// serviceAccount: serviceAccount._id,
// name,
// workspace: new Types.ObjectId(workspaceId),
// environments
// }).save();
// console.log('permission: ', permission);
// })
// );
// })
// );
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;
// }
/**
* Delete service account with id [serviceAccountId]
* @param req
* @param res
* @returns
*/
export const deleteServiceAccount = async (req: Request, res: Response) => {
const { serviceAccountId } = req.params;
const serviceAccount = await ServiceAccount.findByIdAndDelete(serviceAccountId);
await ServiceAccountKey.deleteMany({
serviceAccount: new Types.ObjectId(serviceAccountId)
});
return res.status(200).send({
serviceAccount
});
}
export const addServiceAccountWorkspaceAccess = async (req: Request, res: Response) => {
const { serviceAccountId, workspaceId } = req.params;
const {
encryptedKey,
nonce,
permissions // should contain environments
} = req.body;
const serviceAccountKey = await new ServiceAccountKey({
encryptedKey,
nonce,
sender: req.user._id,
serviceAccount: req.serviceAccount._id,
workspace: new Types.ObjectId('workspaceId')
});
const serviceAccountPermissions = await Promise.all(
permissions.map
);
}
export const deleteServiceAccountWorkspaceAccess = async (req: Request, res: Response) => {
// TODO
}
// /**
// * 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

@@ -56,6 +56,7 @@ import {
secret as v2SecretRouter, // begin to phase out secret as v2SecretRouter, // begin to phase out
secrets as v2SecretsRouter, secrets as v2SecretsRouter,
serviceTokenData as v2ServiceTokenDataRouter, serviceTokenData as v2ServiceTokenDataRouter,
serviceAccounts as v2ServiceAccountsRouter,
apiKeyData as v2APIKeyDataRouter, apiKeyData as v2APIKeyDataRouter,
environment as v2EnvironmentRouter, environment as v2EnvironmentRouter,
tags as v2TagsRouter, tags as v2TagsRouter,
@@ -148,6 +149,7 @@ const main = async () => {
app.use('/api/v2/secret', v2SecretRouter); // deprecated app.use('/api/v2/secret', v2SecretRouter); // deprecated
app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/secrets', v2SecretsRouter);
app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route
app.use('/api/v2/service-accounts', v2ServiceAccountsRouter); // new
app.use('/api/v2/api-key', v2APIKeyDataRouter); app.use('/api/v2/api-key', v2APIKeyDataRouter);
// api docs // api docs

View File

@@ -0,0 +1,8 @@
interface CreateServiceAccountDto {
organizationId: string;
name: string;
publicKey: string;
expiresIn: number;
}
export default CreateServiceAccountDto;

View File

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

View File

@@ -10,6 +10,7 @@ import requireIntegrationAuth from './requireIntegrationAuth';
import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizationAuth'; import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizationAuth';
import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenAuth from './requireServiceTokenAuth';
import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth';
import requireServiceAccountAuth from './requireServiceAccountAuth';
import requireSecretAuth from './requireSecretAuth'; import requireSecretAuth from './requireSecretAuth';
import requireSecretsAuth from './requireSecretsAuth'; import requireSecretsAuth from './requireSecretsAuth';
import validateRequest from './validateRequest'; import validateRequest from './validateRequest';
@@ -27,6 +28,7 @@ export {
requireIntegrationAuthorizationAuth, requireIntegrationAuthorizationAuth,
requireServiceTokenAuth, requireServiceTokenAuth,
requireServiceTokenDataAuth, requireServiceTokenDataAuth,
requireServiceAccountAuth,
requireSecretAuth, requireSecretAuth,
requireSecretsAuth, requireSecretsAuth,
validateRequest validateRequest

View File

@@ -2,6 +2,8 @@ import { Request, Response, NextFunction } from 'express';
import { IOrganization, MembershipOrg } from '../models'; import { IOrganization, MembershipOrg } from '../models';
import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; import { UnauthorizedRequestError, ValidationError } from '../utils/errors';
type req = 'params' | 'body' | 'query';
/** /**
* Validate if user on request is a member with proper roles for organization * Validate if user on request is a member with proper roles for organization
* on request params. * on request params.
@@ -11,18 +13,22 @@ import { UnauthorizedRequestError, ValidationError } from '../utils/errors';
*/ */
const requireOrganizationAuth = ({ const requireOrganizationAuth = ({
acceptedRoles, acceptedRoles,
acceptedStatuses acceptedStatuses,
location = 'params'
}: { }: {
acceptedRoles: string[]; acceptedRoles: string[];
acceptedStatuses: string[]; acceptedStatuses: string[];
location?: req;
}) => { }) => {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
// organization authorization middleware // organization authorization middleware
const { organizationId } = req[location];
// validate organization membership // validate organization membership
const membershipOrg = await MembershipOrg.findOne({ const membershipOrg = await MembershipOrg.findOne({
user: req.user._id, user: req.user._id,
organization: req.params.organizationId organization: organizationId
}).populate<{ organization: IOrganization }>('organization'); }).populate<{ organization: IOrganization }>('organization');

View File

@@ -0,0 +1,39 @@
import { Request, Response, NextFunction } from 'express';
import { ServiceAccount } from '../models';
import {
AccountNotFoundError,
UnauthorizedRequestError
} from '../utils/errors';
type req = 'params' | 'body' | 'query';
const requireServiceAccountAuth = ({
acceptedRoles,
acceptedStatuses,
location = 'params'
}: {
acceptedRoles: string[];
acceptedStatuses: string[];
location?: req;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
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' }));
}
if (serviceAccount.user.toString() !== req.user.id.toString()) {
return next(UnauthorizedRequestError({ message: 'Failed to authenticate the Service Account' }));
}
req.serviceAccount = serviceAccount;
next();
}
}
export default requireServiceAccountAuth;

View File

@@ -10,6 +10,9 @@ import MembershipOrg, { IMembershipOrg } from './membershipOrg';
import Organization, { IOrganization } from './organization'; import Organization, { IOrganization } from './organization';
import Secret, { ISecret } from './secret'; import Secret, { ISecret } from './secret';
import ServiceToken, { IServiceToken } from './serviceToken'; import ServiceToken, { IServiceToken } from './serviceToken';
import ServiceAccount, { IServiceAccount } from './serviceAccount'; // new
import ServiceAccountKey, { IServiceAccountKey } from './serviceAccountKey'; // new
import ServiceAccountPermission, { IServiceAccountPermission } from './serviceAccountPermission';
import TokenData, { ITokenData } from './tokenData'; import TokenData, { ITokenData } from './tokenData';
import User, { IUser } from './user'; import User, { IUser } from './user';
import UserAction, { IUserAction } from './userAction'; import UserAction, { IUserAction } from './userAction';
@@ -43,6 +46,12 @@ export {
ISecret, ISecret,
ServiceToken, ServiceToken,
IServiceToken, IServiceToken,
ServiceAccount,
IServiceAccount,
ServiceAccountKey,
IServiceAccountKey,
ServiceAccountPermission,
IServiceAccountPermission,
TokenData, TokenData,
ITokenData, ITokenData,
User, User,

View File

@@ -1,22 +0,0 @@
import { Schema, model, Types, Document } from 'mongoose';
export interface IPermission extends Document {
_id: Types.ObjectId;
name: string;
}
const permissionSchema = new Schema<IPermission>(
{
name: {
type: String,
required: true
}
},
{
timestamps: true
}
);
const Permission = model<IPermission>('Permission', permissionSchema);
export default Permission;

View File

@@ -3,9 +3,8 @@ import { Schema, model, Types, Document } from 'mongoose';
export interface IServiceAccount extends Document { export interface IServiceAccount extends Document {
_id: Types.ObjectId; _id: Types.ObjectId;
name: string; name: string;
isActive: boolean;
organization: Types.ObjectId; organization: Types.ObjectId;
createdBy: Types.ObjectId; user: Types.ObjectId;
publicKey: string; publicKey: string;
expiresAt: Date; expiresAt: Date;
} }
@@ -16,16 +15,12 @@ const serviceAccountSchema = new Schema<IServiceAccount>(
type: String, type: String,
required: true required: true
}, },
isActive: {
type: Boolean,
required: true
},
organization: { organization: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Organization', ref: 'Organization',
required: true required: true
}, },
createdBy: { user: { // user who created the service account
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'User', ref: 'User',
required: true required: true

View File

@@ -9,7 +9,7 @@ export interface IServiceAccountKey {
workspace: Types.ObjectId; workspace: Types.ObjectId;
} }
const serviceAccountSchema = new Schema<IServiceAccountKey>( const serviceAccountKeySchema = new Schema<IServiceAccountKey>(
{ {
encryptedKey: { encryptedKey: {
type: String, type: String,
@@ -39,6 +39,6 @@ const serviceAccountSchema = new Schema<IServiceAccountKey>(
} }
); );
const ServiceAccountKey = model<IServiceAccountKey>('ServiceAccountKey', serviceAccountSchema); const ServiceAccountKey = model<IServiceAccountKey>('ServiceAccountKey', serviceAccountKeySchema);
export default ServiceAccountKey; export default ServiceAccountKey;

View File

@@ -0,0 +1,37 @@
import { Schema, model, Types, Document } from 'mongoose';
export interface IServiceAccountPermission extends Document {
_id: Types.ObjectId;
serviceAccount: Types.ObjectId;
name: string;
workspace?: Types.ObjectId;
environment?: string;
}
const serviceAccountPermissionSchema = new Schema<IServiceAccountPermission>(
{
serviceAccount: {
type: Schema.Types.ObjectId,
ref: 'ServiceAccount',
required: true
},
name: {
type: String,
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
},
environment: {
type: 'String'
}
},
{
timestamps: true
}
);
const ServiceAccountPermission = model<IServiceAccountPermission>('ServiceAccountPermission', serviceAccountPermissionSchema);
export default ServiceAccountPermission;

View File

@@ -6,6 +6,7 @@ import workspace from './workspace';
import secret from './secret'; // deprecated import secret from './secret'; // deprecated
import secrets from './secrets'; import secrets from './secrets';
import serviceTokenData from './serviceTokenData'; import serviceTokenData from './serviceTokenData';
import serviceAccounts from './serviceAccounts';
import apiKeyData from './apiKeyData'; import apiKeyData from './apiKeyData';
import environment from "./environment" import environment from "./environment"
import tags from "./tags" import tags from "./tags"
@@ -19,6 +20,7 @@ export {
secret, secret,
secrets, secrets,
serviceTokenData, serviceTokenData,
serviceAccounts,
apiKeyData, apiKeyData,
environment, environment,
tags tags

View File

@@ -6,7 +6,7 @@ import {
requireMembershipOrgAuth, requireMembershipOrgAuth,
validateRequest validateRequest
} from '../../middleware'; } from '../../middleware';
import { body, param, query } from 'express-validator'; import { body, param } from 'express-validator';
import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables';
import { organizationsController } from '../../controllers/v2'; import { organizationsController } from '../../controllers/v2';
@@ -77,4 +77,18 @@ router.get(
organizationsController.getOrganizationWorkspaces organizationsController.getOrganizationWorkspaces
); );
router.get(
'/:organizationId/service-accounts',
param('organizationId').exists().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN],
acceptedStatuses: [ACCEPTED]
}),
organizationsController.getOrganizationServiceAccounts
);
export default router; export default router;

View File

@@ -0,0 +1,53 @@
import express from 'express';
const router = express.Router();
import {
requireOrganizationAuth,
requireServiceAccountAuth
} from '../../middleware';
import { body } from 'express-validator';
import {
OWNER,
ADMIN,
MEMBER,
ACCEPTED
} from '../../variables';
import { serviceAccountsController } from '../../controllers/v2';
router.post(
'/',
body('organizationId').exists().isString().trim(),
body('name').exists().isString().trim(),
body('publicKey').exists().isString().trim(),
body('expiresIn'), // measured in ms
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
location: 'body'
}),
serviceAccountsController.createServiceAccount
);
// router.post(
// '/:serviceAccountId/key',
// body('workspaceId').exists().isString().trim(),
// body('encryptedKey').exists().isString().trim(),
// body('nonce').exists().isString().trim(),
// requireServiceAccountAuth({
// acceptedRoles: [OWNER, ADMIN, MEMBER],
// acceptedStatuses: [ACCEPTED]
// }),
// serviceAccountsController.addServiceAccountKey
// );
router.delete(
'/:serviceAccountId/key/:serviceAccountKeyId',
requireServiceAccountAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED]
}),
async (req, res) => {
// TODO: delete service account key id
}
);
export default router;

View File

@@ -19,6 +19,7 @@ declare global {
secrets: any; secrets: any;
secretSnapshot: any; secretSnapshot: any;
serviceToken: any; serviceToken: any;
serviceAccount: any;
accessToken: any; accessToken: any;
serviceTokenData: any; serviceTokenData: any;
apiKeyData: any; apiKeyData: any;