Skip to content

First pass implementation of a working RBAC system #53

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5522fac
Merged changes from remote
chriscarrollsmith Nov 26, 2024
476f69c
Reorganize database schema to allow 3-way user-org-role relationship
chriscarrollsmith Nov 27, 2024
5225799
Perform org operations only if the user has permissions
chriscarrollsmith Nov 27, 2024
11320b3
Moved role.py validation logic to request models and used custom HTTP…
chriscarrollsmith Nov 27, 2024
636bd45
Added authenticated user dependencies
chriscarrollsmith Nov 27, 2024
a8bee02
Replaced role and organization endpoints with helper functions to ret…
chriscarrollsmith Nov 28, 2024
4542f23
Added preliminary organizations.html component, moved some helpers to…
chriscarrollsmith Nov 28, 2024
2bd854e
Merge branch 'main' of https://github.com/Promptly-Technologies-LLC/f…
chriscarrollsmith Nov 28, 2024
ad71d96
Merge branch 'main' into 1-finish-implementing-roleorg-system
chriscarrollsmith Nov 28, 2024
eb58d4d
Default permissions will be global, but default roles will be organiz…
chriscarrollsmith Nov 28, 2024
f99af68
Refactored db.py
chriscarrollsmith Nov 28, 2024
b959ff0
Passing tests for utils/db.py helpers
chriscarrollsmith Nov 28, 2024
890e7cc
Test db setup helpers
chriscarrollsmith Nov 28, 2024
91bc397
Correct association table relationships to remove overlaps and silenc…
chriscarrollsmith Nov 29, 2024
a373df7
Eagerly load roles, orgs, and permissions with user in endpoints that…
chriscarrollsmith Nov 29, 2024
747352b
Org creation now works correctly! :-O
chriscarrollsmith Nov 29, 2024
3f13345
Added page for managing an organization
chriscarrollsmith Nov 30, 2024
2587dcf
Tests of cascade delete behaviors pass
chriscarrollsmith Nov 30, 2024
c2d63c6
Moved password hash to a separate database model, resolved some mypy …
chriscarrollsmith Dec 1, 2024
6b48566
Massively imporved/fixed role.py
chriscarrollsmith Dec 1, 2024
5ba8044
Use POST for all routes (since HTML forms only support GET and POST)
chriscarrollsmith Dec 1, 2024
4224b22
Re-render database model diagram and update LLMs.txt
chriscarrollsmith Dec 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ __pycache__
/.quarto/
_docs/
.pytest_cache/
.mypy_cache/
.mypy_cache/
.cursorrules
33 changes: 30 additions & 3 deletions docs/customization.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ To run the tests, use these commands:
The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory:

```bash
mypy
mypy .
```

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!
Expand Down Expand Up @@ -274,9 +274,9 @@ graph.write_png('static/schema.png')
![Database Schema](static/schema.png)


#### Database operations
#### Database helpers

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

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

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

#### Cascade deletes

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:

```python
sa_relationship_kwargs={
"cascade": "all, delete-orphan"
}
```

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.

For example,

```python
session.exec(delete(Role))
```

will not trigger the cascade delete. Instead, we need to select the role objects and then delete them:

```python
for role in session.exec(select(Role)).all():
session.delete(role)
```

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.
71 changes: 62 additions & 9 deletions docs/static/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri

### Start development database

To start the development database, run the following command in your terminal from the root directory:

``` bash
docker compose up -d
```
Expand Down Expand Up @@ -515,6 +517,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De

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.

*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.*

## Install development dependencies manually

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

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.

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.)

## Start development database

To start the development database, run the following command in your terminal from the root directory:

``` bash
docker compose up -d
```

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*:

``` bash
# Don't forget the -v flag to tear down the volume!
docker compose down -v
```

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:

``` bash
docker compose up -d --force-recreate --build
```

## Run the development server

Make sure the development database is running and tables and default permissions/roles are created first.
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:

``` bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
Expand Down Expand Up @@ -646,7 +667,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes
- `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.
- `session`: Provides a session for database operations in tests.
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables.
- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session.
- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password.

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

```bash
mypy
mypy .
```

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!
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!

### Developing with LLMs

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

#### Routing patterns in this template

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.
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.

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

```python
# -- Authenticated Routes --
```

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`.
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`.

### HTML templating with Jinja2

Expand All @@ -734,7 +760,7 @@ async def welcome(request: Request):
)
```

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.
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.

#### Form validation strategy

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


#### Database operations
#### Database helpers

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

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

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

#### Cascade deletes

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:

```python
sa_relationship_kwargs={
"cascade": "all, delete-orphan"
}
```

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.

For example,

```python
session.exec(delete(Role))
```

will not trigger the cascade delete. Instead, we need to select the role objects and then delete them:

```python
for role in session.exec(select(Role)).all():
session.delete(role)
```

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.


# Deployment

Expand Down
Binary file modified docs/static/schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 23 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
from sqlmodel import Session
from routers import authentication, organization, role, user
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
from utils.models import User
from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
from utils.models import User, Organization
from utils.db import get_session, set_up_db


logger = logging.getLogger("uvicorn.error")
logger.setLevel(logging.DEBUG)


@asynccontextmanager
async def lifespan(app: FastAPI):
# Optional startup logic
set_up_db(drop=False)
set_up_db()
yield
# Optional shutdown logic

Expand Down Expand Up @@ -229,8 +228,8 @@ async def read_reset_password(
# Define a dependency for common parameters
async def common_authenticated_parameters(
request: Request,
user: User = Depends(get_authenticated_user),
error_message: Optional[str] = None,
user: User = Depends(get_user_with_relations),
error_message: Optional[str] = None
) -> dict:
return {"request": request, "user": user, "error_message": error_message}

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


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


@app.get("/organizations/{org_id}")
async def read_organization(
org_id: int,
params: dict = Depends(common_authenticated_parameters)
):
# Get the organization only if the user is a member of it
org: Organization = params["user"].organizations.get(org_id)
if not org:
raise organization.OrganizationNotFoundError()

# Eagerly load roles and users
org.roles
org.users
params["organization"] = org

return templates.TemplateResponse(params["request"], "users/organization.html", params)


# -- Include Routers --


Expand Down
25 changes: 21 additions & 4 deletions routers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr, ConfigDict
from sqlmodel import Session, select
from utils.models import User
from utils.models import User, UserPassword
from utils.auth import (
get_session,
get_user_from_reset_token,
Expand Down Expand Up @@ -119,20 +119,25 @@ class UserRead(BaseModel):
# -- Routes --


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

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

# Hash the password
hashed_password = get_password_hash(user.password)

# Create the user
db_user = User(name=user.name, email=user.email,
hashed_password=hashed_password)
password=UserPassword(hashed_password=hashed_password))
session.add(db_user)
session.commit()
session.refresh(db_user)
Expand All @@ -154,9 +159,11 @@ async def login(
user: UserLogin = Depends(UserLogin.as_form),
session: Session = Depends(get_session),
) -> RedirectResponse:
# Check if the email is registered
db_user = session.exec(select(User).where(
User.email == user.email)).first()
if not db_user or not verify_password(user.password, db_user.hashed_password):

if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials")

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

# Update password and mark token as used
authorized_user.hashed_password = get_password_hash(user.new_password)
if authorized_user.password:
authorized_user.password.hashed_password = get_password_hash(
user.new_password
)
else:
logger.warning(
"User password not found during password reset; creating new password for user")
authorized_user.password = UserPassword(
hashed_password=get_password_hash(user.new_password)
)

reset_token.used = True
session.commit()
session.refresh(authorized_user)
Expand Down
Loading