Skip to content

Commit 52f365e

Browse files
First pass at setting up an RBAC system (still need to actually validate permissions on org and role endpoints)
1 parent d11ec61 commit 52f365e

File tree

6 files changed

+263
-11
lines changed

6 files changed

+263
-11
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,25 @@ Set your desired database name, username, and password in the .env file.
1818

1919
`docker compose up -d`
2020

21+
## Create database tables and default permissions/roles
22+
23+
`poetry run python migrations/set_up_db.py --drop`
24+
2125
## Run the development server
2226

27+
Make sure the development database is running and tables and default permissions/roles are created first.
28+
2329
`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
2430

2531
Navigate to http://localhost:8000/
2632

33+
## To do
34+
35+
- Implement password recovery
36+
- Implement role/org system
37+
- Implement user profile page
38+
- Add payments/billing system?
39+
2740
## License
2841

2942
This project is licensed under the GPLv3 License. See the LICENSE file for more details.

main.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,20 @@
55
from fastapi.responses import RedirectResponse
66
from fastapi.staticfiles import StaticFiles
77
from fastapi.templating import Jinja2Templates
8-
from sqlmodel import SQLModel, create_engine
98
from fastapi.exceptions import RequestValidationError, StarletteHTTPException
109
from routers import auth, organization, role, user
1110
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens
12-
from utils.db import User, get_connection_url
11+
from utils.db import User
1312

1413

1514
logger = logging.getLogger("uvicorn.error")
1615

1716

1817
@asynccontextmanager
1918
async def lifespan(app: FastAPI):
20-
# Startup logic
21-
engine = create_engine(get_connection_url())
22-
SQLModel.metadata.create_all(engine)
23-
engine.dispose()
19+
# Optional startup logic
2420
yield
25-
# Shutdown logic
21+
# Optional shutdown logic
2622

2723

2824
app = FastAPI(lifespan=lifespan)

migrations/set_up_db.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import sys
2+
import os
3+
4+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5+
6+
7+
if __name__ == "__main__":
8+
import argparse
9+
import os
10+
from dotenv import load_dotenv
11+
from utils.db import set_up_db
12+
13+
load_dotenv()
14+
15+
parser = argparse.ArgumentParser(
16+
description="Optionally drop and recreate all tables in the database and set up default roles and permissions"
17+
)
18+
parser.add_argument(
19+
"--drop",
20+
action="store_true",
21+
help="Drop all tables first"
22+
)
23+
24+
args = parser.parse_args()
25+
26+
set_up_db(args.drop)
27+
28+
print(
29+
f"Set up database {os.getenv('DB_NAME')}"
30+
)

routers/__init__.py

Whitespace-only changes.

routers/role.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,108 @@
1-
from fastapi import APIRouter
1+
from fastapi import APIRouter, Depends, HTTPException
2+
from pydantic import BaseModel, ConfigDict
3+
from sqlmodel import Session, select
4+
from utils.db import Role, RolePermissionLink, ValidPermissions, get_session
5+
from typing import List
6+
from utils.db import utc_time
7+
from datetime import datetime
28

3-
router = APIRouter(prefix="/role", tags=["role"])
9+
router = APIRouter(prefix="/roles", tags=["roles"])
10+
11+
12+
class RoleCreate(BaseModel):
13+
model_config = ConfigDict(from_attributes=True)
14+
15+
name: str
16+
permission_ids: List[int]
17+
18+
19+
class RoleRead(BaseModel):
20+
model_config = ConfigDict(from_attributes=True)
21+
22+
id: int
23+
name: str
24+
created_at: datetime
25+
updated_at: datetime
26+
deleted: bool
27+
permissions: List[str]
28+
29+
30+
class RoleUpdate(BaseModel):
31+
model_config = ConfigDict(from_attributes=True)
32+
33+
name: str
34+
permission_ids: List[int]
35+
36+
37+
@router.post("/", response_model=RoleRead)
38+
def create_role(role: RoleCreate, session: Session = Depends(get_session)):
39+
db_role = session.exec(select(Role).where(Role.name == role.name)).first()
40+
if db_role:
41+
raise HTTPException(status_code=400, detail="Role already exists")
42+
db_role = Role(name=role.name)
43+
session.add(db_role)
44+
session.commit()
45+
session.refresh(db_role)
46+
47+
for permission_id in role.permission_ids:
48+
db_role_permission_link = RolePermissionLink(
49+
role_id=db_role.id, permission_id=permission_id)
50+
session.add(db_role_permission_link)
51+
52+
session.commit()
53+
session.refresh(db_role)
54+
return db_role
55+
56+
57+
@router.get("/{role_id}", response_model=RoleRead)
58+
def read_role(role_id: int, session: Session = Depends(get_session)):
59+
db_role = session.get(Role, role_id)
60+
if not db_role:
61+
raise HTTPException(status_code=404, detail="Role not found")
62+
permissions = [
63+
link.permission.name for link in db_role.role_permission_links]
64+
return RoleRead(
65+
id=db_role.id,
66+
name=db_role.name,
67+
created_at=db_role.created_at,
68+
updated_at=db_role.updated_at,
69+
deleted=db_role.deleted,
70+
permissions=permissions
71+
)
72+
73+
74+
@router.put("/{role_id}", response_model=RoleRead)
75+
def update_role(role_id: int, role: RoleUpdate, session: Session = Depends(get_session)):
76+
db_role = session.get(Role, role_id)
77+
if not db_role:
78+
raise HTTPException(status_code=404, detail="Role not found")
79+
role_data = role.model_dump(exclude_unset=True)
80+
for key, value in role_data.items():
81+
setattr(db_role, key, value)
82+
db_role.updated_at = utc_time()
83+
session.add(db_role)
84+
session.commit()
85+
86+
# Update RolePermissionLinks
87+
session.exec(select(RolePermissionLink).where(
88+
RolePermissionLink.role_id == role_id)).delete()
89+
for permission_id in role.permission_ids:
90+
db_role_permission_link = RolePermissionLink(
91+
role_id=db_role.id, permission_id=permission_id)
92+
session.add(db_role_permission_link)
93+
94+
session.commit()
95+
session.refresh(db_role)
96+
return db_role
97+
98+
99+
@router.delete("/{role_id}")
100+
def delete_role(role_id: int, session: Session = Depends(get_session)):
101+
db_role = session.get(Role, role_id)
102+
if not db_role:
103+
raise HTTPException(status_code=404, detail="Role not found")
104+
db_role.deleted = True
105+
db_role.updated_at = utc_time()
106+
session.add(db_role)
107+
session.commit()
108+
return {"message": "Role deleted successfully"}

utils/db.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import os
22
import logging
3+
from enum import Enum
34
from typing import Optional, List
45
from datetime import datetime, UTC
56
from dotenv import load_dotenv
67
from sqlalchemy.engine import URL
7-
from sqlmodel import create_engine, Session, SQLModel, Field, Relationship
8-
8+
from sqlmodel import create_engine, Session, SQLModel, Field, Relationship, select
9+
from sqlalchemy import Column, Enum as SQLAlchemyEnum
910

1011
load_dotenv()
1112

@@ -41,13 +42,71 @@ def get_session():
4142
yield session
4243

4344

45+
def set_up_db(drop: bool = False):
46+
engine = create_engine(get_connection_url())
47+
if drop:
48+
SQLModel.metadata.drop_all(engine)
49+
SQLModel.metadata.create_all(engine)
50+
with Session(engine) as session:
51+
roles_in_db = []
52+
# Create default roles
53+
for role_name in default_roles:
54+
db_role = session.exec(select(Role).where(
55+
Role.name == role_name)).first()
56+
if not db_role:
57+
db_role = Role(name=role_name)
58+
session.add(db_role)
59+
roles_in_db.append(db_role)
60+
else:
61+
roles_in_db.append(db_role)
62+
63+
session.commit() # Commit after adding roles
64+
65+
# Create default permissions
66+
for permission in [permission for permission in ValidPermissions]:
67+
db_permission = session.exec(select(Permission).where(
68+
Permission.name == permission)).first()
69+
if not db_permission:
70+
db_permission = Permission(name=permission)
71+
session.add(db_permission)
72+
73+
# Create RolePermissionLink for Owner and Administrator
74+
# Assuming first two roles are Owner and Administrator
75+
for role in roles_in_db[:2]:
76+
db_role_permission_link = session.exec(select(RolePermissionLink).where(
77+
RolePermissionLink.role_id == role.id,
78+
RolePermissionLink.permission_id == db_permission.id)).first()
79+
if not db_role_permission_link:
80+
if not (permission == ValidPermissions.DELETE_ORGANIZATION and role.name == "Administrator"):
81+
role_permission_link = RolePermissionLink(
82+
role_id=role.id, permission_id=db_permission.id)
83+
session.add(role_permission_link)
84+
85+
session.commit() # Commit after adding permissions and links
86+
engine.dispose()
87+
88+
4489
# --- Models ---
4590

4691

4792
def utc_time():
4893
return datetime.now(UTC)
4994

5095

96+
default_roles = ["Owner", "Administrator", "Member"]
97+
98+
99+
class ValidPermissions(Enum):
100+
DELETE_ORGANIZATION = "Delete Organization"
101+
EDIT_ORGANIZATION = "Edit Organization"
102+
INVITE_USER = "Invite User"
103+
REMOVE_USER = "Remove User"
104+
EDIT_USER_ROLE = "Edit User Role"
105+
CREATE_ROLE = "Create Role"
106+
DELETE_ROLE = "Delete Role"
107+
EDIT_ROLE = "Edit Role"
108+
109+
51110
class Organization(SQLModel, table=True):
52111
id: Optional[int] = Field(default=None, primary_key=True)
53112
name: str
@@ -58,6 +117,45 @@ class Organization(SQLModel, table=True):
58117
users: List["User"] = Relationship(back_populates="organization")
59118

60119

120+
class Role(SQLModel, table=True):
121+
id: Optional[int] = Field(default=None, primary_key=True)
122+
name: str
123+
organization_id: Optional[int] = Field(
124+
default=None, foreign_key="organization.id")
125+
created_at: datetime = Field(default_factory=utc_time)
126+
updated_at: datetime = Field(default_factory=utc_time)
127+
deleted: bool = Field(default=False)
128+
129+
users: List["User"] = Relationship(back_populates="role")
130+
role_permission_links: List["RolePermissionLink"] = Relationship(
131+
back_populates="role")
132+
133+
134+
class Permission(SQLModel, table=True):
135+
id: Optional[int] = Field(default=None, primary_key=True)
136+
name: ValidPermissions = Field(
137+
sa_column=Column(SQLAlchemyEnum(ValidPermissions)))
138+
created_at: datetime = Field(default_factory=utc_time)
139+
updated_at: datetime = Field(default_factory=utc_time)
140+
deleted: bool = Field(default=False)
141+
142+
role_permission_links: List["RolePermissionLink"] = Relationship(
143+
back_populates="permission")
144+
145+
146+
class RolePermissionLink(SQLModel, table=True):
147+
id: Optional[int] = Field(default=None, primary_key=True)
148+
role_id: Optional[int] = Field(
149+
default=None, foreign_key="role.id")
150+
permission_id: Optional[int] = Field(
151+
default=None, foreign_key="permission.id")
152+
153+
role: Optional["Role"] = Relationship(
154+
back_populates="role_permission_links")
155+
permission: Optional["Permission"] = Relationship(
156+
back_populates="role_permission_links")
157+
158+
61159
class User(SQLModel, table=True):
62160
id: Optional[int] = Field(default=None, primary_key=True)
63161
name: str
@@ -66,9 +164,19 @@ class User(SQLModel, table=True):
66164
avatar_url: Optional[str] = None
67165
organization_id: Optional[int] = Field(
68166
default=None, foreign_key="organization.id")
167+
role_id: Optional[int] = Field(default=None, foreign_key="role.id")
69168
created_at: datetime = Field(default_factory=utc_time)
70169
updated_at: datetime = Field(default_factory=utc_time)
71170
deleted: bool = Field(default=False)
72171

73172
organization: Optional["Organization"] = Relationship(
74173
back_populates="users")
174+
role: Optional["Role"] = Relationship(back_populates="users")
175+
176+
177+
class UserOrganizationLink(SQLModel, table=True):
178+
id: Optional[int] = Field(default=None, primary_key=True)
179+
user_id: Optional[int] = Field(
180+
default=None, foreign_key="user.id")
181+
organization_id: Optional[int] = Field(
182+
default=None, foreign_key="organization.id")

0 commit comments

Comments
 (0)