mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 00:59:09 +00:00
feat: add access control
This commit is contained in:
parent
9c07ff0e54
commit
3f45153b66
47 changed files with 1084 additions and 164 deletions
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
50
packages/api/utils/access-control.ts
Normal file
50
packages/api/utils/access-control.ts
Normal 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;
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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 ✔'
|
||||
}`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue