RBAC in the API

This commit is contained in:
James Pattinson
2025-10-23 20:02:54 +00:00
parent 91b734426c
commit fb21329109
7 changed files with 131 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.core.security import verify_token from app.core.security import verify_token
from app.crud.crud_user import user as crud_user from app.crud.crud_user import user as crud_user
from app.models.ppr import UserRole
security = HTTPBearer() security = HTTPBearer()
@@ -44,4 +45,29 @@ def get_current_active_user(
current_user = Depends(get_current_user), current_user = Depends(get_current_user),
): ):
"""Get current active user (for future use if we add user status)""" """Get current active user (for future use if we add user status)"""
return current_user
def get_current_admin_user(current_user = Depends(get_current_user)):
"""Get current user and ensure they are an administrator"""
if current_user.role != UserRole.ADMINISTRATOR:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
def get_current_operator_user(current_user = Depends(get_current_user)):
"""Get current user and ensure they are an operator or administrator"""
if current_user.role not in [UserRole.OPERATOR, UserRole.ADMINISTRATOR]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
def get_current_read_user(current_user = Depends(get_current_user)):
"""Get current user (read-only or higher)"""
return current_user return current_user

View File

@@ -1,12 +1,13 @@
from datetime import timedelta from datetime import timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db, get_current_admin_user, get_current_read_user
from app.core.config import settings from app.core.config import settings
from app.core.security import create_access_token from app.core.security import create_access_token
from app.crud.crud_user import user as crud_user from app.crud.crud_user import user as crud_user
from app.schemas.ppr import Token from app.schemas.ppr import Token, UserCreate, UserUpdate, User
router = APIRouter() router = APIRouter()
@@ -26,16 +27,63 @@ async def login_for_access_token(
detail="Incorrect username or password", detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token( access_token = create_access_token(
subject=user.username, expires_delta=access_token_expires subject=user.username, expires_delta=access_token_expires
) )
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/test-token") @router.post("/test-token", response_model=User)
async def test_token(current_user = Depends(get_db)): async def test_token(current_user = Depends(get_current_read_user)):
"""Test access token""" """Test access token"""
return current_user return current_user
@router.get("/users", response_model=List[User])
async def list_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user = Depends(get_current_admin_user)
):
"""List all users (admin only)"""
users = crud_user.get_multi(db, skip=skip, limit=limit)
return users
@router.post("/users", response_model=User)
async def create_user(
user_in: UserCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_admin_user)
):
"""Create a new user (admin only)"""
user = crud_user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
user = crud_user.create(db, obj_in=user_in)
return user
@router.put("/users/{user_id}", response_model=User)
async def update_user(
user_id: int,
user_in: UserUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_admin_user)
):
"""Update a user (admin only)"""
user = crud_user.get(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud_user.update(db, db_obj=user, obj_in=user_in)
return user

View File

@@ -2,7 +2,7 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import date from datetime import date
from app.api.deps import get_db, get_current_active_user from app.api.deps import get_db, get_current_read_user, get_current_operator_user
from app.crud.crud_ppr import ppr as crud_ppr from app.crud.crud_ppr import ppr as crud_ppr
from app.crud.crud_journal import journal as crud_journal from app.crud.crud_journal import journal as crud_journal
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
@@ -21,7 +21,7 @@ async def get_pprs(
date_from: Optional[date] = None, date_from: Optional[date] = None,
date_to: Optional[date] = None, date_to: Optional[date] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_read_user)
): ):
"""Get PPR records with optional filtering""" """Get PPR records with optional filtering"""
pprs = crud_ppr.get_multi( pprs = crud_ppr.get_multi(
@@ -36,7 +36,7 @@ async def create_ppr(
request: Request, request: Request,
ppr_in: PPRCreate, ppr_in: PPRCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Create a new PPR record""" """Create a new PPR record"""
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
@@ -60,7 +60,7 @@ async def create_ppr(
async def get_ppr( async def get_ppr(
ppr_id: int, ppr_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_read_user)
): ):
"""Get a specific PPR record""" """Get a specific PPR record"""
ppr = crud_ppr.get(db, ppr_id=ppr_id) ppr = crud_ppr.get(db, ppr_id=ppr_id)
@@ -78,7 +78,7 @@ async def update_ppr(
ppr_id: int, ppr_id: int,
ppr_in: PPRUpdate, ppr_in: PPRUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Update a PPR record""" """Update a PPR record"""
db_ppr = crud_ppr.get(db, ppr_id=ppr_id) db_ppr = crud_ppr.get(db, ppr_id=ppr_id)
@@ -111,7 +111,7 @@ async def patch_ppr(
ppr_id: int, ppr_id: int,
ppr_in: PPRUpdate, ppr_in: PPRUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Partially update a PPR record (only provided fields will be updated)""" """Partially update a PPR record (only provided fields will be updated)"""
db_ppr = crud_ppr.get(db, ppr_id=ppr_id) db_ppr = crud_ppr.get(db, ppr_id=ppr_id)
@@ -144,7 +144,7 @@ async def update_ppr_status(
ppr_id: int, ppr_id: int,
status_update: PPRStatusUpdate, status_update: PPRStatusUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Update PPR status (LANDED, DEPARTED, etc.)""" """Update PPR status (LANDED, DEPARTED, etc.)"""
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
@@ -182,7 +182,7 @@ async def delete_ppr(
request: Request, request: Request,
ppr_id: int, ppr_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_operator_user)
): ):
"""Delete (soft delete) a PPR record""" """Delete (soft delete) a PPR record"""
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
@@ -210,7 +210,7 @@ async def delete_ppr(
async def get_ppr_journal( async def get_ppr_journal(
ppr_id: int, ppr_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_read_user)
): ):
"""Get journal entries for a specific PPR""" """Get journal entries for a specific PPR"""
# Verify PPR exists # Verify PPR exists

View File

@@ -1,7 +1,7 @@
from typing import Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ppr import User from app.models.ppr import User
from app.schemas.ppr import UserCreate from app.schemas.ppr import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
@@ -12,17 +12,32 @@ class CRUDUser:
def get_by_username(self, db: Session, username: str) -> Optional[User]: def get_by_username(self, db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first() return db.query(User).filter(User.username == username).first()
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
return db.query(User).offset(skip).limit(limit).all()
def create(self, db: Session, obj_in: UserCreate) -> User: def create(self, db: Session, obj_in: UserCreate) -> User:
hashed_password = get_password_hash(obj_in.password) hashed_password = get_password_hash(obj_in.password)
db_obj = User( db_obj = User(
username=obj_in.username, username=obj_in.username,
password=hashed_password password=hashed_password,
role=obj_in.role
) )
db.add(db_obj) db.add(db_obj)
db.commit() db.commit()
db.refresh(db_obj) db.refresh(db_obj)
return db_obj return db_obj
def update(self, db: Session, db_obj: User, obj_in: UserUpdate) -> User:
update_data = obj_in.dict(exclude_unset=True)
if "password" in update_data:
update_data["password"] = get_password_hash(update_data["password"])
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]: def authenticate(self, db: Session, username: str, password: str) -> Optional[User]:
user = self.get_by_username(db, username=username) user = self.get_by_username(db, username=username)
if not user: if not user:

View File

@@ -13,6 +13,12 @@ class PPRStatus(str, Enum):
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
class UserRole(str, Enum):
ADMINISTRATOR = "administrator"
OPERATOR = "operator"
READ_ONLY = "read_only"
class PPRRecord(Base): class PPRRecord(Base):
__tablename__ = "submitted" __tablename__ = "submitted"
@@ -44,6 +50,7 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), nullable=False, unique=True, index=True) username = Column(String(50), nullable=False, unique=True, index=True)
password = Column(String(255), nullable=False) password = Column(String(255), nullable=False)
role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.READ_ONLY)
class Journal(Base): class Journal(Base):

