Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Add organizations, memberships and item.org_id

Revision ID: fe12b3c4a567
Revises: 1a31ce608336
Create Date: 2025-10-23 00:00:00

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


# revision identifiers, used by Alembic.
revision = 'fe12b3c4a567'
down_revision = '1a31ce608336'
branch_labels = None
depends_on = None


def upgrade():
# Ensure uuid extension
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')

# Create organization table
op.create_table(
'organization',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, nullable=False, server_default=sa.text('uuid_generate_v4()')),
sa.Column('name', sa.String(length=255), nullable=False),
)

# Create membership table
op.create_table(
'membership',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, nullable=False, server_default=sa.text('uuid_generate_v4()')),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False, server_default='member'),
sa.Column('accepted', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'),
)

# Add org_id to item table
op.add_column('item', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key(None, 'item', 'organization', ['org_id'], ['id'], ondelete='CASCADE')

# Backfill org_id for existing items by creating a default org for each owner
# For simplicity, create a single org per user and assign existing items to it
op.execute("""
DO $$
DECLARE
u RECORD;
new_org UUID;
BEGIN
FOR u IN SELECT id FROM "user" LOOP
new_org := uuid_generate_v4();
INSERT INTO organization (id, name) VALUES (new_org, 'Default Org ' || substr(u.id::text, 1, 8));
INSERT INTO membership (user_id, org_id, role, accepted) VALUES (u.id, new_org, 'admin', true);
UPDATE item SET org_id = new_org WHERE owner_id = u.id;
END LOOP;
END$$;
""")

# Make org_id not null now that it's backfilled
op.alter_column('item', 'org_id', nullable=False)


def downgrade():
op.drop_constraint(None, 'item', type_='foreignkey')
op.drop_column('item', 'org_id')
op.drop_table('membership')
op.drop_table('organization')
37 changes: 30 additions & 7 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from collections.abc import Generator
from typing import Annotated
from typing import Annotated, Tuple
import uuid

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session
from sqlmodel import Session, select

from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.models import TokenPayload, User
from app.models import Membership, Role, TokenPayload, User

reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)


def get_db() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
Expand All @@ -26,8 +26,7 @@ def get_db() -> Generator[Session, None, None]:
SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]


def get_current_user(session: SessionDep, token: TokenDep) -> User:
def get_token_payload(token: TokenDep) -> TokenPayload:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
Expand All @@ -38,20 +37,44 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
return token_data

def get_current_user(session: SessionDep, token: TokenDep) -> User:
token_data = get_token_payload(token)
user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user

def get_active_org_id(token: TokenDep) -> str | None:
token_data = get_token_payload(token)
return token_data.active_org_id

CurrentUser = Annotated[User, Depends(get_current_user)]
def require_org_member(session: SessionDep, current_user: User, token: TokenDep) -> Tuple[str, Membership]:
org_id = get_active_org_id(token)
if not org_id:
raise HTTPException(status_code=400, detail="No active organization selected")
statement = select(Membership).where(
(Membership.user_id == current_user.id) & (Membership.org_id == uuid.UUID(org_id))
)
membership = session.exec(statement).first()
if not membership or not membership.accepted:
raise HTTPException(status_code=403, detail="User is not a member of the active organization")
return org_id, membership

CurrentUser = Annotated[User, Depends(get_current_user)]

def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

def require_admin(session: SessionDep, current_user: CurrentUser, token: TokenDep) -> Tuple[str, Membership]:
org_id, membership = require_org_member(session, current_user, token)
if membership.role != Role.admin:
raise HTTPException(status_code=403, detail="Only organization admins can perform this action")
return org_id, membership
2 changes: 2 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from fastapi import APIRouter

from app.api.routes import items, login, private, users, utils
from app.api.routes import orgs
from app.core.config import settings

api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
api_router.include_router(orgs.router)


if settings.ENVIRONMENT == "local":
Expand Down
82 changes: 48 additions & 34 deletions backend/app/api/routes/items.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,57 @@
import uuid
from typing import Any

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from sqlmodel import func, select

