Skip to content

Commit 0ed821a

Browse files
Merge pull request #53 from Promptly-Technologies-LLC/1-finish-implementing-roleorg-system
First pass implementation of a working RBAC system
2 parents 49b4468 + 4224b22 commit 0ed821a

File tree

20 files changed

+1361
-244
lines changed

20 files changed

+1361
-244
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ __pycache__
55
/.quarto/
66
_docs/
77
.pytest_cache/
8-
.mypy_cache/
8+
.mypy_cache/
9+
.cursorrules

docs/customization.qmd

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ To run the tests, use these commands:
4141
The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory:
4242

4343
```bash
44-
mypy
44+
mypy .
4545
```
4646

4747
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change!
@@ -274,9 +274,9 @@ graph.write_png('static/schema.png')
274274
![Database Schema](static/schema.png)
275275

276276

277-
#### Database operations
277+
#### Database helpers
278278

279-
Database operations are handled by helper functions in `utils/db.py`. Key functions include:
279+
Database operations are facilitated by helper functions in `utils/db.py`. Key functions include:
280280

281281
- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`)
282282
- `get_connection_url()`: Creates a database connection URL from environment variables in `.env`
@@ -292,3 +292,30 @@ async def get_users(session: Session = Depends(get_session)):
292292
```
293293

294294
The session automatically handles transaction management, ensuring that database operations are atomic and consistent.
295+
296+
#### Cascade deletes
297+
298+
Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set:
299+
300+
```python
301+
sa_relationship_kwargs={
302+
"cascade": "all, delete-orphan"
303+
}
304+
```
305+
306+
This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly.
307+
308+
For example,
309+
310+
```python
311+
session.exec(delete(Role))
312+
```
313+
314+
will not trigger the cascade delete. Instead, we need to select the role objects and then delete them:
315+
316+
```python
317+
for role in session.exec(select(Role)).all():
318+
session.delete(role)
319+
```
320+
321+
This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage.

docs/static/llms.txt

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri
105105

106106
### Start development database
107107

108+
To start the development database, run the following command in your terminal from the root directory:
109+
108110
``` bash
109111
docker compose up -d
110112
```
@@ -515,6 +517,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De
515517

516518
Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette.
517519

520+
*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.*
521+
518522
## Install development dependencies manually
519523

520524
### Python and Docker
@@ -598,15 +602,32 @@ Set your desired database name, username, and password in the .env file.
598602

599603
To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
600604

605+
If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.)
606+
601607
## Start development database
602608

609+
To start the development database, run the following command in your terminal from the root directory:
610+
603611
``` bash
604612
docker compose up -d
605613
```
606614

615+
If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*:
616+
617+
``` bash
618+
# Don't forget the -v flag to tear down the volume!
619+
docker compose down -v
620+
```
621+
622+
You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt:
623+
624+
``` bash
625+
docker compose up -d --force-recreate --build
626+
```
627+
607628
## Run the development server
608629

609-
Make sure the development database is running and tables and default permissions/roles are created first.
630+
Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory:
610631

611632
``` bash
612633
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
@@ -646,7 +667,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes
646667
- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state.
647668
- `session`: Provides a session for database operations in tests.
648669
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables.
649-
- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session.
670+
- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture.
671+
- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture.
650672
- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password.
651673

652674
To run the tests, use these commands:
@@ -661,10 +683,10 @@ To run the tests, use these commands:
661683
The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory:
662684

663685
```bash
664-
mypy
686+
mypy .
665687
```
666688

667-
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change!
689+
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change!
668690

669691
### Developing with LLMs
670692

@@ -705,15 +727,19 @@ We also create POST endpoints, which accept form submissions so the user can cre
705727

706728
#### Routing patterns in this template
707729

708-
In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the page, to indicate that they are read-only endpoints that do not modify the database.
730+
In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory.
731+
732+
We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the page, to indicate that they are read-only endpoints that do not modify the database.
709733

710734
We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this:
711735

712736
```python
713737
# -- Authenticated Routes --
714738
```
715739

716-
Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`.
740+
Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes.
741+
742+
Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`.
717743

718744
### HTML templating with Jinja2
719745

@@ -734,7 +760,7 @@ async def welcome(request: Request):
734760
)
735761
```
736762

737-
In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.
763+
In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.
738764

739765
#### Form validation strategy
740766

@@ -890,9 +916,9 @@ graph.write_png('static/schema.png')
890916
![Database Schema](static/schema.png)
891917

892918

893-
#### Database operations
919+
#### Database helpers
894920

895-
Database operations are handled by helper functions in `utils/db.py`. Key functions include:
921+
Database operations are facilitated by helper functions in `utils/db.py`. Key functions include:
896922

