Compare commits
4 Commits
54feb9686c
...
fix/users-
| Author | SHA1 | Date | |
|---|---|---|---|
| cacb1d2652 | |||
| d2b83ad58d | |||
| 01f6b562e1 | |||
| 595391b41b |
@@ -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 <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:
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -39,7 +39,11 @@ def _user_response(user: models.User) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user)):
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
# Accept either OAuth2 JWT or X-API-Key (incl. Bearer-as-apikey fallback)
|
||||
# so CLI clients using their provisioned api-key can hit admin-gated user
|
||||
# routes (list / get / update / patch). The admin gate still reads
|
||||
# User.is_admin — only the auth carrier broadens.
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
return current_user
|
||||
@@ -68,11 +72,29 @@ def require_account_creator(
|
||||
raise HTTPException(status_code=403, detail="Account creation permission required")
|
||||
|
||||
|
||||
def _resolve_user_role(db: Session, role_id: int | None) -> Role:
|
||||
def _resolve_user_role(db: Session, role_id: int | None, *, is_agent: bool = False) -> Role:
|
||||
"""Resolve target role for user creation.
|
||||
|
||||
Default policy when caller didn't pin role_id:
|
||||
- is_agent (i.e. payload had agent_id/claw_identifier) → general-agent
|
||||
- human user → guest
|
||||
|
||||
general-agent ≈ guest + user.reset-self-apikey so agents can rotate
|
||||
their own API key without admin intervention. Created in
|
||||
init_bootstrap.py on every startup; falls back to guest if absent
|
||||
(e.g. very old DB that hasn't been re-seeded yet).
|
||||
"""
|
||||
if role_id is None:
|
||||
role = db.query(Role).filter(Role.name == "guest").first()
|
||||
default_name = "general-agent" if is_agent else "guest"
|
||||
role = db.query(Role).filter(Role.name == default_name).first()
|
||||
if not role and is_agent:
|
||||
# general-agent missing from this DB → fall back to guest, log warn
|
||||
role = db.query(Role).filter(Role.name == "guest").first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=500, detail="Default guest role is missing")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Default role '{default_name}' is missing (DB not seeded)",
|
||||
)
|
||||
return role
|
||||
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
@@ -112,7 +134,7 @@ def create_user(
|
||||
if existing_agent:
|
||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||
|
||||
assigned_role = _resolve_user_role(db, user.role_id)
|
||||
assigned_role = _resolve_user_role(db, user.role_id, is_agent=has_agent_id)
|
||||
# In OIDC-only mode, ignore any supplied password: the user is created
|
||||
# passwordless (cannot password-login) and is expected to sign in via a
|
||||
# bound OIDC identity. API keys still work for such users.
|
||||
|
||||
@@ -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
|
||||
@@ -76,6 +77,12 @@ DEFAULT_PERMISSIONS = [
|
||||
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
|
||||
# Webhook
|
||||
("webhook.manage", "Manage webhooks", "admin"),
|
||||
# Project member management (used by DELETE /projects/{id}/members/{user_id})
|
||||
("member.remove", "Remove a project member", "project"),
|
||||
# Schedule type (calendar templates) — read covers list+detail, manage covers
|
||||
# create/edit/delete on schedule_types AND their special slots.
|
||||
("schedule_type.read", "View schedule types and special slots", "calendar"),
|
||||
("schedule_type.manage", "Create / edit / delete schedule types and slots", "calendar"),
|
||||
]
|
||||
|
||||
|
||||
@@ -98,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",
|
||||
@@ -124,11 +131,26 @@ _ACCOUNT_MANAGER_PERMISSIONS = {
|
||||
"user.reset-apikey",
|
||||
}
|
||||
|
||||
# Default role for agents (assigned automatically by POST /users when
|
||||
# the create-user payload carries agent_id/claw_identifier — see
|
||||
# app/api/routers/users.py:_resolve_user_role). Guest-tier reads +
|
||||
# self-service API-key rotation so agents can manage their own creds
|
||||
# without admin intervention.
|
||||
_GENERAL_AGENT_PERMISSIONS = {
|
||||
"project.read",
|
||||
"task.read",
|
||||
"milestone.read",
|
||||
"monitor.read",
|
||||
"calendar.read",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
_DEFAULT_ROLES = [
|
||||
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
|
||||
("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS),
|
||||
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
|
||||
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
|
||||
("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_PERMISSIONS),
|
||||
("guest", "Guest - read-only access", None), # special: *.read only
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user