fix(projects): perm-gate create + apikey-via-Bearer + introspect with apikey
Three coupled fixes so non-admin agents (e.g. nav, role=mgr) can actually create projects through hf-cli with their API key: 1. POST /projects no longer hardcodes is_admin. It checks the global `project.create` perm via role_permissions (admin still wins via is_admin short-circuit). Permission-denied 403 message names the exact perm. 2. /auth/me/permissions now uses get_current_user_or_apikey (was get_current_user JWT-only). This is what hf-cli hits to populate its local permission cache that drives the "not permitted" gate; previously every API-key-authed agent saw all commands as gated. 3. get_current_user_or_apikey now also accepts an API key delivered via Authorization: Bearer (in addition to X-API-Key). hf-cli only knows Bearer; trying to JWT-decode an API key string would fail — so on decode failure, fall through to the API key lookup. Keeps X-API-Key behavior unchanged. 4. init_bootstrap: add `project.create` to DEFAULT_PERMISSIONS and to _MGR_PERMISSIONS so admin (auto-all) + mgr both get it on seed. Bug came to light when manager-agent reported `hf project list`/`create` returned `not permitted`. Root cause: hf-cli calls /auth/me/permissions with the API key via Bearer header → 401 → state.Known=false → every command in the surface is gated false locally. Even after the local gate, POST /projects would still 403 due to the hardcoded admin check. All four steps above are required end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,22 +59,43 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
|||||||
return user
|
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(
|
async def get_current_user_or_apikey(
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
api_key: str = Depends(apikey_header),
|
api_key: str = Depends(apikey_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Authenticate via JWT token OR API key."""
|
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
||||||
|
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
||||||
|
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
||||||
|
"""
|
||||||
|
# Native X-API-Key header
|
||||||
if api_key:
|
if api_key:
|
||||||
key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
|
user = _lookup_api_key(db, api_key)
|
||||||
if key_obj:
|
if user:
|
||||||
key_obj.last_used_at = datetime.utcnow()
|
return user
|
||||||
db.commit()
|
|
||||||
user = db.query(models.User).filter(models.User.id == key_obj.user_id).first()
|
# 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:
|
if user:
|
||||||
return user
|
return user
|
||||||
if token:
|
raise
|
||||||
return await get_current_user(token=token, db=db)
|
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.core.config import get_db, settings
|
|||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
from app.schemas import schemas
|
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"])
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class PermissionIntrospectionResponse(BaseModel):
|
|||||||
|
|
||||||
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
||||||
async def get_my_permissions(
|
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),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return the current user's effective permissions for CLI help introspection."""
|
"""Return the current user's effective permissions for CLI help introspection."""
|
||||||
|
|||||||
@@ -153,9 +153,27 @@ def _generate_project_code(db, name: str) -> str:
|
|||||||
|
|
||||||
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
|
@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)):
|
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:
|
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
|
# Auto-fill owner_name from owner_id
|
||||||
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ DEFAULT_PERMISSIONS = [
|
|||||||
# Project permissions
|
# Project permissions
|
||||||
("project.read", "View project", "project"),
|
("project.read", "View project", "project"),
|
||||||
("project.write", "Edit project", "project"),
|
("project.write", "Edit project", "project"),
|
||||||
|
("project.create", "Create a project", "project"),
|
||||||
("project.delete", "Delete project", "project"),
|
("project.delete", "Delete project", "project"),
|
||||||
("project.manage_members", "Manage project members", "project"),
|
("project.manage_members", "Manage project members", "project"),
|
||||||
# Task/Milestone permissions
|
# Task/Milestone permissions
|
||||||
@@ -104,7 +105,7 @@ def init_default_permissions(db: Session) -> list[Permission]:
|
|||||||
# Default roles + permission set per role
|
# Default roles + permission set per role
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_MGR_PERMISSIONS = {
|
_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",
|
"task.create", "task.read", "task.write", "task.delete",
|
||||||
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
|
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
|
||||||
"milestone.freeze", "milestone.start", "milestone.close",
|
"milestone.freeze", "milestone.start", "milestone.close",
|
||||||
|
|||||||
Reference in New Issue
Block a user