897923
- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`)
898924
- `get_connection_url()`: Creates a database connection URL from environment variables in `.env`
@@ -909,6 +935,33 @@ async def get_users(session: Session = Depends(get_session)):
909935

910936
The session automatically handles transaction management, ensuring that database operations are atomic and consistent.
911937

938+
#### Cascade deletes
939+
940+
Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set:
941+
942+
```python
943+
sa_relationship_kwargs={
944+
"cascade": "all, delete-orphan"
945+
}
946+
```
947+
948+
This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly.
949+
950+
For example,
951+
952+
```python
953+
session.exec(delete(Role))
954+
```
955+
956+
will not trigger the cascade delete. Instead, we need to select the role objects and then delete them:
957+
958+
```python
959+
for role in session.exec(select(Role)).all():
960+
session.delete(role)
961+
```
962+
963+
This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage.
964+
912965

913966
# Deployment
914967

docs/static/schema.png

-16.3 KB
Loading

main.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,18 @@
88
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
99
from sqlmodel import Session
1010
from routers import authentication, organization, role, user
11-
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
12-
from utils.models import User
11+
from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
12+
from utils.models import User, Organization
1313
from utils.db import get_session, set_up_db
1414

15-
1615
logger = logging.getLogger("uvicorn.error")
1716
logger.setLevel(logging.DEBUG)
1817

1918

2019
@asynccontextmanager
2120
async def lifespan(app: FastAPI):
2221
# Optional startup logic
23-
set_up_db(drop=False)
22+
set_up_db()
2423
yield
2524
# Optional shutdown logic
2625

@@ -229,8 +228,8 @@ async def read_reset_password(
229228
# Define a dependency for common parameters
230229
async def common_authenticated_parameters(
231230
request: Request,
232-
user: User = Depends(get_authenticated_user),
233-
error_message: Optional[str] = None,
231+
user: User = Depends(get_user_with_relations),
232+
error_message: Optional[str] = None
234233
) -> dict:
235234
return {"request": request, "user": user, "error_message": error_message}
236235

@@ -240,21 +239,34 @@ async def common_authenticated_parameters(
240239
async def read_dashboard(
241240
params: dict = Depends(common_authenticated_parameters)
242241
):
243-
if not params["user"]:
244-
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
245242
return templates.TemplateResponse(params["request"], "dashboard/index.html", params)
246243

247244

248245
@app.get("/profile")
249246
async def read_profile(
250247
params: dict = Depends(common_authenticated_parameters)
251248
):
252-
if not params["user"]:
253-
# Changed to 302
254-
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
255249
return templates.TemplateResponse(params["request"], "users/profile.html", params)
256250

257251

252+
@app.get("/organizations/{org_id}")
253+
async def read_organization(
254+
org_id: int,
255+
params: dict = Depends(common_authenticated_parameters)
256+
):
257+
# Get the organization only if the user is a member of it
258+
org: Organization = params["user"].organizations.get(org_id)
259+
if not org:
260+
raise organization.OrganizationNotFoundError()
261+
262+
# Eagerly load roles and users
263+
org.roles
264+
org.users
265+
params["organization"] = org
266+
267+
return templates.TemplateResponse(params["request"], "users/organization.html", params)
268+
269+
258270
# -- Include Routers --
259271

260272

routers/authentication.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import RedirectResponse
77
from pydantic import BaseModel, EmailStr, ConfigDict
88
from sqlmodel import Session, select
9-
from utils.models import User
9+
from utils.models import User, UserPassword
1010
from utils.auth import (
1111
get_session,
1212
get_user_from_reset_token,
@@ -119,20 +119,25 @@ class UserRead(BaseModel):
119119
# -- Routes --
120120

121121

122+
# TODO: Use custom error message in the case where the user is already registered
122123
@router.post("/register", response_class=RedirectResponse)
123124
async def register(
124125
user: UserRegister = Depends(UserRegister.as_form),
125126
session: Session = Depends(get_session),
126127
) -> RedirectResponse:
128+
# Check if the email is already registered
127129
db_user = session.exec(select(User).where(
128130
User.email == user.email)).first()
129131

130132
if db_user:
131133
raise HTTPException(status_code=400, detail="Email already registered")
132134

135+
# Hash the password
133136
hashed_password = get_password_hash(user.password)
137+
138+
# Create the user
134139
db_user = User(name=user.name, email=user.email,
135-
hashed_password=hashed_password)
140+
password=UserPassword(hashed_password=hashed_password))
136141
session.add(db_user)
137142
session.commit()
138143
session.refresh(db_user)
@@ -154,9 +159,11 @@ async def login(
154159
user: UserLogin = Depends(UserLogin.as_form),
155160
session: Session = Depends(get_session),
156161
) -> RedirectResponse:
162+
# Check if the email is registered
157163
db_user = session.exec(select(User).where(
158164
User.email == user.email)).first()
159-
if not db_user or not verify_password(user.password, db_user.hashed_password):
165+
166+
if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password):
160167
raise HTTPException(status_code=400, detail="Invalid credentials")
161168

162169
# Create access token
@@ -258,7 +265,17 @@ async def reset_password(
258265
raise HTTPException(status_code=400, detail="Invalid or expired token")
259266

260267
# Update password and mark token as used
261-
authorized_user.hashed_password = get_password_hash(user.new_password)
268+
if authorized_user.password:
269+
authorized_user.password.hashed_password = get_password_hash(
270+
user.new_password
271+
)
272+
else:
273+
logger.warning(
274+
"User password not found during password reset; creating new password for user")
275+
authorized_user.password = UserPassword(
276+
hashed_password=get_password_hash(user.new_password)
277+
)
278+
262279
reset_token.used = True
263280
session.commit()
264281
session.refresh(authorized_user)

0 commit comments

Comments
 (0)