View File

@@ -13,6 +13,12 @@ class PPRStatus(str, Enum):
DEPARTED = "DEPARTED" DEPARTED = "DEPARTED"
class UserRole(str, Enum):
ADMINISTRATOR = "administrator"
OPERATOR = "operator"
READ_ONLY = "read_only"
class PPRBase(BaseModel): class PPRBase(BaseModel):
ac_reg: str ac_reg: str
ac_type: str ac_type: str
@@ -97,12 +103,19 @@ class PPRInDB(PPRInDBBase):
# User schemas # User schemas
class UserBase(BaseModel): class UserBase(BaseModel):
username: str username: str
role: UserRole = UserRole.READ_ONLY
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
class UserUpdate(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
role: Optional[UserRole] = None
class UserInDBBase(UserBase): class UserInDBBase(UserBase):
id: int id: int

View File

@@ -9,6 +9,7 @@ CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
role ENUM('ADMINISTRATOR','OPERATOR','READ_ONLY') NOT NULL DEFAULT 'READ_ONLY',
email VARCHAR(128), email VARCHAR(128),
full_name VARCHAR(100), full_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
@@ -108,8 +109,8 @@ CREATE TABLE aircraft (
-- Insert default admin user (password: admin123) -- Insert default admin user (password: admin123)
-- Password hash for 'admin123' using bcrypt -- Password hash for 'admin123' using bcrypt
INSERT INTO users (username, password, email, full_name) VALUES INSERT INTO users (username, password, role, email, full_name) VALUES
('admin', '$2b$12$BJOha2yRxkxuHL./BaMfpu2fMDgGMYISuRV2.B1sSklVpRjz3Y4a6', 'admin@ppr.local', 'System Administrator'); ('admin', '$2b$12$BJOha2yRxkxuHL./BaMfpu2fMDgGMYISuRV2.B1sSklVpRjz3Y4a6', 'ADMINISTRATOR', 'admin@ppr.local', 'System Administrator');
-- Create a view for active PPRs -- Create a view for active PPRs
CREATE VIEW active_pprs AS CREATE VIEW active_pprs AS