feat: add audit logging via webhook (#309)

* feat: add audit logging via webhook

* addd missing auditLogWebhook values in various places
This commit is contained in:
41666 2021-07-13 23:01:25 -04:00 committed by GitHub
parent 5671a408c1
commit acc604f83f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 488 additions and 22 deletions

View file

@ -11,6 +11,7 @@ export const CreateRoleypolyData = onlyRootUsers(
message:
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
features: Features.Preview,
auditLogWebhook: null,
categories: [
{
id: KSUID.randomSync().string,

View file

@ -1,8 +1,20 @@
import { GuildDataUpdate, SessionData, UserGuildPermissions } from '@roleypoly/types';
import { sendAuditLog, validateAuditLogWebhook } from '@roleypoly/api/utils/audit-log';
import {
GuildDataUpdate,
SessionData,
UserGuildPermissions,
WebhookValidationStatus,
} from '@roleypoly/types';
import { withSession } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { GuildData } from '../utils/kv';
import { lowPermissions, missingParameters, notFound, ok } from '../utils/responses';
import {
invalid,
lowPermissions,
missingParameters,
notFound,
ok,
} from '../utils/responses';
export const UpdateGuild = withSession(
(session: SessionData) =>
@ -27,13 +39,42 @@ export const UpdateGuild = withSession(
return lowPermissions();
}
const oldGuildData = await getGuildData(guildID);
const newGuildData = {
...(await getGuildData(guildID)),
...oldGuildData,
...guildUpdate,
};
if (oldGuildData.auditLogWebhook !== newGuildData.auditLogWebhook) {
try {
const validationStatus = await validateAuditLogWebhook(
guild,
newGuildData.auditLogWebhook
);
if (validationStatus !== WebhookValidationStatus.Ok) {
if (validationStatus === WebhookValidationStatus.NoneSet) {
newGuildData.auditLogWebhook = null;
} else {
return invalid({
what: 'webhookValidationStatus',
webhookValidationStatus: validationStatus,
});
}
}
} catch (e) {
invalid();
}
}
await GuildData.put(guildID, newGuildData);
try {
await sendAuditLog(oldGuildData, guildUpdate, session.user);
} catch (e) {
// Catching errors here because this isn't a critical task, and could simply fail due to operator error.
}
return ok();
}
);

View file

@ -11,6 +11,8 @@
"@roleypoly/misc-utils": "*",
"@roleypoly/types": "*",
"@roleypoly/worker-emulator": "*",
"@types/deep-equal": "^1.0.1",
"deep-equal": "^2.0.5",
"ksuid": "^2.0.0",
"lodash": "^4.17.21",
"ts-loader": "^8.3.0"

View file

@ -0,0 +1,226 @@
import { userAgent } from '@roleypoly/api/utils/api-tools';
import { uiPublicURI } from '@roleypoly/api/utils/config';
import {
Category,
DiscordUser,
GuildData,
GuildDataUpdate,
GuildSlug,
WebhookValidationStatus,
} from '@roleypoly/types';
import deepEqual from 'deep-equal';
import { sortBy, uniq } from 'lodash';
type WebhookEmbed = {
fields: {
name: string;
value: string;
inline: boolean;
}[];
timestamp: string;
title: string;
color: number;
author?: {
name: string;
icon_url: string;
};
};
type WebhookPayload = {
username: string;
avatar_url: string;
embeds: WebhookEmbed[];
provider: {
name: string;
url: string;
};
};
type ChangeHandler = (
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
newValue: GuildData[keyof GuildDataUpdate]
) => WebhookEmbed[];
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
message: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x453e3d,
fields: [
{
name: 'Old Message',
value: oldValue as string,
inline: false,
},
{
name: 'New Message',
value: newValue as string,
inline: false,
},
],
title: `Server message was updated...`,
},
],
auditLogWebhook: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x5d5352,
fields: [
{
name: 'Old Webhook ID',
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
inline: false,
},
{
name: 'New Webhook ID',
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
inline: false,
},
],
title: `Audit Log webhook URL was changed...`,
},
],
categories: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Categories',
value: getChangedCategories(
oldValue as Category[],
newValue as Category[]
).join('\n'),
inline: false,
},
],
title: `Categories were changed...`,
},
],
};
export const sendAuditLog = async (
guild: GuildData,
guildUpdate: GuildDataUpdate,
user: DiscordUser
) => {
const auditLogWebhooks = uniq([
guild.auditLogWebhook || '',
guildUpdate.auditLogWebhook || '',
]).filter((webhook) => webhook !== '');
if (auditLogWebhooks.length === 0) {
return;
}
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
const webhookPayload: WebhookPayload = {
username: 'Roleypoly (Audit Log)',
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
embeds: [
{
fields: [],
timestamp: new Date().toISOString(),
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
color: 0x332d2d,
author: {
name: user.username,
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
},
},
],
provider: {
name: 'Roleypoly',
url: uiPublicURI,
},
};
for (let key of keys) {
if (deepEqual(guildUpdate[key], guild[key])) {
continue;
}
const handler = changeHandlers[key];
if (!handler) {
continue;
}
const changeFields = handler(guild[key], guildUpdate[key]);
webhookPayload.embeds.push(...changeFields);
}
if (webhookPayload.embeds.length === 1) {
// No changes, don't bother sending
return;
}
// Colors are in order already, so use them to order the embeds.
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
const doWebhook = (webhook: string) =>
fetch(webhook, {
method: 'POST',
body: JSON.stringify(webhookPayload),
headers: {
'content-type': 'application/json',
'user-agent': userAgent,
},
});
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
};
export const validateAuditLogWebhook = async (
guild: GuildSlug,
webhook: string | null
): Promise<WebhookValidationStatus> => {
if (!webhook) {
return WebhookValidationStatus.NoneSet;
}
const url = new URL(webhook);
if (
url.hostname !== 'discord.com' ||
url.protocol !== 'https:' ||
url.pathname.startsWith('api/webhooks/')
) {
return WebhookValidationStatus.NotDiscordURL;
}
const response = await fetch(webhook, { method: 'GET' });
if (response.status !== 200) {
return WebhookValidationStatus.DoesNotExist;
}
const webhookData = await response.json();
if (webhookData.guild_id !== guild.id) {
return WebhookValidationStatus.NotSameGuild;
}
return WebhookValidationStatus.Ok;
};
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
const addedCategories = newCategories.filter(
(c) => !oldCategories.find((o) => o.id === c.id)
);
const removedCategories = oldCategories.filter(
(c) => !newCategories.find((o) => o.id === c.id)
);
const changedCategories = newCategories.filter(
(c) =>
oldCategories.find((o) => o.id === c.id) &&
!deepEqual(
oldCategories.find((o) => o.id === c.id),
newCategories.find((o) => o.id === c.id)
)
);
return [
...addedCategories.map((c) => ` **Added** ${c.name}`),
...removedCategories.map((c) => ` **Removed** ${c.name}`),
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
];
};

