Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,4 @@ src/.env
# Don't ignore files inside of script folder:
!scripts/*

crudadmin_data/*
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ dependencies = [
"pydantic[email]>=2.6.1",
"fastapi>=0.109.1",
"uvicorn>=0.27.0",
"uvloop>=0.19.0",
"httptools>=0.6.1",
"uuid>=1.30",
"uuid6>=2024.1.12",
Expand All @@ -31,8 +30,9 @@ dependencies = [
"fastcrud>=0.19.2",
"crudadmin>=0.4.2",
"gunicorn>=23.0.0",
"ruff>=0.11.13",
"mypy>=1.16.0",
"fastapi-authz>=1.0.0",
"casbin>=1.43.0",
"casbin-async-sqlalchemy-adapter>=1.16.1",
]

[project.optional-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions scripts/local_with_uvicorn/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ POSTGRES_PORT=5432
POSTGRES_DB="postgres"
POSTGRES_ASYNC_PREFIX="postgresql+asyncpg://"

REDIS_ENABLED=False

# ------------- crypt -------------
SECRET_KEY=de2132a4a3a029d6a93a2aefcb519f0219990f92ca258a7c5ed938a444dbe1c8
ALGORITHM=HS256
Expand Down
9 changes: 7 additions & 2 deletions src/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..crud.crud_rate_limit import crud_rate_limits
from ..crud.crud_tier import crud_tiers
from ..crud.crud_users import crud_users
from ..middleware.authentication import AuthenticatedUser
from ..schemas.rate_limit import RateLimitRead, sanitize_path
from ..schemas.tier import TierRead

Expand All @@ -22,8 +23,12 @@


async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(async_get_db)]
request: Request, token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(async_get_db)]
) -> dict[str, Any]:
# Optimization: Check if user is already authenticated by middleware
if hasattr(request, "user") and isinstance(request.user, AuthenticatedUser):
return request.user.extra_data

token_data = await verify_token(token, TokenType.ACCESS, db)
if token_data is None:
raise UnauthorizedException("User not authenticated.")
Expand Down Expand Up @@ -53,7 +58,7 @@ async def get_optional_user(request: Request, db: AsyncSession = Depends(async_g
if token_data is None:
return None

return await get_current_user(token_value, db=db)
return await get_current_user(request, token=token_value, db=db)

except HTTPException as http_exc:
if http_exc.status_code != 401:
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from .health import router as health_router
from .login import router as login_router
from .logout import router as logout_router
from .permissions import router as permissions_router
from .posts import router as posts_router
from .rate_limits import router as rate_limits_router
from .roles import router as roles_router
from .tasks import router as tasks_router
from .tiers import router as tiers_router
from .users import router as users_router
Expand All @@ -18,3 +20,5 @@
router.include_router(tasks_router)
router.include_router(tiers_router)
router.include_router(rate_limits_router)
router.include_router(permissions_router)
router.include_router(roles_router)
22 changes: 17 additions & 5 deletions src/app/api/v1/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,25 @@ async def health():


@router.get("/ready", response_model=ReadyCheck)
async def ready(redis: Annotated[Redis, Depends(async_get_redis)], db: Annotated[AsyncSession, Depends(async_get_db)]):
async def ready(
db: Annotated[AsyncSession, Depends(async_get_db)],
redis: Annotated[Redis, Depends(async_get_redis)] = None,
):
database_status = await check_database_health(db=db)
LOGGER.debug(f"Database health check status: {database_status}")
redis_status = await check_redis_health(redis=redis)
LOGGER.debug(f"Redis health check status: {redis_status}")

overall_status = STATUS_HEALTHY if database_status and redis_status else STATUS_UNHEALTHY
redis_status = STATUS_UNHEALTHY
if settings.REDIS_ENABLED:
redis_status = await check_redis_health(redis=redis) if redis else STATUS_UNHEALTHY
LOGGER.debug(f"Redis health check status: {redis_status}")
else:
redis_status = "disabled"

overall_status = (
STATUS_HEALTHY
if database_status and (redis_status == STATUS_HEALTHY or not settings.REDIS_ENABLED)
else STATUS_UNHEALTHY
)
http_status = status.HTTP_200_OK if overall_status == STATUS_HEALTHY else status.HTTP_503_SERVICE_UNAVAILABLE

response = {
Expand All @@ -50,7 +62,7 @@ async def ready(redis: Annotated[Redis, Depends(async_get_redis)], db: Annotated
"version": settings.APP_VERSION,
"app": STATUS_HEALTHY,
"database": STATUS_HEALTHY if database_status else STATUS_UNHEALTHY,
"redis": STATUS_HEALTHY if redis_status else STATUS_UNHEALTHY,
"redis": redis_status,
"timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
}

Expand Down
31 changes: 31 additions & 0 deletions src/app/api/v1/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Any

from fastapi import APIRouter, Depends, Request

from ...api.dependencies import get_current_superuser

router = APIRouter(tags=["permissions"])


@router.get("/permissions", dependencies=[Depends(get_current_superuser)])
async def get_permission_tree(request: Request) -> list[dict[str, Any]]:
"""
Get the permission tree of the system.
Returns a list of all available routes and their methods.
"""
routes = []
# Iterate over all routes in the application
for route in request.app.routes:
if hasattr(route, "path") and hasattr(route, "methods"):
methods = [m for m in route.methods if m not in ("HEAD", "OPTIONS")]
if methods:
routes.append({
"path": route.path,
"methods": methods,
"name": route.name
})

# Sort by path
routes.sort(key=lambda x: x["path"])

return routes
115 changes: 115 additions & 0 deletions src/app/api/v1/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from fastapi import APIRouter, Depends, HTTPException

from ...api.dependencies import get_current_superuser
from ...core.authz.casbin import enforcer
from ...schemas.role import Permission, RoleCreate, RoleRead, RoleUpdate

router = APIRouter(tags=["roles"])

def ensure_role_prefix(name: str) -> str:
return name if name.startswith("role:") else f"role:{name}"

RESERVED_ROLES = {"role:superuser", "role:anonymous"}

@router.get("/roles", response_model=list[RoleRead], dependencies=[Depends(get_current_superuser)])
async def get_roles():
"""
Get all roles and their permissions.
"""
if not enforcer:
raise HTTPException(status_code=503, detail="Authorization service not available")

# Collect all unique roles from policies (p) and groupings (g)
roles = set()

# From 'p' policies (subjects)
all_subjects = enforcer.get_all_subjects()
for sub in all_subjects:
if sub.startswith("role:"):
roles.add(sub)

# From 'g' policies (roles)
# get_filtered_grouping_policy(0) returns all 'g' rules
# g rules are [user, role]
grouping_policies = enforcer.get_filtered_grouping_policy(0)
for rule in grouping_policies:
if len(rule) > 1 and rule[1].startswith("role:"):
roles.add(rule[1])

result = []
for role in roles:
# Get permissions for this role
perms = []
# get_filtered_policy(field_index, field_value) -> returns rules where sub == role
policy_rules = enforcer.get_filtered_policy(0, role)
for rule in policy_rules:
# rule: [sub, obj, act]
if len(rule) >= 3:
perms.append(Permission(resource=rule[1], action=rule[2]))

result.append(RoleRead(name=role, permissions=perms))

return result

@router.post("/roles", dependencies=[Depends(get_current_superuser)])
async def create_role(role: RoleCreate):
"""
Create a new role with permissions.
"""
if not enforcer:
raise HTTPException(status_code=503, detail="Authorization service not available")

role_name = ensure_role_prefix(role.name)

# Add permissions
for perm in role.permissions:
await enforcer.add_policy(role_name, perm.resource, perm.action)

return {"message": "Role created successfully"}

@router.put("/roles/{role_name}", dependencies=[Depends(get_current_superuser)])
async def update_role(role_name: str, role_data: RoleUpdate):
"""
Update a role's permissions (Overwrites existing permissions).
"""
if not enforcer:
raise HTTPException(status_code=503, detail="Authorization service not available")

full_role_name = ensure_role_prefix(role_name)
if full_role_name in RESERVED_ROLES:
raise HTTPException(status_code=400, detail="Built-in role cannot be modified")

# 1. Remove all existing permissions for this role
# field_index 0 is 'sub'
await enforcer.remove_filtered_policy(0, full_role_name)

# 2. Add new permissions
for perm in role_data.permissions:
await enforcer.add_policy(full_role_name, perm.resource, perm.action)

return {"message": "Role updated successfully"}

@router.delete("/roles/{role_name}", dependencies=[Depends(get_current_superuser)])
async def delete_role(role_name: str):
"""
Delete a role. Fails if the role is assigned to any user.
"""
if not enforcer:
raise HTTPException(status_code=503, detail="Authorization service not available")

full_role_name = ensure_role_prefix(role_name)
if full_role_name in RESERVED_ROLES:
raise HTTPException(status_code=400, detail="Built-in role cannot be deleted")

# Check if any user has this role
users = enforcer.get_users_for_role(full_role_name)
if users:
raise HTTPException(status_code=400, detail=f"Role is assigned to {len(users)} users. Cannot delete.")

# Delete permissions (p policies)
await enforcer.remove_filtered_policy(0, full_role_name)
# Delete groupings (g policies) where role is the group
# g: [user, role] -> role is at index 1
await enforcer.remove_filtered_grouping_policy(1, full_role_name)

return {"message": "Role deleted successfully"}
34 changes: 33 additions & 1 deletion src/app/api/v1/users.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Body, Depends, Request
from fastcrud import PaginatedListResponse, compute_offset, paginated_response
from sqlalchemy.ext.asyncio import AsyncSession

from ...api.dependencies import get_current_superuser, get_current_user
from ...core.authz.casbin import enforcer
from ...core.db.database import async_get_db
from ...core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException
from ...core.security import blacklist_token, get_password_hash, oauth2_scheme
Expand Down Expand Up @@ -201,3 +202,34 @@ async def patch_user_tier(

await crud_users.update(db=db, object=values.model_dump(), username=username)
return {"message": f"User {db_user['name']} Tier updated"}


@router.post("/user/{id}/roles", dependencies=[Depends(get_current_superuser)])
async def assign_user_roles(id: str, roles: list[str] = Body(embed=True)) -> dict[str, str]:
if not enforcer:
raise NotFoundException("Authorization service not available")

# 1. Remove existing roles for this user
# remove_filtered_grouping_policy(field_index, field_value)
# field_index 0 is the user (g rule: user, role)
await enforcer.remove_filtered_grouping_policy(0, id)

# 2. Add new roles
for role in roles:
role_name = role if role.startswith("role:") else f"role:{role}"
await enforcer.add_grouping_policy(id, role_name)

return {"message": "Roles assigned successfully"}


@router.get("/user/{id}/roles", dependencies=[Depends(get_current_superuser)])
async def get_user_roles(id: str) -> dict[str, list[str]]:
if not enforcer:
raise NotFoundException("Authorization service not available")

# get_filtered_grouping_policy(0, id) returns list of [user, role]
# This reads from memory, so it is synchronous
grouping_policies = enforcer.get_filtered_grouping_policy(0, id)
roles = [rule[1] for rule in grouping_policies if len(rule) > 1]

return {"roles": roles}
31 changes: 31 additions & 0 deletions src/app/core/authz/casbin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os

import casbin
import casbin_async_sqlalchemy_adapter

from ..db.database import DATABASE_URL

adapter = casbin_async_sqlalchemy_adapter.Adapter(DATABASE_URL)
model_path = os.path.join(os.path.dirname(__file__), "model.conf")
enforcer: casbin.AsyncEnforcer = casbin.AsyncEnforcer(model_path, adapter)


async def _seed_default_policies() -> None:
await enforcer.add_grouping_policy("anonymous", "role:anonymous")
await enforcer.add_policy("role:anonymous", "/api/v1/health", "GET")
await enforcer.add_policy("role:anonymous", "/api/v1/ready", "GET")
await enforcer.add_policy("role:anonymous", "/api/v1/login", "POST")
await enforcer.add_policy("role:anonymous", "/api/v1/refresh", "POST")
await enforcer.add_policy("role:anonymous", "/docs", "GET")
await enforcer.add_policy("role:anonymous", "/redoc", "GET")
await enforcer.add_policy("role:anonymous", "/openapi.json", "GET")
await enforcer.add_policy("role:superuser", "/*", "GET|POST|PUT|PATCH|DELETE")


async def initialize_enforcer() -> casbin.AsyncEnforcer:
if hasattr(adapter, "create_table"):
await adapter.create_table()
await enforcer.load_policy()
enforcer.enable_auto_save(True)
await _seed_default_policies()
return enforcer
14 changes: 14 additions & 0 deletions src/app/core/authz/model.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)
3 changes: 3 additions & 0 deletions src/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class TestSettings(BaseSettings):
class RedisCacheSettings(BaseSettings):
REDIS_CACHE_HOST: str = "localhost"
REDIS_CACHE_PORT: int = 6379
REDIS_ENABLED: bool = True

@computed_field # type: ignore[prop-decorator]
@property
Expand All @@ -95,11 +96,13 @@ class ClientSideCacheSettings(BaseSettings):
class RedisQueueSettings(BaseSettings):
REDIS_QUEUE_HOST: str = "localhost"
REDIS_QUEUE_PORT: int = 6379
REDIS_ENABLED: bool = True


class RedisRateLimiterSettings(BaseSettings):
REDIS_RATE_LIMIT_HOST: str = "localhost"
REDIS_RATE_LIMIT_PORT: int = 6379
REDIS_ENABLED: bool = True

@computed_field # type: ignore[prop-decorator]
@property
Expand Down
Loading