feat: add access control

This commit is contained in:
41666 2021-07-18 01:57:03 -04:00
parent 9c07ff0e54
commit 3f45153b66
47 changed files with 1084 additions and 164 deletions

View file

@ -1,6 +1,8 @@
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
import { respond, withSession } from '../utils/api-tools';
import { getGuild, getGuildData, getGuildMemberRoles } from '../utils/guild';
import { getGuild, getGuildData, getGuildMember } from '../utils/guild';
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
@ -30,24 +32,28 @@ export const GetPickerData = withSession(
return fail();
}
const memberRolesP = getGuildMemberRoles({
const memberP = getGuildMember({
serverID: guildID,
userID,
});
const guildDataP = getGuildData(guildID);
const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]);
if (!memberRoles) {
const [guildData, member] = await Promise.all([guildDataP, memberP]);
if (!member) {
return fail();
}
if (!memberPassesAccessControl(checkGuild, member, guildData.accessControl)) {
return accessControlViolation();
}
const presentableGuild: PresentableGuild = {
id: guildID,
guild: checkGuild,
roles: guild.roles,
member: {
roles: memberRoles,
roles: member.roles,
},
data: guildData,
};

View file

@ -1,3 +1,5 @@
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
import {
GuildData,
Member,
@ -14,8 +16,8 @@ import { botToken } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMemberRoles,
updateGuildMemberRoles,
getGuildMember,
updateGuildMember,
} from '../utils/guild';
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
@ -45,18 +47,24 @@ export const UpdateRoles = withSession(
return notFound();
}
const guildMemberRoles = await getGuildMemberRoles(
const guildMember = await getGuildMember(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
if (!guildMemberRoles) {
if (!guildMember) {
return notFound();
}
const guildData = await getGuildData(guildID);
if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) {
return accessControlViolation();
}
const newRoles = calculateNewRoles({
currentRoles: guildMemberRoles,
currentRoles: guildMember.roles,
guildRoles: guild.roles,
guildData: await getGuildData(guildID),
guildData,
updateRequest,
});
@ -84,7 +92,8 @@ export const UpdateRoles = withSession(
roles: patchMemberRoles.roles,
};
await updateGuildMemberRoles({ serverID: guildID, userID }, patchMemberRoles.roles);
// Delete the cache by re-pulling... might be dangerous :)
await updateGuildMember({ serverID: guildID, userID });
return respond(updatedMember);
}

View file

@ -0,0 +1,50 @@
import { isRoot } from '@roleypoly/api/utils/api-tools';
import {
GuildAccessControl,
GuildSlug,
Member,
UserGuildPermissions,
} from '@roleypoly/types';
import { xor } from 'lodash';
export const memberPassesAccessControl = (
guildSlug: GuildSlug,
member: Member,
accessControl: GuildAccessControl
): boolean => {
// Root has a bypass
if (isRoot(member.user?.id || '')) {
return true;
}
// Admin and Manager has a bypass
if (guildSlug.permissionLevel !== UserGuildPermissions.User) {
return true;
}
// Block pending members, "Welcome Screen" feature
if (accessControl.blockPending && member.pending) {
return false;
}
// If member has roles in the blockList, block.
// Blocklist takes precedence over allowlist
// We use xor because xor([1, 3], [2, 3]) returns [3]), e.g. present in both lists
if (
accessControl.blockList &&
xor(member.roles, accessControl.blockList).length !== 0
) {
return false;
}
// If there is an allowList, and the member is not in it, block.
// If thew allowList is empty, we bypass this.
if (
accessControl.allowList &&
xor(member.roles, accessControl.allowList).length === 0
) {
return false;
}
return true;
};

View file

@ -108,6 +108,10 @@ export const discordFetch = async <T>(
}
};
export type CacheLayerOptions = {
skipCachePull?: boolean;
};
export const cacheLayer =
<Identity, Data>(
kv: WrappedKVNamespace,
@ -115,10 +119,7 @@ export const cacheLayer =
missHandler: (identity: Identity) => Promise<Data | null>,
ttlSeconds?: number
) =>
async (
identity: Identity,
options: { skipCachePull?: boolean } = {}
): Promise<Data | null> => {
async (identity: Identity, options: CacheLayerOptions = {}): Promise<Data | null> => {
const key = keyFactory(identity);
if (!options.skipCachePull) {

View file

@ -97,6 +97,23 @@ const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
title: `Categories were changed...`,
},
],
accessControl: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Access Control',
value: getChangedAccessControl(
oldValue as GuildDataUpdate['accessControl'],
newValue as GuildDataUpdate['accessControl']
).join('\n'),
inline: false,
},
],
title: `Access Control was changed...`,
},
],
};
export const sendAuditLog = async (
@ -224,3 +241,26 @@ const getChangedCategories = (oldCategories: Category[], newCategories: Category
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
];
};
const getChangedAccessControl = (
oldAccessControl: GuildDataUpdate['accessControl'],
newAccessControl: GuildDataUpdate['accessControl']
) => {
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
return [
`✅ Allowed roles: ${
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
}`,
`❌ Blocked roles: ${
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
}`,
...(pendingChanged
? [
`🔧 Pending/Welcome Screening users are ${
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
}`,
]
: []),
];
};