View file

@ -128,17 +128,22 @@ export const updateGuildMemberRoles = async (
export const getGuildData = async (id: string): Promise<GuildDataT> => {
const guildData = await GuildData.get<GuildDataT>(id);
const empty = {
id,
message: '',
categories: [],
features: Features.None,
auditLogWebhook: null,
};
if (!guildData) {
return {
id,
message: '',
categories: [],
features: Features.None,
};
return empty;
}
return guildData;
return {
...empty,
...guildData,
};
};
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {

View file

@ -44,6 +44,7 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
id: guild.id,
message: guild.message,
features: Features.LegacyGuild,
auditLogWebhook: null,
categories: sortBy(Object.values(guild.categories), 'position').map(
(category, idx) => ({
...category,

View file

@ -14,3 +14,6 @@ export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
export const rateLimited = () =>
respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 });
export const invalid = (obj: any = {}) =>
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });

View file

@ -1,14 +1,22 @@
import * as React from 'react';
import { Space as SpaceComponent } from './Space';
import { LinedSpace, Space } from './Space';
export default {
title: 'Atoms',
title: 'Atoms/Space',
};
export const Space = () => (
export const space = () => (
<>
hello world
<SpaceComponent />
<Space />
but im over here
</>
);
export const linedSpace = () => (
<>
hello world
<LinedSpace />
but im over here
</>
);

View file

@ -1,5 +1,17 @@
import styled from 'styled-components';
import { palette } from '@roleypoly/design-system/atoms/colors';
import styled, { css } from 'styled-components';
export const Space = styled.div`
height: 15px;
`;
export const LinedSpace = styled.div<{ width?: number }>`
height: 7.5px;
margin-top: 7.5px;
border-top: 1px solid ${palette.taupe300};
${(props) =>
props.width &&
css`
width: ${props.width}px;
`}
`;

View file

@ -174,6 +174,7 @@ export const guildData: GuildData = {
message: 'henlo worl!!',
categories: [mockCategory, mockCategorySingle],
features: Features.None,
auditLogWebhook: null,
};
export const user: DiscordUser = {

View file

@ -0,0 +1,72 @@
import { palette } from '@roleypoly/design-system/atoms/colors';
import { FaderOpacity } from '@roleypoly/design-system/atoms/fader';
import { TextInput } from '@roleypoly/design-system/atoms/text-input';
import { AmbientLarge, Text } from '@roleypoly/design-system/atoms/typography';
import { MessageBox } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled';
import { GuildData, WebhookValidationStatus } from '@roleypoly/types';
import { GoAlert, GoInfo } from 'react-icons/go';
import ReactTooltip from 'react-tooltip';
import styled from 'styled-components';
type Props = {
onChange: (guildData: GuildData) => void;
guildData: GuildData;
validationStatus: WebhookValidationStatus | null;
};
export const ServerUtilities = (props: Props) => {
return (
<MessageBox>
<Text>
(optional) Webhook URL for Audit Logging{' '}
<GoInfo
data-for="server-utilities"
data-tip="Reports changes made in the editor to a Webhook integration within your Discord server."
/>
</Text>
<TextInput
placeholder="https://discord.com/api/webhooks/000000000000000000/..."
value={props.guildData.auditLogWebhook || ''}
onChange={(event) =>
props.onChange({ ...props.guildData, auditLogWebhook: event.target.value })
}
/>
<FaderOpacity isVisible={props.validationStatus !== WebhookValidationStatus.Ok}>
<ValidationStatus validationStatus={props.validationStatus} />
</FaderOpacity>
<ReactTooltip id="server-utilities" />
</MessageBox>
);
};
const ValidationStatus = (props: Pick<Props, 'validationStatus'>) => {
switch (props.validationStatus) {
case WebhookValidationStatus.NotDiscordURL:
return (
<AmbientLarge>
<Alert /> URL must be to a Discord webhook, starting with
"https://discord.com/api/webhooks/".
</AmbientLarge>
);
case WebhookValidationStatus.NotSameGuild:
return (
<AmbientLarge>
<Alert /> Webhook must be in the same guild you are currently editing.
</AmbientLarge>
);
case WebhookValidationStatus.DoesNotExist:
return (
<AmbientLarge>
<Alert /> This webhook doesn't exist.
</AmbientLarge>
);
default:
return <AmbientLarge>&nbsp;</AmbientLarge>;
}
};
const Alert = styled(GoAlert)`
color: ${palette.red400};
position: relative;
top: 2px;
`;

View file

@ -1,10 +1,16 @@
import { Space } from '@roleypoly/design-system/atoms/space';
import { LinedSpace, Space } from '@roleypoly/design-system/atoms/space';
import { EditableServerMessage } from '@roleypoly/design-system/molecules/editable-server-message';
import { ServerMasthead } from '@roleypoly/design-system/molecules/server-masthead';
import { ServerUtilities } from '@roleypoly/design-system/molecules/server-utilities/ServerUtilities';
import { SecondaryEditing } from '@roleypoly/design-system/organisms/masthead';
import { Container } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled';
import { ServerCategoryEditor } from '@roleypoly/design-system/organisms/server-category-editor';
import { Category, PresentableGuild } from '@roleypoly/types';
import {
Category,
GuildData,
PresentableGuild,
WebhookValidationStatus,
} from '@roleypoly/types';
import deepEqual from 'deep-equal';
import React from 'react';
@ -13,17 +19,26 @@ export type EditorShellProps = {
onGuildChange?: (guild: PresentableGuild) => void;
onCategoryChange?: (category: Category) => void;
onMessageChange?: (message: PresentableGuild['data']['message']) => void;
errors: {
webhookValidation: WebhookValidationStatus;
};
};
export const EditorShell = (props: EditorShellProps) => {
const [guild, setGuild] = React.useState<PresentableGuild>(props.guild);
const [errors, setErrors] = React.useState<EditorShellProps['errors']>(props.errors);
React.useEffect(() => {
setGuild(props.guild);
}, [props.guild]);
React.useEffect(() => {
setErrors(props.errors);
}, [props.errors]);
const reset = () => {
setGuild(props.guild);
setErrors({ webhookValidation: WebhookValidationStatus.Ok });
};
const replaceCategories = (categories: Category[]) => {
@ -38,6 +53,12 @@ export const EditorShell = (props: EditorShellProps) => {
});
};
const updateGuildData = (data: PresentableGuild['data']) => {
setGuild((currentGuild) => {
return { ...currentGuild, data };
});
};
const doSubmit = () => {
props.onGuildChange?.(guild);
};
@ -65,7 +86,48 @@ export const EditorShell = (props: EditorShellProps) => {
/>
<Space />
<ServerCategoryEditor guild={guild} onChange={replaceCategories} />
<LinedSpace />
<ServerUtilities
guildData={guild.data}
onChange={updateGuildData}
validationStatus={validateWebhook(
guild.data.auditLogWebhook,
errors.webhookValidation
)}
/>
</Container>
</>
);
};
const validateWebhook = (
webhook: GuildData['auditLogWebhook'],
validationStatus: WebhookValidationStatus
) => {
if (!webhook) {
return WebhookValidationStatus.NoneSet;
}
try {
const url = new URL(webhook);
if (
url.hostname !== 'discord.com' ||
url.protocol !== 'https:' ||
url.pathname.startsWith('api/webhooks/')
) {
return WebhookValidationStatus.NotDiscordURL;
}
} catch (e) {
return WebhookValidationStatus.Ok;
}
if (
validationStatus !== WebhookValidationStatus.Ok &&
validationStatus !== WebhookValidationStatus.NoneSet
) {
return validationStatus;
}
return WebhookValidationStatus.Ok;
};

View file

@ -14,6 +14,7 @@ export default {
),
],
args: {
errors: { validationStatus: 0 },
guilds: mastheadSlugs,
user: user,
guild: guildEnum.guilds[0],

View file

@ -11,7 +11,9 @@ export const EditorTemplate = (
props;
return (
<AppShell {...appShellProps} activeGuildId={guild.id} small double>
<EditorShell guild={guild} onGuildChange={onGuildChange} />
<EditorShell guild={guild} onGuildChange={onGuildChange} errors={props.errors} />
</AppShell>
);
};
export type EditorErrors = EditorShellProps['errors'];

View file

@ -20,6 +20,7 @@ export type GuildData = {
message: string;
categories: Category[];
features: Features;
auditLogWebhook: string | null;
};
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
@ -48,3 +49,11 @@ export type GuildSlug = {
icon: string;
permissionLevel: UserGuildPermissions;
};
export enum WebhookValidationStatus {
Ok,
NoneSet,
DoesNotExist,
NotSameGuild,
NotDiscordURL,
}

View file

@ -1,10 +1,11 @@
import { navigate, Redirect } from '@reach/router';
import { EditorTemplate } from '@roleypoly/design-system/templates/editor';
import { EditorErrors, EditorTemplate } from '@roleypoly/design-system/templates/editor';
import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading';
import {
GuildDataUpdate,
PresentableGuild,
UserGuildPermissions,
WebhookValidationStatus,
} from '@roleypoly/types';
import * as React from 'react';
import { useAppShellProps } from '../contexts/app-shell/AppShellContext';
@ -25,6 +26,9 @@ const Editor = (props: EditorProps) => {
const [guild, setGuild] = React.useState<PresentableGuild | null | false>(null);
const [pending, setPending] = React.useState(false);
const [errors, setErrors] = React.useState<EditorErrors>({
webhookValidation: WebhookValidationStatus.Ok,
});
React.useEffect(() => {
const shouldPullUncached = (): boolean => {
@ -87,6 +91,7 @@ const Editor = (props: EditorProps) => {
const updatePayload: GuildDataUpdate = {
message: guild.data.message,
categories: guild.data.categories,
auditLogWebhook: guild.data.auditLogWebhook,
};
const response = await authedFetch(`/update-guild/${serverID}`, {
@ -96,16 +101,31 @@ const Editor = (props: EditorProps) => {
if (response.status === 200) {
setGuild(guild);
setPending(false);
navigate(`/s/${props.serverID}`);
}
navigate(`/s/${props.serverID}`);
if (response.status === 400) {
const error = await response.json();
if (error.data.what === 'webhookValidationStatus') {
setErrors((errors) => ({
...errors,
webhookValidation: error.data.webhookValidationStatus,
}));
}
}
setPending(false);
};
return (
<>
<Title title={`Editing ${guild.guild.name} - Roleypoly`} />
<EditorTemplate {...appShellProps} guild={guild} onGuildChange={onGuildChange} />
<EditorTemplate
{...appShellProps}
guild={guild}
onGuildChange={onGuildChange}
errors={errors}
/>
</>
);
};