Skip to content

Commit 25fce5e

Browse files
authored
Merge pull request #135 from python-discord/roles
Overhaul Access System
2 parents c86b95e + f545c0b commit 25fce5e

17 files changed

Lines changed: 455 additions & 89 deletions

File tree

SCHEMA.md

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,24 @@ In this document:
1212

1313
## Form
1414

15-
| Field | Type | Description | Example |
16-
| ------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
17-
| `id` | Unique identifier | A user selected, unique, descriptive identifier (used in URL routes, so no spaces) | `"ban-appeals"` |
18-
| `features` | List of [form features](#form-features) | A list of features to change the behaviour of the form, described in the features section | `["OPEN", "COLLECT_EMAIL"]` |
19-
| `questions` | List of [form questions](#form-question) | The list of questions to render on a specific form | Too long! See below |
20-
| `name` | String | Name of the form | `"Summer Code Jam 2100"` |
21-
| `description` | String | Form description | `"This is my amazing form description."` |
22-
| `webhook` | [Webhook object](#webhooks) | An optional discord webhook. | See webhook documentation. |
23-
| `submitted_text` | Optional[String] | An optional string for the response upon submitting. | `"This is my amazing form response."` |
24-
| `discord_role` | String (optional) | Discord role ID what will be assigned, required when `ASSIGN_ROLE` flag provided. | `784467518298259466` |
15+
| Field | Type | Description | Example |
16+
|--------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
17+
| `id` | Unique identifier | A user selected, unique, descriptive identifier (used in URL routes, so no spaces) | `"ban-appeals"` |
18+
| `features` | List of [form features](#form-features) | A list of features to change the behaviour of the form, described in the features section | `["OPEN", "COLLECT_EMAIL"]` |
19+
| `questions` | List of [form questions](#form-question) | The list of questions to render on a specific form | Too long! See below |
20+
| `name` | String | Name of the form | `"Summer Code Jam 2100"` |
21+
| `description` | String | Form description | `"This is my amazing form description."` |
22+
| `webhook` | [Webhook object](#webhooks) | An optional discord webhook. | See webhook documentation. |
23+
| `submitted_text` | Optional[String] | An optional string for the response upon submitting. | `"This is my amazing form response."` |
24+
| `discord_role` | String (optional) | Discord role ID what will be assigned, required when `ASSIGN_ROLE` flag provided. | `784467518298259466` |
25+
| `response_readers` | List[String] | Discord roles which can view the responses of the form. Can not be the everyone role. | `["267629731250176001", "825337057181696020"]` |
26+
| `editors` | List[String] | Discord roles which have permission to edit, delete, or otherwise modify the form. Can not be the everyone role. | `["409416496733880320"]` |
27+
2528

2629
### Form features
2730

2831
| Flag | Description |
29-
| ------------------ | ----------------------------------------------------------------------------- |
32+
|--------------------|-------------------------------------------------------------------------------|
3033
| `DISCOVERABLE` | The form should be displayed on the homepage of the forms application. |
3134
| `REQUIRES_LOGIN` | Requires the user to authenticate with Discord before completing the form. |
3235
| `OPEN` | The form is currently accepting responses. |
@@ -39,7 +42,7 @@ In this document:
3942
Discord webhooks to send information upon form submission.
4043

4144
| Field | Type | Description |
42-
| ----------| ------ | --------------------------------------------------------------------------------------------------------- |
45+
|-----------|--------|-----------------------------------------------------------------------------------------------------------|
4346
| `url` | String | Discord webhook URL. |
4447
| `message` | String | An optional message to include before the embed. Can use certain [context variables](#webhook-variables). |
4548

@@ -48,7 +51,7 @@ Discord webhooks to send information upon form submission.
4851
The following variables can be used in a webhook's message. The variables must be wrapped by braces (`{}`).
4952

5053
| Name | Description |
51-
| ------------- | ---------------------------------------------------------------------------- |
54+
|---------------|------------------------------------------------------------------------------|
5255
| `user` | A discord mention of the user submitting the form, or "User" if unavailable. |
5356
| `response_id` | ID of the submitted response. |
5457
| `form` | Name of the submitted form. |
@@ -59,7 +62,7 @@ The following variables can be used in a webhook's message. The variables must b
5962
### Form question
6063

6164
| Field | Type | Description | Example |
62-
| ---------- | ---------------------------------------- | ------------------------------------------------ | -------------------- |
65+
|------------|------------------------------------------|--------------------------------------------------|----------------------|
6366
| `id` | string | Unique identifier of the question | `"aabbcc"` |
6467
| `name` | string | Name of the question | `"What's the time?"` |
6568
| `type` | one of [Question types](#question-types) | The type of input for this question | `"radio"` |
@@ -69,7 +72,7 @@ The following variables can be used in a webhook's message. The variables must b
6972
#### Question types
7073

7174
| Name | Description |
72-
| ------------ | --------------------------------------------------------- |
75+
|--------------|-----------------------------------------------------------|
7376
| `radio` | Radio buttons |
7477
| `checkbox` | Checkbox toggle |
7578
| `select` | Dropdown list |
@@ -165,7 +168,7 @@ Textareas require no additional configuration.
165168
## Form response
166169

167170
| Field | Type | Description |
168-
| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------- |
171+
|-------------|------------------------------------------------------|-----------------------------------------------------------------------------|
169172
| `_id`/`id` | MongoDB ObjectID | Random identifier used for the response |
170173
| `user` | Optional [user details object](#user-details-object) | An object describing the user that submitted if the form is not anonymous |
171174
| `antispam` | Optional [anti spam object](#anti-spam-object) | An object containing information about the anti-spam on the form submission |
@@ -197,7 +200,7 @@ The user details contains the information returned by Discord alongside an `admi
197200
The anti-spam object contains information about the source of the form submission.
198201

199202
| Field | Type | Description |
200-
| ----------------- | ------- | ----------------------------------------------- |
203+
|-------------------|---------|-------------------------------------------------|
201204
| `ip_hash` | String | hash of the submitting users IP address |
202205
| `user_agent_hash` | String | hash of the submitting users user agent |
203206
| `captcha_pass` | Boolean | Whether the user passsed the hCaptcha |

backend/authentication/backend.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from starlette.requests import Request
66

77
from backend import constants
8+
from backend import discord
89
# We must import user such way here to avoid circular imports
910
from .user import User
1011

@@ -60,8 +61,12 @@ async def authenticate(
6061
except Exception:
6162
raise authentication.AuthenticationError("Could not parse user details.")
6263

63-
user = User(token, user_details)
64-
if await user.fetch_admin_status(request):
64+
user = User(
65+
token, user_details, await discord.get_member(request.state.db, user_details["id"])
66+
)
67+
if await user.fetch_admin_status(request.state.db):
6568
scopes.append("admin")
6669

70+
scopes.extend(await user.get_user_roles(request.state.db))
71+
6772
return authentication.AuthCredentials(scopes), user

backend/authentication/user.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1+
import typing
12
import typing as t
23

34
import jwt
5+
from pymongo.database import Database
46
from starlette.authentication import BaseUser
5-
from starlette.requests import Request
67

8+
from backend import discord, models
79
from backend.constants import SECRET_KEY
8-
from backend.discord import fetch_user_details
910

1011

1112
class User(BaseUser):
1213
"""Starlette BaseUser implementation for JWT authentication."""
1314

14-
def __init__(self, token: str, payload: dict[str, t.Any]) -> None:
15+
def __init__(
16+
self,
17+
token: str,
18+
payload: dict[str, t.Any],
19+
member: typing.Optional[models.DiscordMember],
20+
) -> None:
1521
self.token = token
1622
self.payload = payload
1723
self.admin = False
24+
self.member = member
1825

1926
@property
2027
def is_authenticated(self) -> bool:
@@ -34,16 +41,36 @@ def discord_mention(self) -> str:
3441
def decoded_token(self) -> dict[str, any]:
3542
return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"])
3643

37-
async def fetch_admin_status(self, request: Request) -> bool:
38-
self.admin = await request.state.db.admins.find_one(
44+
async def get_user_roles(self, database: Database) -> list[str]:
45+
"""Get a list of the user's discord roles."""
46+
if not self.member:
47+
return []
48+
49+
server_roles = await discord.get_roles(database)
50+
roles = [role.name for role in server_roles if role.id in self.member.roles]
51+
52+
if "admin" in roles:
53+
# Protect against collision with the forms admin role
54+
roles.remove("admin")
55+
roles.append("discord admin")
56+
57+
return roles
58+
59+
async def fetch_admin_status(self, database: Database) -> bool:
60+
self.admin = await database.admins.find_one(
3961
{"_id": self.payload["id"]}
4062
) is not None
4163

4264
return self.admin
4365

44-
async def refresh_data(self) -> None:
66+
async def refresh_data(self, database: Database) -> None:
4567
"""Fetches user data from discord, and updates the instance."""
46-
self.payload = await fetch_user_details(self.decoded_token.get("token"))
68+
self.member = await discord.get_member(database, self.payload["id"])
69+
70+
if self.member:
71+
self.payload = self.member.user.dict()
72+
else:
73+
self.payload = await discord.fetch_user_details(self.decoded_token.get("token"))
4774

4875
updated_info = self.decoded_token
4976
updated_info["user_details"] = self.payload

backend/discord.py

Lines changed: 164 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
"""Various utilities for working with the Discord API."""
2+
3+
import datetime
4+
import json
5+
import typing
6+
27
import httpx
8+
import starlette.requests
9+
from pymongo.database import Database
10+
from starlette import exceptions
311

4-
from backend.constants import (
5-
DISCORD_API_BASE_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET
6-
)
12+
from backend import constants, models
713

814

915
async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict:
1016
async with httpx.AsyncClient() as client:
1117
data = {
12-
"client_id": OAUTH2_CLIENT_ID,
13-
"client_secret": OAUTH2_CLIENT_SECRET,
18+
"client_id": constants.OAUTH2_CLIENT_ID,
19+
"client_secret": constants.OAUTH2_CLIENT_SECRET,
1420
"redirect_uri": f"{redirect}/callback"
1521
}
1622

@@ -21,7 +27,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict
2127
data["grant_type"] = "authorization_code"
2228
data["code"] = code
2329

24-
r = await client.post(f"{DISCORD_API_BASE_URL}/oauth2/token", headers={
30+
r = await client.post(f"{constants.DISCORD_API_BASE_URL}/oauth2/token", headers={
2531
"Content-Type": "application/x-www-form-urlencoded"
2632
}, data=data)
2733

@@ -32,10 +38,161 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict
3238

3339
async def fetch_user_details(bearer_token: str) -> dict:
3440
async with httpx.AsyncClient() as client:
35-
r = await client.get(f"{DISCORD_API_BASE_URL}/users/@me", headers={
41+
r = await client.get(f"{constants.DISCORD_API_BASE_URL}/users/@me", headers={
3642
"Authorization": f"Bearer {bearer_token}"
3743
})
3844

3945
r.raise_for_status()
4046

4147
return r.json()
48+
49+
50+
async def _get_role_info() -> list[models.DiscordRole]:
51+
"""Get information about the roles in the configured guild."""
52+
async with httpx.AsyncClient() as client:
53+
r = await client.get(
54+
f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}/roles",
55+
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}
56+
)
57+
58+
r.raise_for_status()
59+
return [models.DiscordRole(**role) for role in r.json()]
60+
61+
62+
async def get_roles(
63+
database: Database, *, force_refresh: bool = False
64+
) -> list[models.DiscordRole]:
65+
"""
66+
Get a list of all roles from the cache, or discord API if not available.
67+
68+
If `force_refresh` is True, the cache is skipped and the roles are updated.
69+
"""
70+
collection = database.get_collection("roles")
71+
72+
if force_refresh:
73+
# Drop all values in the collection
74+
await collection.delete_many({})
75+
76+
# `create_index` creates the index if it does not exist, or passes
77+
# This handles TTL on role objects
78+
await collection.create_index(
79+
"inserted_at",
80+
expireAfterSeconds=60 * 60 * 24, # 1 day
81+
name="inserted_at",
82+
)
83+
84+
roles = [models.DiscordRole(**json.loads(role["data"])) async for role in collection.find()]
85+
86+
if len(roles) == 0:
87+
# Fetch roles from the API and insert into the database
88+
roles = await _get_role_info()
89+
await collection.insert_many({
90+
"name": role.name,
91+
"id": role.id,
92+
"data": role.json(),
93+
"inserted_at": datetime.datetime.now(tz=datetime.timezone.utc),
94+
} for role in roles)
95+
96+
return roles
97+
98+
99+
async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMember]:
100+
"""Get a member by ID from the configured guild using the discord API."""
101+
async with httpx.AsyncClient() as client:
102+
r = await client.get(
103+
f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}"
104+
f"/members/{member_id}",
105+
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}
106+
)
107+
108+
if r.status_code == 404:
109+
return None
110+
111+
r.raise_for_status()
112+
return models.DiscordMember(**r.json())
113+
114+
115+
async def get_member(
116+
database: Database, user_id: str, *, force_refresh: bool = False
117+
) -> typing.Optional[models.DiscordMember]:
118+
"""
119+
Get a member from the cache, or from the discord API.
120+
121+
If `force_refresh` is True, the cache is skipped and the entry is updated.
122+
None may be returned if the member object does not exist.
123+
"""
124+
collection = database.get_collection("discord_members")
125+
126+
if force_refresh:
127+
await collection.delete_one({"user": user_id})
128+
129+
# `create_index` creates the index if it does not exist, or passes
130+
# This handles TTL on member objects
131+
await collection.create_index(
132+
"inserted_at",
133+
expireAfterSeconds=60 * 60, # 1 hour
134+
name="inserted_at",
135+
)
136+
137+
result = await collection.find_one({"user": user_id})
138+
139+
if result is not None:
140+
return models.DiscordMember(**json.loads(result["data"]))
141+
142+
member = await _fetch_member_api(user_id)
143+
144+
if not member:
145+
return None
146+
147+
await collection.insert_one({
148+
"user": user_id,
149+
"data": member.json(),
150+
"inserted_at": datetime.datetime.now(tz=datetime.timezone.utc),
151+
})
152+
return member
153+
154+
155+
class FormNotFoundError(exceptions.HTTPException):
156+
"""The requested form was not found."""
157+
158+
159+
class UnauthorizedError(exceptions.HTTPException):
160+
"""You are not authorized to use this resource."""
161+
162+
163+
async def _verify_access_helper(
164+
form_id: str, request: starlette.requests.Request, attribute: str
165+
) -> None:
166+
"""A low level helper to validate access to a form resource based on the user's scopes."""
167+
form = await request.state.db.forms.find_one({"_id": form_id})
168+
169+
if not form:
170+
raise FormNotFoundError(status_code=404)
171+
172+
# Short circuit all resources for forms admins
173+
if "admin" in request.auth.scopes:
174+
return
175+
176+
form = models.Form(**form)
177+
178+
for role_id in getattr(form, attribute, []):
179+
role = await request.state.db.roles.find_one({"id": role_id})
180+
if not role:
181+
continue
182+
183+
role = models.DiscordRole(**json.loads(role["data"]))
184+
185+
if role.name in request.auth.scopes:
186+
return
187+
188+
raise UnauthorizedError(status_code=401)
189+
190+
191+
async def verify_response_access(form_id: str, request: starlette.requests.Request) -> None:
192+
"""Ensure the user can access responses on the requested resource."""
193+
await _verify_access_helper(form_id, request, "response_readers")
194+
195+
196+
async def verify_edit_access(form_id: str, request: starlette.requests.Request) -> None:
197+
"""Ensure the user can view and modify the requested resource."""
198+
await _verify_access_helper(form_id, request, "editors")

0 commit comments

Comments
 (0)