View file

@ -11,13 +11,21 @@ import {
Guild,
GuildData as GuildDataT,
GuildSlug,
Member,
OwnRoleInfo,
Role,
RoleSafety,
SessionData,
UserGuildPermissions,
} from '@roleypoly/types';
import { AuthType, cacheLayer, discordFetch, isRoot, withSession } from './api-tools';
import {
AuthType,
cacheLayer,
CacheLayerOptions,
discordFetch,
isRoot,
withSession,
} from './api-tools';
import { botClientID, botToken } from './config';
import { GuildData, Guilds } from './kv';
import { useRateLimiter } from './rate-limiting';
@ -73,6 +81,8 @@ export const getGuild = cacheLayer(
return role.position;
}, 0);
const guildData = await getGuildData(id);
const roles = guildRaw.roles.map<Role>((role) => ({
id: role.id,
name: role.name,
@ -80,7 +90,7 @@ export const getGuild = cacheLayer(
managed: role.managed,
position: role.position,
permissions: role.permissions,
safety: calculateRoleSafety(role, highestRolePosition),
safety: calculateRoleSafety(role, highestRolePosition, guildData),
}));
// Filters the raw guild data into data we actually want
@ -105,14 +115,20 @@ type GuildMemberIdentity = {
type APIMember = {
// Only relevant stuff, again.
roles: string[];
pending: boolean;
};
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
`guilds/${serverID}/members/${userID}/roles`;
export const getGuildMemberRoles = async (
{ serverID, userID }: GuildMemberIdentity,
opts?: CacheLayerOptions
) => (await getGuildMember({ serverID, userID }, opts))?.roles;
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
const guildMemberIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
`guilds/${serverID}/members/${userID}`;
export const getGuildMember = cacheLayer<GuildMemberIdentity, Member>(
Guilds,
guildMemberRolesIdentity,
guildMemberIdentity,
async ({ serverID, userID }) => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
@ -124,16 +140,16 @@ export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>
return null;
}
return discordMember.roles;
return {
roles: discordMember.roles,
pending: discordMember.pending,
};
},
60 * 5 // 5 minute TTL
);
export const updateGuildMemberRoles = async (
identity: GuildMemberIdentity,
roles: Role['id'][]
) => {
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
export const updateGuildMember = async (identity: GuildMemberIdentity) => {
await getGuildMember(identity, { skipCachePull: true });
};
export const getGuildData = async (id: string): Promise<GuildDataT> => {
@ -144,6 +160,11 @@ export const getGuildData = async (id: string): Promise<GuildDataT> => {
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
if (!guildData) {
@ -156,7 +177,11 @@ export const getGuildData = async (id: string): Promise<GuildDataT> => {
};
};
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
const calculateRoleSafety = (
role: Role | APIRole,
highestBotRolePosition: number,
guildData: GuildDataT
) => {
let safety = RoleSafety.Safe;
if (role.managed) {
@ -175,6 +200,13 @@ const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: numbe
safety |= RoleSafety.DangerousPermissions;
}
if (
guildData.accessControl.allowList.includes(role.id) ||
guildData.accessControl.blockList.includes(role.id)
) {
safety |= RoleSafety.AccessControl;
}
return safety;
};

View file

@ -45,6 +45,11 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
message: guild.message,
features: Features.LegacyGuild,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
categories: sortBy(Object.values(guild.categories), 'position').map(
(category, idx) => ({
...category,

View file

@ -8,12 +8,15 @@ export const missingParameters = () =>
export const lowPermissions = () =>
respond({ error: 'no permissions for this action' }, { status: 403 });
export const accessControlViolation = () =>
respond({ error: 'member fails access control requirements' }, { status: 403 });
export const notFound = () => respond({ error: 'not found' }, { status: 404 });
export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
export const rateLimited = () =>
respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 });
respond({ error: 'rate limit hit, enhance your calm' }, { status: 429 });
export const invalid = (obj: any = {}) =>
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });