Add permissions and audit logging to service tokens v3

This commit is contained in:
Tuan Dang
2023-09-25 13:24:28 +01:00
parent f59b3b3305
commit 698a268b5f
12 changed files with 246 additions and 58 deletions
@@ -4,9 +4,23 @@ import {
ServiceTokenDataV3, ServiceTokenDataV3,
ServiceTokenDataV3Key ServiceTokenDataV3Key
} from "../../models"; } from "../../models";
import {
Scope
} from "../../models/serviceTokenDataV3";
import {
EventType
} from "../../ee/models";
import { validateRequest } from "../../helpers/validation"; import { validateRequest } from "../../helpers/validation";
import * as reqValidator from "../../validation/serviceTokenV3"; import * as reqValidator from "../../validation/serviceTokenV3";
import { createToken } from "../../helpers/auth"; import { createToken } from "../../helpers/auth";
import {
ProjectPermissionActions,
ProjectPermissionSub,
getUserProjectPermissions
} from "../../ee/services/ProjectRoleService";
import { ForbiddenError } from "@casl/ability";
import { BadRequestError, ResourceNotFoundError } from "../../utils/errors";
import { EEAuditLogService } from "../../ee/services";
/** /**
* Create service token data * Create service token data
@@ -26,6 +40,11 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
nonce // for ServiceTokenDataV3Key nonce // for ServiceTokenDataV3Key
} }
} = await validateRequest(reqValidator.CreateServiceTokenV3, req); } = await validateRequest(reqValidator.CreateServiceTokenV3, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.ServiceTokens
);
let expiresAt; let expiresAt;
if (expiresIn) { if (expiresIn) {
@@ -33,12 +52,13 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
} }
const isActive = false;
const serviceTokenData = await new ServiceTokenDataV3({ const serviceTokenData = await new ServiceTokenDataV3({
name, name,
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
publicKey, publicKey,
scopes, scopes,
isActive: false, isActive,
expiresAt expiresAt
}).save(); }).save();
@@ -58,6 +78,22 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
secret: "hello" // TODO: replace with real secret secret: "hello" // TODO: replace with real secret
}); });
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.CREATE_SERVICE_TOKEN_V3,
metadata: {
name,
isActive,
scopes: scopes as Array<Scope>,
expiresAt
}
},
{
workspaceId: new Types.ObjectId(workspaceId)
}
);
return res.status(200).send({ return res.status(200).send({
serviceTokenData, serviceTokenData,
serviceToken: `proj_token.${token}` serviceToken: `proj_token.${token}`
@@ -81,13 +117,29 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
} }
} = await validateRequest(reqValidator.UpdateServiceTokenV3, req); } = await validateRequest(reqValidator.UpdateServiceTokenV3, req);
let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId);
if (!serviceTokenData) throw ResourceNotFoundError({
message: "Service token not found"
});
const { permission } = await getUserProjectPermissions(
req.user._id,
serviceTokenData.workspace.toString()
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.ServiceTokens
);
let expiresAt; let expiresAt;
if (expiresIn) { if (expiresIn) {
expiresAt = new Date(); expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
} }
const serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenDataId, serviceTokenDataId,
{ {
name, name,
@@ -99,6 +151,26 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
new: true new: true
} }
); );
if (!serviceTokenData) throw BadRequestError({
message: "Failed to update service token"
});
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.UPDATE_SERVICE_TOKEN_V3,
metadata: {
name,
isActive,
scopes: scopes as Array<Scope>,
expiresAt
}
},
{
workspaceId: serviceTokenData.workspace
}
);
return res.status(200).send({ return res.status(200).send({
serviceTokenData serviceTokenData
@@ -116,13 +188,42 @@ export const deleteServiceTokenData = async (req: Request, res: Response) => {
params: { serviceTokenDataId } params: { serviceTokenDataId }
} = await validateRequest(reqValidator.DeleteServiceTokenV3, req); } = await validateRequest(reqValidator.DeleteServiceTokenV3, req);
const serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId); let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId);
if (!serviceTokenData) throw ResourceNotFoundError({
message: "Service token not found"
});
if (serviceTokenData) { const { permission } = await getUserProjectPermissions(
await ServiceTokenDataV3Key.findOneAndDelete({ req.user._id,
serviceTokenData: serviceTokenData._id serviceTokenData.workspace.toString()
}); );
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.ServiceTokens
);
serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId);
if (!serviceTokenData) throw BadRequestError({
message: "Failed to delete service token"
});
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.DELETE_SERVICE_TOKEN_V3,
metadata: {
name: serviceTokenData.name,
isActive: serviceTokenData.isActive,
scopes: serviceTokenData.scopes as Array<Scope>,
expiresAt: serviceTokenData.expiresAt
}
},
{
workspaceId: serviceTokenData.workspace
}
);
return res.status(200).send({ return res.status(200).send({
serviceTokenData serviceTokenData
+5 -2
View File
@@ -26,8 +26,11 @@ export enum EventType {
ADD_TRUSTED_IP = "add-trusted-ip", ADD_TRUSTED_IP = "add-trusted-ip",
UPDATE_TRUSTED_IP = "update-trusted-ip", UPDATE_TRUSTED_IP = "update-trusted-ip",
DELETE_TRUSTED_IP = "delete-trusted-ip", DELETE_TRUSTED_IP = "delete-trusted-ip",
CREATE_SERVICE_TOKEN = "create-service-token", CREATE_SERVICE_TOKEN = "create-service-token", // v2
DELETE_SERVICE_TOKEN = "delete-service-token", DELETE_SERVICE_TOKEN = "delete-service-token", // v2
CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3
UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3
DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3
CREATE_ENVIRONMENT = "create-environment", CREATE_ENVIRONMENT = "create-environment",
UPDATE_ENVIRONMENT = "update-environment", UPDATE_ENVIRONMENT = "update-environment",
DELETE_ENVIRONMENT = "delete-environment", DELETE_ENVIRONMENT = "delete-environment",
+36
View File
@@ -2,6 +2,9 @@ import {
ActorType, ActorType,
EventType EventType
} from "./enums"; } from "./enums";
import {
Scope
} from "../../../models/serviceTokenDataV3";
interface UserActorMetadata { interface UserActorMetadata {
userId: string; userId: string;
@@ -194,6 +197,36 @@ interface DeleteServiceTokenEvent {
} }
} }
interface CreateServiceTokenV3Event {
type: EventType.CREATE_SERVICE_TOKEN_V3;
metadata: {
name: string;
isActive: boolean;
scopes: Array<Scope>;
expiresAt?: Date;
}
}
interface UpdateServiceTokenV3Event {
type: EventType.UPDATE_SERVICE_TOKEN_V3;
metadata: {
name?: string;
isActive?: boolean;
scopes?: Array<Scope>;
expiresAt?: Date;
}
}
interface DeleteServiceTokenV3Event {
type: EventType.DELETE_SERVICE_TOKEN_V3;
metadata: {
name: string;
isActive: boolean;
scopes: Array<Scope>;
expiresAt?: Date;
}
}
interface CreateEnvironmentEvent { interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT; type: EventType.CREATE_ENVIRONMENT;
metadata: { metadata: {
@@ -390,6 +423,9 @@ export type Event =
| DeleteTrustedIPEvent | DeleteTrustedIPEvent
| CreateServiceTokenEvent | CreateServiceTokenEvent
| DeleteServiceTokenEvent | DeleteServiceTokenEvent
| CreateServiceTokenV3Event
| UpdateServiceTokenV3Event
| DeleteServiceTokenV3Event
| CreateEnvironmentEvent | CreateEnvironmentEvent
| UpdateEnvironmentEvent | UpdateEnvironmentEvent
| DeleteEnvironmentEvent | DeleteEnvironmentEvent
+2 -1
View File
@@ -6,6 +6,7 @@ import {
} from "../../models"; } from "../../models";
import { import {
ServiceActor, ServiceActor,
ServiceActorV3,
UserActor, UserActor,
UserAgentType UserAgentType
} from "../../ee/models"; } from "../../ee/models";
@@ -23,7 +24,7 @@ export interface UserAuthData extends BaseAuthData {
} }
export interface ServiceTokenV3AuthData extends BaseAuthData { export interface ServiceTokenV3AuthData extends BaseAuthData {
actor: ServiceActor; actor: ServiceActorV3;
authPayload: IServiceTokenDataV3; authPayload: IServiceTokenDataV3;
} }
+1 -1
View File
@@ -5,7 +5,7 @@ enum Permission {
READ_WRITE = "readWrite" READ_WRITE = "readWrite"
} }
interface Scope { export interface Scope {
environment: string; environment: string;
secretPath: string; secretPath: string;
permission: Permission; permission: Permission;
+4
View File
@@ -58,6 +58,10 @@ export const validateClientForIntegration = async ({
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: "Failed service token authorization for integration" message: "Failed service token authorization for integration"
}); });
case ActorType.SERVICE_V3:
throw UnauthorizedRequestError({
message: "Failed service token authorization for integration"
});
} }
}; };
@@ -58,6 +58,10 @@ const validateClientForIntegrationAuth = async ({
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: "Failed service token authorization for integration authorization" message: "Failed service token authorization for integration authorization"
}); });
case ActorType.SERVICE_V3:
throw UnauthorizedRequestError({
message: "Failed service token authorization for integration authorization"
});
} }
}; };
+4
View File
@@ -46,6 +46,10 @@ export const validateClientForOrganization = async ({
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: "Failed service token authorization for organization" message: "Failed service token authorization for organization"
}); });
case ActorType.SERVICE_V3:
throw UnauthorizedRequestError({
message: "Failed service token authorization for organization"
});
} }
}; };
+6 -2
View File
@@ -7,6 +7,7 @@ import { WorkspaceNotFoundError } from "../utils/errors";
import { AuthData } from "../interfaces/middleware"; import { AuthData } from "../interfaces/middleware";
import { z } from "zod"; import { z } from "zod";
import { EventType, UserAgentType } from "../ee/models"; import { EventType, UserAgentType } from "../ee/models";
import { UnauthorizedRequestError } from "../utils/errors";
/** /**
* Validate authenticated clients for workspace with id [workspaceId] based * Validate authenticated clients for workspace with id [workspaceId] based
@@ -56,8 +57,11 @@ export const validateClientForWorkspace = async ({
environment, environment,
requiredPermissions requiredPermissions
}); });
break;
return {}; case ActorType.SERVICE_V3:
throw UnauthorizedRequestError({
message: "Failed service token authorization for organization"
});
} }
}; };
@@ -50,7 +50,7 @@ export const ServiceTokenSection = withProjectPermission(
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between"> <div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100"> <p className="text-xl font-semibold text-mineshaft-100">
{t("section.token.service-tokens")} Service Tokens
</p> </p>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
@@ -2,10 +2,13 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal DeleteActionModal
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { import {
useDeleteServiceTokenV3 useDeleteServiceTokenV3
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -14,7 +17,8 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal"; import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal";
import { ServiceTokenV3Table } from "./ServiceTokenV3Table"; import { ServiceTokenV3Table } from "./ServiceTokenV3Table";
export const ServiceTokenV3Section = () => { export const ServiceTokenV3Section = withProjectPermission(
() => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3(); const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -46,16 +50,24 @@ export const ServiceTokenV3Section = () => {
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100"> <p className="text-xl font-semibold text-mineshaft-100">
Service Tokens 2.0 (New) Service Tokens
</p> </p>
<Button <ProjectPermissionCan
colorSchema="secondary" I={ProjectPermissionActions.Create}
type="submit" a={ProjectPermissionSub.ServiceTokens}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("serviceTokenV3")}
> >
Create ST V3 {(isAllowed) => (
</Button> <Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("serviceTokenV3")}
isDisabled={!isAllowed}
>
Create ST V3
</Button>
)}
</ProjectPermissionCan>
</div> </div>
<ServiceTokenV3Table <ServiceTokenV3Table
handlePopUpOpen={handlePopUpOpen} handlePopUpOpen={handlePopUpOpen}
@@ -79,4 +91,6 @@ export const ServiceTokenV3Section = () => {
/> />
</div> </div>
); );
} },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
);
@@ -2,6 +2,7 @@ import { faKey, faPencil,faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
@@ -15,7 +16,7 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context";
import { import {
useGetWorkspaceServiceTokenDataV3, useGetWorkspaceServiceTokenDataV3,
useUpdateServiceTokenV3 useUpdateServiceTokenV3
@@ -96,7 +97,7 @@ export const ServiceTokenV3Table = ({
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={5} innerKey="service-tokens" />} {isLoading && <TableSkeleton columns={7} innerKey="service-tokens" />}
{!isLoading && {!isLoading &&
data && data &&
data.length > 0 && data.length > 0 &&
@@ -140,43 +141,59 @@ export const ServiceTokenV3Table = ({
<Td>{formatDate(createdAt)}</Td> <Td>{formatDate(createdAt)}</Td>
<Td>{expiresAt ? formatDate(expiresAt) : "-"}</Td> <Td>{expiresAt ? formatDate(expiresAt) : "-"}</Td>
<Td className="flex justify-end"> <Td className="flex justify-end">
<IconButton <ProjectPermissionCan
onClick={async () => { I={ProjectPermissionActions.Edit}
handlePopUpOpen("serviceTokenV3", { a={ProjectPermissionSub.ServiceTokens}
serviceTokenDataId: _id, >
name, {(isAllowed) => (
scopes, <IconButton
}); onClick={async () => {
}} handlePopUpOpen("serviceTokenV3", {
size="lg" serviceTokenDataId: _id,
colorSchema="primary" name,
variant="plain" scopes,
ariaLabel="update" });
> }}
<FontAwesomeIcon icon={faPencil} /> size="lg"
</IconButton> colorSchema="primary"
<IconButton variant="plain"
onClick={() => { ariaLabel="update"
handlePopUpOpen("deleteServiceTokenV3", { isDisabled={!isAllowed}
serviceTokenDataId: _id, >
name <FontAwesomeIcon icon={faPencil} />
}); </IconButton>
}} )}
size="lg" </ProjectPermissionCan>
colorSchema="danger" <ProjectPermissionCan
variant="plain" I={ProjectPermissionActions.Delete}
ariaLabel="update" a={ProjectPermissionSub.ServiceTokens}
className="ml-4" >
> {(isAllowed) => (
<FontAwesomeIcon icon={faXmark} /> <IconButton
</IconButton> onClick={() => {
handlePopUpOpen("deleteServiceTokenV3", {
serviceTokenDataId: _id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td> </Td>
</Tr> </Tr>
); );
})} })}
{!isLoading && data && data?.length === 0 && ( {!isLoading && data && data?.length === 0 && (
<Tr> <Tr>
<Td colSpan={5}> <Td colSpan={7}>
<EmptyState title="No service token v3 on file" icon={faKey} /> <EmptyState title="No service token v3 on file" icon={faKey} />
</Td> </Td>
</Tr> </Tr>