From 992357cbc48bb6dfc7747c9cd65e2750c1a1872c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 16 Jan 2023 00:00:04 +0700 Subject: [PATCH] Add new organization endpoints to API reference --- backend/spec.json | 564 ++++++++++++++++-- backend/src/app.ts | 11 +- .../controllers/v1/organizationController.ts | 6 - backend/src/controllers/v2/index.ts | 2 + .../controllers/v2/organizationsController.ts | 296 +++++++++ backend/src/controllers/v2/usersController.ts | 64 +- .../src/controllers/v2/workspaceController.ts | 142 ++--- backend/src/helpers/membership.ts | 1 + backend/src/helpers/membershipOrg.ts | 45 +- backend/src/middleware/index.ts | 2 + .../src/middleware/requireMembershipAuth.ts | 3 +- .../middleware/requireMembershipOrgAuth.ts | 49 ++ backend/src/routes/v1/membership.ts | 2 + backend/src/routes/v1/organization.ts | 6 +- backend/src/routes/v2/index.ts | 8 +- backend/src/routes/v2/organizations.ts | 80 +++ backend/src/routes/v2/secret.ts | 2 +- backend/src/routes/v2/users.ts | 8 + backend/src/routes/v2/workspace.ts | 34 +- backend/swagger/index.ts | 27 + .../organizations/delete-membership.mdx | 4 + .../endpoints/organizations/memberships.mdx | 4 + .../organizations/update-membership.mdx | 4 + .../endpoints/organizations/workspaces.mdx | 4 + docs/api-reference/endpoints/users/me.mdx | 2 +- .../endpoints/users/my-organizations.mdx | 4 + docs/mint.json | 12 +- docs/spec.yaml | 359 +++++++++-- 28 files changed, 1546 insertions(+), 199 deletions(-) create mode 100644 backend/src/controllers/v2/organizationsController.ts create mode 100644 backend/src/middleware/requireMembershipOrgAuth.ts create mode 100644 backend/src/routes/v2/organizations.ts create mode 100644 docs/api-reference/endpoints/organizations/delete-membership.mdx create mode 100644 docs/api-reference/endpoints/organizations/memberships.mdx create mode 100644 docs/api-reference/endpoints/organizations/update-membership.mdx create mode 100644 docs/api-reference/endpoints/organizations/workspaces.mdx create mode 100644 docs/api-reference/endpoints/users/my-organizations.mdx diff --git a/backend/spec.json b/backend/spec.json index e28c66eaa..31ef07373 100644 --- a/backend/spec.json +++ b/backend/spec.json @@ -2258,6 +2258,368 @@ ] } }, + "/api/v2/users/me/organizations": { + "get": { + "summary": "Return organizations that current user is part of", + "description": "Return organizations that current user is part of", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "organizations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Organization" + }, + "description": "Organizations that user is part of" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] + } + }, + "/api/v2/organizations/{organizationId}/memberships": { + "get": { + "summary": "Return organization memberships", + "description": "Return organization memberships", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipOrg" + }, + "description": "Memberships of organization" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] + } + }, + "/api/v2/organizations/{organizationId}/memberships/{membershipId}": { + "patch": { + "summary": "Update organization membership", + "description": "Update organization membership", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization" + }, + { + "name": "membershipId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization membership to update" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + "$ref": "#/components/schemas/MembershipOrg", + "description": "Updated organization membership" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "description": "Role of organization membership - either owner, admin, or member" + } + } + } + } + } + } + }, + "delete": { + "summary": "Delete organization membership", + "description": "Delete organization membership", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization" + }, + { + "name": "membershipId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization membership to delete" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + "$ref": "#/components/schemas/MembershipOrg", + "description": "Deleted organization membership" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] + } + }, + "/api/v2/organizations/{organizationId}/workspaces": { + "get": { + "summary": "Return projects in organization that user is part of", + "description": "Return projects in organization that user is part of", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of organization" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + }, + "description": "Projects of organization" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] + } + }, + "/api/v2/workspace/{workspaceId}/environments": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentName": { + "example": "any" + }, + "environmentSlug": { + "example": "any" + } + } + } + } + } + } + }, + "put": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentName": { + "example": "any" + }, + "environmentSlug": { + "example": "any" + }, + "oldEnvironmentSlug": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentSlug": { + "example": "any" + } + } + } + } + } + } + } + }, "/api/v2/workspace/{workspaceId}/secrets": { "post": { "description": "", @@ -2450,56 +2812,6 @@ } }, "/api/v2/workspace/{workspaceId}/memberships/{membershipId}": { - "delete": { - "summary": "Delete project membership", - "description": "Delete project membership", - "parameters": [ - { - "name": "workspaceId", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "ID of project" - }, - { - "name": "membershipId", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "ID of membership" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "membership": { - "$ref": "#/components/schemas/Membership", - "description": "Deleted membership" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - } - }, - "security": [ - { - "apiKeyAuth": [] - } - ] - }, "patch": { "summary": "Update project membership", "description": "Update project membership", @@ -2520,7 +2832,7 @@ "schema": { "type": "string" }, - "description": "ID of membership" + "description": "ID of project membership to update" } ], "responses": { @@ -2565,6 +2877,56 @@ } } } + }, + "delete": { + "summary": "Delete project membership", + "description": "Delete project membership", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of project" + }, + { + "name": "membershipId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of project membership to delete" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + "$ref": "#/components/schemas/Membership", + "description": "Deleted membership" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] } }, "/api/v2/secret/batch-create/workspace/{workspaceId}/environment/{environment}": { @@ -3131,7 +3493,7 @@ } } }, - "/api/v2/api-key-data/": { + "/api/v2/api-key/": { "get": { "description": "", "parameters": [], @@ -3174,7 +3536,7 @@ } } }, - "/api/v2/api-key-data/{apiKeyDataId}": { + "/api/v2/api-key/{apiKeyDataId}": { "delete": { "description": "", "parameters": [ @@ -3302,6 +3664,102 @@ } } }, + "MembershipOrg": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "example": "" + }, + "email": { + "type": "string", + "example": "" + }, + "firstName": { + "type": "string", + "example": "" + }, + "lastName": { + "type": "string", + "example": "" + }, + "publicKey": { + "type": "string", + "example": "" + }, + "updatedAt": { + "type": "string", + "example": "" + }, + "createdAt": { + "type": "string", + "example": "" + } + } + }, + "organization": { + "type": "string", + "example": "" + }, + "role": { + "type": "string", + "example": "owner" + }, + "status": { + "type": "string", + "example": "accepted" + } + } + }, + "Organization": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "example": "" + }, + "name": { + "type": "string", + "example": "" + }, + "customerId": { + "type": "string", + "example": "" + } + } + }, + "Project": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "My Project" + }, + "organization": { + "type": "string", + "example": "" + }, + "environments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "development" + }, + "slug": { + "type": "string", + "example": "dev" + } + } + } + } + } + }, "ProjectKey": { "type": "object", "properties": { diff --git a/backend/src/app.ts b/backend/src/app.ts index e1ea949b9..24b73d7e3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,9 +42,10 @@ import { } from './routes/v1'; import { users as v2UsersRouter, + organizations as v2OrganizationsRouter, + workspace as v2WorkspaceRouter, secret as v2SecretRouter, // begin to phase out secrets as v2SecretsRouter, - workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, environment as v2EnvironmentRouter, @@ -98,7 +99,7 @@ app.use('/api/v1/membership', v1MembershipRouter); app.use('/api/v1/key', v1KeyRouter); app.use('/api/v1/invite-org', v1InviteOrgRouter); app.use('/api/v1/secret', v1SecretRouter); -app.use('/api/v1/service-token', v1ServiceTokenRouter); // stop supporting +app.use('/api/v1/service-token', v1ServiceTokenRouter); // deprecated app.use('/api/v1/password', v1PasswordRouter); app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); @@ -106,9 +107,10 @@ app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes app.use('/api/v2/users', v2UsersRouter); +app.use('/api/v2/organizations', v2OrganizationsRouter); app.use('/api/v2/workspace', v2EnvironmentRouter); -app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route -app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise +app.use('/api/v2/workspace', v2WorkspaceRouter); +app.use('/api/v2/secret', v2SecretRouter); // deprecated app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key', v2APIKeyDataRouter); @@ -128,7 +130,6 @@ app.use((req, res, next) => { //* Error Handling Middleware (must be after all routing logic) app.use(requestErrorHandler) - export const server = app.listen(PORT, () => { getLogger("backend-main").info(`Server started listening at port ${PORT}`) }); diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index 44e140b4e..353d47e3f 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -29,12 +29,6 @@ const productToPriceMap = { cardAuth: STRIPE_PRODUCT_CARD_AUTH }; -/** - * Return organizations that user is part of - * @param req - * @param res - * @returns - */ export const getOrganizations = async (req: Request, res: Response) => { let organizations; try { diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index b2b245d9c..936f5e281 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,4 +1,5 @@ import * as usersController from './usersController'; +import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; @@ -8,6 +9,7 @@ import * as environmentController from './environmentController'; export { usersController, + organizationsController, workspaceController, serviceTokenDataController, apiKeyDataController, diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts new file mode 100644 index 000000000..d167e5129 --- /dev/null +++ b/backend/src/controllers/v2/organizationsController.ts @@ -0,0 +1,296 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { + MembershipOrg, + Membership, + Workspace +} from '../../models'; +import { deleteMembershipOrg } from '../../helpers/membershipOrg'; +import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; + +/** + * Return memberships for organization with id [organizationId] + * @param req + * @param res + */ +export const getOrganizationMemberships = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Return organization memberships' + #swagger.description = 'Return organization memberships' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['organizationId'] = { + "description": "ID of organization", + "required": true, + "type": "string" + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "memberships": { + "type": "array", + "items": { + $ref: "#/components/schemas/MembershipOrg" + }, + "description": "Memberships of organization" + } + } + } + } + } + } + */ + let memberships; + try { + const { organizationId } = req.params; + + memberships = await MembershipOrg.find({ + organization: organizationId + }).populate('user', '+publicKey'); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get organization memberships' + }); + } + + return res.status(200).send({ + memberships + }); +} + +/** + * Update role of membership with id [membershipId] to role [role] + * @param req + * @param res + */ +export const updateOrganizationMembership = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Update organization membership' + #swagger.description = 'Update organization membership' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['organizationId'] = { + "description": "ID of organization", + "required": true, + "type": "string" + } + + #swagger.parameters['membershipId'] = { + "description": "ID of organization membership to update", + "required": true, + "type": "string" + } + + #swagger.requestBody = { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "description": "Role of organization membership - either owner, admin, or member", + } + } + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + $ref: "#/components/schemas/MembershipOrg", + "description": "Updated organization membership" + } + } + } + } + } + } + */ + let membership; + try { + const { membershipId } = req.params; + const { role } = req.body; + + membership = await MembershipOrg.findByIdAndUpdate( + membershipId, + { + role + }, { + new: true + } + ); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to update organization membership' + }); + } + + return res.status(200).send({ + membership + }); +} + +/** + * Delete organization membership with id [membershipId] + * @param req + * @param res + * @returns + */ +export const deleteOrganizationMembership = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Delete organization membership' + #swagger.description = 'Delete organization membership' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['organizationId'] = { + "description": "ID of organization", + "required": true, + "type": "string" + } + + #swagger.parameters['membershipId'] = { + "description": "ID of organization membership to delete", + "required": true, + "type": "string" + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + $ref: "#/components/schemas/MembershipOrg", + "description": "Deleted organization membership" + } + } + } + } + } + } + */ + let membership; + try { + const { membershipId } = req.params; + + // delete organization membership + membership = await deleteMembershipOrg({ + membershipOrgId: membershipId + }); + + await updateSubscriptionOrgQuantity({ + organizationId: membership.organization.toString() + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete organization membership' + }); + } + + return res.status(200).send({ + membership + }); +} + +/** + * Return workspaces for organization with id [organizationId] that user has + * access to + * @param req + * @param res + */ +export const getOrganizationWorkspaces = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Return projects in organization that user is part of' + #swagger.description = 'Return projects in organization that user is part of' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['organizationId'] = { + "description": "ID of organization", + "required": true, + "type": "string" + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaces": { + "type": "array", + "items": { + $ref: "#/components/schemas/Project" + }, + "description": "Projects of organization" + } + } + } + } + } + } + */ + let workspaces; + try { + const { organizationId } = req.params; + + const workspacesSet = new Set( + ( + await Workspace.find( + { + organization: organizationId + }, + '_id' + ) + ).map((w) => w._id.toString()) + ); + + workspaces = ( + await Membership.find({ + user: req.user._id + }).populate('workspace') + ) + .filter((m) => workspacesSet.has(m.workspace._id.toString())) + .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({ + workspaces + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 7ad247ff0..4ec1099d9 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -1,9 +1,16 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { - User + User, + MembershipOrg } from '../../models'; +/** + * Return the current user. + * @param req + * @param res + * @returns + */ export const getMe = async (req: Request, res: Response) => { /* #swagger.summary = "Retrieve the current user on the request" @@ -39,11 +46,64 @@ export const getMe = async (req: Request, res: Response) => { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to get user' + message: 'Failed to get current user' }); } return res.status(200).send({ user }); +} + +/** + * Return organizations that the current user is part of. + * @param req + * @param res + */ +export const getMyOrganizations = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Return organizations that current user is part of' + #swagger.description = 'Return organizations that current user is part of' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "organizations": { + "type": "array", + "items": { + $ref: "#/components/schemas/Organization" + }, + "description": "Organizations that user is part of" + } + } + } + } + } + } + */ + let organizations; + try { + organizations = ( + await MembershipOrg.find({ + user: req.user._id + }).populate('organization') + ).map((m) => m.organization); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get current user's organizations" + }); + } + + return res.status(200).send({ + organizations + }); } \ No newline at end of file diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 39a1be5fd..90781b58c 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -311,76 +311,6 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { }); } -/** - * Delete workspace membership with id [membershipId] - * @param req - * @param res - * @returns - */ -export const deleteWorkspaceMembership = async (req: Request, res: Response) => { - /* - #swagger.summary = 'Delete project membership' - #swagger.description = 'Delete project membership' - - #swagger.security = [{ - "apiKeyAuth": [] - }] - - #swagger.parameters['workspaceId'] = { - "description": "ID of project", - "required": true, - "type": "string" - } - - #swagger.parameters['membershipId'] = { - "description": "ID of membership", - "required": true, - "type": "string" - } - - #swagger.responses[200] = { - content: { - "application/json": { - "schema": { - "type": "object", - "properties": { - "membership": { - $ref: "#/components/schemas/Membership", - "description": "Deleted membership" - } - } - } - } - } - } - */ - let membership; - try { - const { - membershipId - } = req.params; - - membership = await Membership.findByIdAndDelete(membershipId); - - if (!membership) throw new Error('Failed to delete workspace membership'); - - await Key.deleteMany({ - receiver: membership.user, - workspace: membership.workspace - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete workspace membership' - }); - } - - return res.status(200).send({ - membership - }); -} - /** * Update role of membership with id [membershipId] to role [role] * @param req @@ -403,7 +333,7 @@ export const updateWorkspaceMembership = async (req: Request, res: Response) => } #swagger.parameters['membershipId'] = { - "description": "ID of membership", + "description": "ID of project membership to update", "required": true, "type": "string" } @@ -467,4 +397,74 @@ export const updateWorkspaceMembership = async (req: Request, res: Response) => return res.status(200).send({ membership }); +} + +/** + * Delete workspace membership with id [membershipId] + * @param req + * @param res + * @returns + */ +export const deleteWorkspaceMembership = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Delete project membership' + #swagger.description = 'Delete project membership' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['workspaceId'] = { + "description": "ID of project", + "required": true, + "type": "string" + } + + #swagger.parameters['membershipId'] = { + "description": "ID of project membership to delete", + "required": true, + "type": "string" + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membership": { + $ref: "#/components/schemas/Membership", + "description": "Deleted membership" + } + } + } + } + } + } + */ + let membership; + try { + const { + membershipId + } = req.params; + + membership = await Membership.findByIdAndDelete(membershipId); + + if (!membership) throw new Error('Failed to delete workspace membership'); + + await Key.deleteMany({ + receiver: membership.user, + workspace: membership.workspace + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete workspace membership' + }); + } + + return res.status(200).send({ + membership + }); } \ No newline at end of file diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 466cefdef..406162a8e 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -7,6 +7,7 @@ import { Membership, Key } from '../models'; * @param {Object} obj * @param {String} obj.userId - id of user to validate * @param {String} obj.workspaceId - id of workspace + * @returns {Membership} membership - membership of user with id [userId] for workspace with id [workspaceId] */ const validateMembership = async ({ userId, diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 42164b8e4..4de4be82d 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -1,6 +1,42 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { MembershipOrg, Workspace, Membership, Key } from '../models'; +/** + * Validate that user with id [userId] is a member of organization with id [organizationId] + * and has at least one of the roles in [acceptedRoles] + * + */ +const validateMembership = async ({ + userId, + organizationId, + acceptedRoles +}: { + userId: string; + organizationId: string; + acceptedRoles: 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'); + } + + return membership; +} + /** * Return organization membership matching criteria specified in * query [queryObj] @@ -84,6 +120,8 @@ const deleteMembershipOrg = async ({ _id: membershipOrgId }); + if (!deletedMembershipOrg) throw new Error('Failed to delete organization membership'); + // delete keys associated with organization membership if (deletedMembershipOrg?.user) { // case: organization membership had a registered user @@ -117,4 +155,9 @@ const deleteMembershipOrg = async ({ return deletedMembershipOrg; }; -export { findMembershipOrg, addMembershipsOrg, deleteMembershipOrg }; +export { + validateMembership, + findMembershipOrg, + addMembershipsOrg, + deleteMembershipOrg +}; diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index e9bffac2d..86b653281 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -3,6 +3,7 @@ import requireBotAuth from './requireBotAuth'; import requireSignupAuth from './requireSignupAuth'; import requireWorkspaceAuth from './requireWorkspaceAuth'; import requireMembershipAuth from './requireMembershipAuth'; +import requireMembershipOrgAuth from './requireMembershipOrgAuth'; import requireOrganizationAuth from './requireOrganizationAuth'; import requireIntegrationAuth from './requireIntegrationAuth'; import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizationAuth'; @@ -18,6 +19,7 @@ export { requireSignupAuth, requireWorkspaceAuth, requireMembershipAuth, + requireMembershipOrgAuth, requireOrganizationAuth, requireIntegrationAuth, requireIntegrationAuthorizationAuth, diff --git a/backend/src/middleware/requireMembershipAuth.ts b/backend/src/middleware/requireMembershipAuth.ts index 06c0980a3..f5e4fe8b1 100644 --- a/backend/src/middleware/requireMembershipAuth.ts +++ b/backend/src/middleware/requireMembershipAuth.ts @@ -6,11 +6,12 @@ import { import { validateMembership } from '../helpers/membership'; type req = 'params' | 'body' | 'query'; + /** * Validate membership with id [membershipId] and that user with id * [req.user._id] can modify that membership. * @param {Object} obj - * @param {String[]} obj.acceptedRoles - accepted workspace roles for JWT auth + * @param {String[]} obj.acceptedRoles - accepted workspace roles * @param {String[]} obj.location - location of [workspaceId] on request (e.g. params, body) for parsing */ const requireMembershipAuth = ({ diff --git a/backend/src/middleware/requireMembershipOrgAuth.ts b/backend/src/middleware/requireMembershipOrgAuth.ts new file mode 100644 index 000000000..ec1a4d4f1 --- /dev/null +++ b/backend/src/middleware/requireMembershipOrgAuth.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { UnauthorizedRequestError } from '../utils/errors'; +import { + MembershipOrg +} from '../models'; +import { validateMembership } from '../helpers/membershipOrg'; + + +type req = 'params' | 'body' | 'query'; + +/** + * Validate (organization) membership id [membershipId] and that user with id + * [req.user._id] can modify that membership. + * @param {Object} obj + * @param {String[]} obj.acceptedRoles - accepted organization roles + * @param {String[]} obj.location - location of [membershipId] on request (e.g. params, body) for parsing + */ +const requireMembershipOrgAuth = ({ + acceptedRoles, + location = 'params' +}: { + acceptedRoles: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const { membershipId } = req[location]; + const membershipOrg = await MembershipOrg.findById(membershipId); + + 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 = targetMembership; + + return next(); + } catch (err) { + return next(UnauthorizedRequestError({ + message: 'Unable to validate organization membership' + })); + } + } +} + +export default requireMembershipOrgAuth; \ No newline at end of file diff --git a/backend/src/routes/v1/membership.ts b/backend/src/routes/v1/membership.ts index f2c3eb554..4481b8843 100644 --- a/backend/src/routes/v1/membership.ts +++ b/backend/src/routes/v1/membership.ts @@ -4,6 +4,8 @@ import { body, param } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; import { membershipController } from '../../controllers/v1'; +// note: ALL DEPRECIATED (moved to api/v2/workspace/:workspaceId/memberships/:membershipId) + router.get( // used for old CLI (deprecate) '/:workspaceId/connect', requireAuth({ diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 6404c3246..65a4b373f 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -9,7 +9,7 @@ import { import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; import { organizationController } from '../../controllers/v1'; -router.get( +router.get( // deprecated (moved to api/v2/users/me/organizations) '/', requireAuth({ acceptedAuthModes: ['jwt'] @@ -41,7 +41,7 @@ router.get( organizationController.getOrganization ); -router.get( +router.get( // deprecated (moved to api/v2/organizations/:organizationId/memberships) '/:organizationId/users', requireAuth({ acceptedAuthModes: ['jwt'] @@ -56,7 +56,7 @@ router.get( ); router.get( - '/:organizationId/my-workspaces', + '/:organizationId/my-workspaces', // deprecated (moved to api/v2/organizations/:organizationId/workspaces) requireAuth({ acceptedAuthModes: ['jwt'] }), diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index cc9375fb7..6f698e316 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,16 +1,18 @@ import users from './users'; -import secret from './secret'; // stop-supporting -import secrets from './secrets'; +import organizations from './organizations'; import workspace from './workspace'; +import secret from './secret'; // deprecated +import secrets from './secrets'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; import environment from "./environment" export { users, + organizations, + workspace, secret, secrets, - workspace, serviceTokenData, apiKeyData, environment diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts new file mode 100644 index 000000000..1362a7759 --- /dev/null +++ b/backend/src/routes/v2/organizations.ts @@ -0,0 +1,80 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireOrganizationAuth, + requireMembershipOrgAuth, + validateRequest +} from '../../middleware'; +import { body, param, query } from 'express-validator'; +import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; +import { organizationsController } from '../../controllers/v2'; + +// TODO: /POST to create membership + +router.get( + '/:organizationId/memberships', + param('organizationId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationMemberships +); + +router.patch( + '/:organizationId/memberships/:membershipId', + param('organizationId').exists().trim(), + param('membershipId').exists().trim(), + body('role').exists().isString().trim().isIn([ADMIN, MEMBER]), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireMembershipOrgAuth({ + acceptedRoles: [OWNER, ADMIN] + }), + organizationsController.updateOrganizationMembership +); + +router.delete( + '/:organizationId/memberships/:membershipId', + param('organizationId').exists().trim(), + param('membershipId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireMembershipOrgAuth({ + acceptedRoles: [OWNER, ADMIN] + }), + organizationsController.deleteOrganizationMembership +); + +router.get( + '/:organizationId/workspaces', + param('organizationId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationWorkspaces +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index b0c0f8f36..f1b61b47c 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -10,7 +10,7 @@ import { ADMIN, MEMBER } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; -// note to devs: stop supporting +// note to devs: stop supporting these routes [deprecated] const router = express.Router(); diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 95b064a79..48c015cca 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -13,4 +13,12 @@ router.get( usersController.getMe ); +router.get( + '/me/organizations', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + usersController.getMyOrganizations +); + export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 42c0f16a4..55299e575 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -83,23 +83,6 @@ router.get( // new - TODO: rewire dashboard to this route workspaceController.getWorkspaceMemberships ); -router.delete( // TODO - rewire dashboard to this route - '/:workspaceId/memberships/:membershipId', - param('workspaceId').exists().trim(), - param('membershipId').exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - }), - requireMembershipAuth({ - acceptedRoles: [ADMIN] - }), - workspaceController.deleteWorkspaceMembership -); - router.patch( // TODO - rewire dashboard to this route '/:workspaceId/memberships/:membershipId', param('workspaceId').exists().trim(), @@ -118,4 +101,21 @@ router.patch( // TODO - rewire dashboard to this route workspaceController.updateWorkspaceMembership ); +router.delete( // TODO - rewire dashboard to this route + '/:workspaceId/memberships/:membershipId', + param('workspaceId').exists().trim(), + param('membershipId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + }), + requireMembershipAuth({ + acceptedRoles: [ADMIN] + }), + workspaceController.deleteWorkspaceMembership +); + export default router; diff --git a/backend/swagger/index.ts b/backend/swagger/index.ts index cbf05ed7e..d7a87357f 100644 --- a/backend/swagger/index.ts +++ b/backend/swagger/index.ts @@ -66,6 +66,33 @@ const generateOpenAPISpec = async () => { workspace: '', role: 'admin' }, + MembershipOrg: { + user: { + _id: '', + email: '', + firstName: '', + lastName: '', + publicKey: '', + updatedAt: '', + createdAt: '' + }, + organization: '', + role: 'owner', + status: 'accepted' + }, + Organization: { + _id: '', + name: '', + customerId: '' + }, + Project: { + name: 'My Project', + organization: '', + environments: [{ + name: 'development', + slug: 'dev' + }] + }, ProjectKey: { encryptedkey: '', nonce: '', diff --git a/docs/api-reference/endpoints/organizations/delete-membership.mdx b/docs/api-reference/endpoints/organizations/delete-membership.mdx new file mode 100644 index 000000000..d9862e854 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/delete-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Membership" +openapi: "DELETE /api/v2/organizations/{organizationId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/organizations/memberships.mdx b/docs/api-reference/endpoints/organizations/memberships.mdx new file mode 100644 index 000000000..b4b6f3d33 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Memberships" +openapi: "GET /api/v2/organizations/{organizationId}/memberships" +--- diff --git a/docs/api-reference/endpoints/organizations/update-membership.mdx b/docs/api-reference/endpoints/organizations/update-membership.mdx new file mode 100644 index 000000000..1654d59c2 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/update-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Membership" +openapi: "PATCH /api/v2/organizations/{organizationId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/organizations/workspaces.mdx b/docs/api-reference/endpoints/organizations/workspaces.mdx new file mode 100644 index 000000000..0950a84e0 --- /dev/null +++ b/docs/api-reference/endpoints/organizations/workspaces.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Projects" +openapi: "GET /api/v2/organizations/{organizationId}/workspaces" +--- diff --git a/docs/api-reference/endpoints/users/me.mdx b/docs/api-reference/endpoints/users/me.mdx index 9273f0ca6..9dab56dfd 100644 --- a/docs/api-reference/endpoints/users/me.mdx +++ b/docs/api-reference/endpoints/users/me.mdx @@ -1,4 +1,4 @@ --- -title: "Get Current User" +title: "Get My User" openapi: "GET /api/v2/users/me" --- diff --git a/docs/api-reference/endpoints/users/my-organizations.mdx b/docs/api-reference/endpoints/users/my-organizations.mdx new file mode 100644 index 000000000..d1ced8124 --- /dev/null +++ b/docs/api-reference/endpoints/users/my-organizations.mdx @@ -0,0 +1,4 @@ +--- +title: "Get My Organizations" +openapi: "GET /api/v2/users/me/organizations" +--- diff --git a/docs/mint.json b/docs/mint.json index 77d17c6c3..f61b37a62 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -162,7 +162,17 @@ { "group": "Users", "pages": [ - "api-reference/endpoints/users/me" + "api-reference/endpoints/users/me", + "api-reference/endpoints/users/my-organizations" + ] + }, + { + "group": "Organizations", + "pages": [ + "api-reference/endpoints/organizations/memberships", + "api-reference/endpoints/organizations/update-membership", + "api-reference/endpoints/organizations/delete-membership", + "api-reference/endpoints/organizations/workspaces" ] }, { diff --git a/docs/spec.yaml b/docs/spec.yaml index 991682871..ced05d577 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -1392,6 +1392,229 @@ paths: description: Bad Request security: - apiKeyAuth: [] + /api/v2/users/me/organizations: + get: + summary: Return organizations that current user is part of + description: Return organizations that current user is part of + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + organizations: + type: array + items: + $ref: '#/components/schemas/Organization' + description: Organizations that user is part of + '400': + description: Bad Request + security: + - apiKeyAuth: [] + /api/v2/organizations/{organizationId}/memberships: + get: + summary: Return organization memberships + description: Return organization memberships + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + description: ID of organization + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + memberships: + type: array + items: + $ref: '#/components/schemas/MembershipOrg' + description: Memberships of organization + '400': + description: Bad Request + security: + - apiKeyAuth: [] + /api/v2/organizations/{organizationId}/memberships/{membershipId}: + patch: + summary: Update organization membership + description: Update organization membership + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + description: ID of organization + - name: membershipId + in: path + required: true + schema: + type: string + description: ID of organization membership to update + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + membership: + $ref: '#/components/schemas/MembershipOrg' + description: Updated organization membership + '400': + description: Bad Request + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + role: + type: string + description: >- + Role of organization membership - either owner, admin, or + member + delete: + summary: Delete organization membership + description: Delete organization membership + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + description: ID of organization + - name: membershipId + in: path + required: true + schema: + type: string + description: ID of organization membership to delete + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + membership: + $ref: '#/components/schemas/MembershipOrg' + description: Deleted organization membership + '400': + description: Bad Request + security: + - apiKeyAuth: [] + /api/v2/organizations/{organizationId}/workspaces: + get: + summary: Return projects in organization that user is part of + description: Return projects in organization that user is part of + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + description: ID of organization + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + workspaces: + type: array + items: + $ref: '#/components/schemas/Project' + description: Projects of organization + '400': + description: Bad Request + security: + - apiKeyAuth: [] + /api/v2/workspace/{workspaceId}/environments: + post: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + requestBody: + content: + application/json: + schema: + type: object + properties: + environmentName: + example: any + environmentSlug: + example: any + put: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + requestBody: + content: + application/json: + schema: + type: object + properties: + environmentName: + example: any + environmentSlug: + example: any + oldEnvironmentSlug: + example: any + delete: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + requestBody: + content: + application/json: + schema: + type: object + properties: + environmentSlug: + example: any /api/v2/workspace/{workspaceId}/secrets: post: description: '' @@ -1509,37 +1732,6 @@ paths: security: - apiKeyAuth: [] /api/v2/workspace/{workspaceId}/memberships/{membershipId}: - delete: - summary: Delete project membership - description: Delete project membership - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - - name: membershipId - in: path - required: true - schema: - type: string - description: ID of membership - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - membership: - $ref: '#/components/schemas/Membership' - description: Deleted membership - '400': - description: Bad Request - security: - - apiKeyAuth: [] patch: summary: Update project membership description: Update project membership @@ -1555,7 +1747,7 @@ paths: required: true schema: type: string - description: ID of membership + description: ID of project membership to update responses: '200': description: OK @@ -1581,6 +1773,37 @@ paths: role: type: string description: Role of membership - either admin or member + delete: + summary: Delete project membership + description: Delete project membership + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + description: ID of project + - name: membershipId + in: path + required: true + schema: + type: string + description: ID of project membership to delete + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + membership: + $ref: '#/components/schemas/Membership' + description: Deleted membership + '400': + description: Bad Request + security: + - apiKeyAuth: [] /api/v2/secret/batch-create/workspace/{workspaceId}/environment/{environment}: post: description: '' @@ -1927,7 +2150,7 @@ paths: description: OK '400': description: Bad Request - /api/v2/api-key-data/: + /api/v2/api-key/: get: description: '' parameters: [] @@ -1954,7 +2177,7 @@ paths: example: any expiresIn: example: any - /api/v2/api-key-data/{apiKeyDataId}: + /api/v2/api-key/{apiKeyDataId}: delete: description: '' parameters: @@ -2043,6 +2266,74 @@ components: role: type: string example: admin + MembershipOrg: + type: object + properties: + user: + type: object + properties: + _id: + type: string + example: '' + email: + type: string + example: '' + firstName: + type: string + example: '' + lastName: + type: string + example: '' + publicKey: + type: string + example: '' + updatedAt: + type: string + example: '' + createdAt: + type: string + example: '' + organization: + type: string + example: '' + role: + type: string + example: owner + status: + type: string + example: accepted + Organization: + type: object + properties: + _id: + type: string + example: '' + name: + type: string + example: '' + customerId: + type: string + example: '' + Project: + type: object + properties: + name: + type: string + example: My Project + organization: + type: string + example: '' + environments: + type: array + items: + type: object + properties: + name: + type: string + example: development + slug: + type: string + example: dev ProjectKey: type: object properties: