4 Commits

Author SHA1 Message Date
6400f7f612 feat(users): PATCH /users/{id}/bind-agent to backfill agents row
Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:58:06 +01:00
5b59806e38 Merge pull request 'fix(schedule-type): accept X-API-Key for CRUD' (#19) from feat/schedule-type-apikey-auth into main 2026-05-22 18:36:20 +00:00
23632aa073 fix(schedule-type): accept X-API-Key for CRUD
The /schedule-types/ router was the last surface still gated on
get_current_user (JWT-only). The companion special-slot router
(PR #18) used get_current_user_or_apikey, so the admin flow was:

  * create a schedule_type → DB direct insert (cli can't reach it)
  * add special slot via API → works

Swaps all 5 CRUD endpoints (list / create / patch / delete /
assign-agent) to get_current_user_or_apikey so the same hzhang
admin api_key that works for special-slot creation now works for
schedule_type creation too. /schedule-types/agent/me already uses
X-Agent-ID headers (not user auth), so no change there.

Existing JWT callers are unaffected — get_current_user_or_apikey
tries api_key first then falls back to JWT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:35:56 +01:00
7017d3483e Merge pull request 'feat(calendar): maintenance window + schedule_type special slots' (#18) from feat/maintenance-window-and-special-slots into main 2026-05-22 18:19:06 +00:00
3 changed files with 85 additions and 7 deletions

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from typing import List from typing import List
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user_or_apikey
from app.models.models import User from app.models.models import User
from app.models.agent import Agent from app.models.agent import Agent
from app.models.schedule_type import ScheduleType from app.models.schedule_type import ScheduleType
@@ -68,7 +68,7 @@ def _require_schedule_manage(db: Session, user: User) -> User:
) )
def list_schedule_types( def list_schedule_types(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_read(db, current_user) _require_schedule_read(db, current_user)
return db.query(ScheduleType).all() return db.query(ScheduleType).all()
@@ -82,7 +82,7 @@ def list_schedule_types(
def create_schedule_type( def create_schedule_type(
payload: ScheduleTypeCreate, payload: ScheduleTypeCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -112,7 +112,7 @@ def update_schedule_type(
schedule_type_id: int, schedule_type_id: int,
payload: ScheduleTypeUpdate, payload: ScheduleTypeUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -135,7 +135,7 @@ def update_schedule_type(
def delete_schedule_type( def delete_schedule_type(
schedule_type_id: int, schedule_type_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -192,7 +192,7 @@ def assign_schedule_type(
agent_id: str, agent_id: str,
payload: AgentScheduleTypeAssign, payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)

View File

@@ -221,6 +221,71 @@ def update_user(
return _user_response(user) return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} _BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime, time from datetime import datetime, time
from enum import Enum from enum import Enum
@@ -186,6 +186,19 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool