diff --git a/app/api/deps.py b/app/api/deps.py index bf70393..9f6983d 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -59,22 +59,43 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De return user +def _lookup_api_key(db: Session, key: str) -> models.User | None: + """Resolve an API key string to a User; mark last_used_at on hit.""" + if not key: + return None + key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712 + if not key_obj: + return None + key_obj.last_used_at = datetime.utcnow() + db.commit() + return db.query(models.User).filter(models.User.id == key_obj.user_id).first() + + async def get_current_user_or_apikey( token: str = Depends(oauth2_scheme), api_key: str = Depends(apikey_header), db: Session = Depends(get_db) ): - """Authenticate via JWT token OR API key.""" + """Authenticate via JWT token (Authorization: Bearer ) OR API key + (X-API-Key: , OR — as a convenience for CLI clients that only know + Bearer — Authorization: Bearer ; falls back when JWT decode fails). + """ + # Native X-API-Key header if api_key: - key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first() - if key_obj: - key_obj.last_used_at = datetime.utcnow() - db.commit() - user = db.query(models.User).filter(models.User.id == key_obj.user_id).first() + user = _lookup_api_key(db, api_key) + if user: + return user + + # Bearer header — try JWT first, then API key on decode failure + if token: + try: + return await get_current_user(token=token, db=db) + except HTTPException: + user = _lookup_api_key(db, token) if user: return user - if token: - return await get_current_user(token=token, db=db) + raise + raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 24b9097..372d88b 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -11,7 +11,7 @@ from app.core.config import get_db, settings from app.models import models from app.models.role_permission import Permission, Role, RolePermission from app.schemas import schemas -from app.api.deps import Token, verify_password, create_access_token, get_current_user +from app.api.deps import Token, verify_password, create_access_token, get_current_user, get_current_user_or_apikey router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -80,7 +80,7 @@ class PermissionIntrospectionResponse(BaseModel): @router.get("/me/permissions", response_model=PermissionIntrospectionResponse) async def get_my_permissions( - current_user: models.User = Depends(get_current_user), + current_user: models.User = Depends(get_current_user_or_apikey), db: Session = Depends(get_db), ): """Return the current user's effective permissions for CLI help introspection.""" diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 8469939..8a371b0 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -153,9 +153,27 @@ def _generate_project_code(db, name: str) -> str: @router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - # Check if user is admin + # Project creation is gated by the `project.create` global permission + # (admin auto-grants by virtue of is_admin). Any role granted that perm + # via the Role Editor can create projects. if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Only admins can create projects") + from app.models.role_permission import Permission, RolePermission + has = ( + db.query(Permission.id) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .filter( + RolePermission.role_id == current_user.role_id, + Permission.name == "project.create", + ) + .first() + if current_user.role_id + else None + ) + if not has: + raise HTTPException( + status_code=403, + detail="Permission denied: project.create required", + ) # Auto-fill owner_name from owner_id user = db.query(models.User).filter(models.User.id == project.owner_id).first() if not user: diff --git a/app/init_bootstrap.py b/app/init_bootstrap.py index 3601bce..f1ddcf0 100644 --- a/app/init_bootstrap.py +++ b/app/init_bootstrap.py @@ -36,6 +36,7 @@ DEFAULT_PERMISSIONS = [ # Project permissions ("project.read", "View project", "project"), ("project.write", "Edit project", "project"), + ("project.create", "Create a project", "project"), ("project.delete", "Delete project", "project"), ("project.manage_members", "Manage project members", "project"), # Task/Milestone permissions @@ -104,7 +105,7 @@ def init_default_permissions(db: Session) -> list[Permission]: # Default roles + permission set per role # --------------------------------------------------------------------------- _MGR_PERMISSIONS = { - "project.read", "project.write", "project.manage_members", + "project.read", "project.write", "project.create", "project.manage_members", "task.create", "task.read", "task.write", "task.delete", "milestone.create", "milestone.read", "milestone.write", "milestone.delete", "milestone.freeze", "milestone.start", "milestone.close",