from app.api.deps import CurrentUser, SessionDep
from app.api.deps import CurrentUser, SessionDep, TokenDep, require_org_member
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message

router = APIRouter(prefix="/items", tags=["items"])


@router.get("/", response_model=ItemsPublic)
def read_items(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
session: SessionDep,
current_user: CurrentUser,
token: TokenDep,
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve items.
Retrieve items scoped by active organization.
"""
org_id, _ = require_org_member(session, current_user, token)

if current_user.is_superuser:
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
statement = select(Item).offset(skip).limit(limit)
items = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Item)
.where(Item.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Item)
.where(Item.owner_id == current_user.id)
.offset(skip)
.limit(limit)
)
items = session.exec(statement).all()
count_statement = (
select(func.count()).select_from(Item).where(Item.org_id == uuid.UUID(org_id))
)
count = session.exec(count_statement).one()
statement = (
select(Item)
.where(Item.org_id == uuid.UUID(org_id))
.offset(skip)
.limit(limit)
)

# Non-admins can only see their own items in the org
items = session.exec(statement).all()
if not current_user.is_superuser:
items = [item for item in items if item.owner_id == current_user.id]

return ItemsPublic(data=items, count=count)


@router.get("/{id}", response_model=ItemPublic)
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
def read_item(
session: SessionDep, current_user: CurrentUser, token: TokenDep, id: uuid.UUID
) -> Any:
"""
Get item by ID.
Get item by ID scoped by active organization.
"""
org_id, _ = require_org_member(session, current_user, token)
item = session.get(Item, id)
if not item:
if not item or str(item.org_id) != org_id:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
Expand All @@ -56,12 +60,19 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) ->

@router.post("/", response_model=ItemPublic)
def create_item(
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
*,
session: SessionDep,
current_user: CurrentUser,
token: TokenDep,
item_in: ItemCreate,
) -> Any:
"""
Create new item.
Create new item in active organization.
"""
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
org_id, _ = require_org_member(session, current_user, token)
item = Item.model_validate(
item_in, update={"owner_id": current_user.id, "org_id": uuid.UUID(org_id)}
)
session.add(item)
session.commit()
session.refresh(item)
Expand All @@ -73,14 +84,16 @@ def update_item(
*,
session: SessionDep,
current_user: CurrentUser,
token: TokenDep,
id: uuid.UUID,
item_in: ItemUpdate,
) -> Any:
"""
Update an item.
Update an item in active organization.
"""
org_id, _ = require_org_member(session, current_user, token)
item = session.get(Item, id)
if not item:
if not item or str(item.org_id) != org_id:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
Expand All @@ -94,13 +107,14 @@ def update_item(

@router.delete("/{id}")
def delete_item(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
session: SessionDep, current_user: CurrentUser, token: TokenDep, id: uuid.UUID
) -> Message:
"""
Delete an item.
Delete an item in active organization.
"""
org_id, _ = require_org_member(session, current_user, token)
item = session.get(Item, id)
if not item:
if not item or str(item.org_id) != org_id:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
Expand Down
10 changes: 8 additions & 2 deletions backend/app/api/routes/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select

from app import crud
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from app.core import security
from app.core.config import settings
from app.core.security import get_password_hash
from app.models import Message, NewPassword, Token, UserPublic
from app.models import Membership, Message, NewPassword, Token, UserPublic
from app.utils import (
generate_password_reset_token,
generate_reset_password_email,
Expand All @@ -36,9 +37,14 @@ def login_access_token(
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
# pick first accepted membership as default active org (if any)
m = session.exec(
select(Membership).where((Membership.user_id == user.id) & (Membership.accepted == True)) # noqa: E712
).first()
active_org_id = str(m.org_id) if m else None
return Token(
access_token=security.create_access_token(
user.id, expires_delta=access_token_expires
user.id, expires_delta=access_token_expires, active_org_id=active_org_id
)
)

Expand Down
Loading