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>
Audit cross-referenced every check_permission / _has_permission /
_has_global_permission / _require_calendar_permission call against
init_bootstrap.DEFAULT_PERMISSIONS. Three were enforced in code but
never seeded, so the Role Editor couldn't expose them:
- member.remove (projects.py:357 — remove project member)
- schedule_type.read (schedule_type.py + schedule_type_special_slot.py)
- schedule_type.manage (schedule_type.py + schedule_type_special_slot.py)
Seed only — default roles are NOT modified (admin still gets everything
via the "None = all perms" rule). Operators can grant via Role Editor.
Other audit notes (not fixed in this commit, separate decisions):
- GET /projects + GET /projects/{id}/members are completely unauthed
(no Depends(get_current_user_or_apikey)). Anonymous can list all
projects. Investigate whether this is deliberate (e.g. for monitor
external scrape) or an oversight.
- create_project hardcodes `if not current_user.is_admin: 403 "Only
admins can create projects"` — doesn't consult permissions at all.
Means manager-role users can't create projects even if they have
project.write or hypothetical project.create. Consider switching
to a perm-based gate.
- Several catalog perms (project.*, task.create/read/write/delete,
milestone.*) are seeded but never checked in code; basic CRUD on
task/project/milestone/comment is gated via the parallel
check_project_role (viewer/member/dev/mgr ladder) instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every account created via POST /users without an explicit
role_id fell through to the `guest` role. Recruitment workflow creates
HF accounts for newly-onboarded agents with --agent-id/--claw-identifier
set, so we can detect "this is an agent" at the backend boundary and pick
a more appropriate default:
payload.agent_id set → general-agent (guest reads + reset-self-apikey)
payload.agent_id unset → guest (human users keep current behavior)
Also adds `general-agent` to init_bootstrap.py's _DEFAULT_ROLES so fresh
deployments seed it on first boot — the role already existed on prod
(created via UI earlier); this is for re-seedability / new envs.
No ClawSkills script changes required: the onboard script already calls
`hf user create --agent-id <id> --claw-identifier <claw>`. The recruitment
workflow.md is updated to note the new default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hf-cli admin list crashed on prod with `KeyError: 'Agent'` because the CLI
bypassed main.py's startup() which is the only place that imports every
model module — User has a relationship target (`Agent`) that SQLAlchemy
can't resolve unless its module is imported. Load them all up front in
__main__.py (mirrors the main.py import block).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same-state transition was 500 (transition_to_busy asserts current=IDLE).
Now: short-circuit identical target → 200 no_change=true. Any other
state-machine violation surfaces as 409 with the actual error message
instead of generic 500.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously only POST /agent/status existed (for state transitions).
Fabric.OpenclawPlugin's triage on-call gate needs to check whether
the on-duty agent is currently on_call without flipping their state —
so the wake decision is read-only. GET returns {agent_id, status},
404 if unknown.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the two hard restrictions in PR #18:
* window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
* maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
((maint_to - maint_from) mod 1440)
## Schema migration (additive)
`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.
## Touched surfaces
- `models/schedule_type.py` — column comments updated; new
`compute_maintenance_duration()` helper (returns 1-1440 min, treats
from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
`_validate_maintenance_window` accepts 1-180min duration; response
includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
pair (partial updates can put the row into an invalid combo the
pydantic single-field validator can't catch); `_attach_derived`
populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
hour→min; the maintenance-window guard error message formats
HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
`scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
with hour/minute split
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
## What this adds
1. **Maintenance window on ScheduleType**
- New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
- Invariant: window is exactly 1 hour (validated in pydantic;
maintenance_to must equal (maintenance_from + 1) % 24)
- Default applied via additive migration: 8:00-9:00 UTC for existing
rows so deployments don't crash on first boot
2. **ScheduleTypeSpecialSlot** — admin-managed slot template
- New table schedule_type_special_slots
- Admin (schedule_type.manage) CRUD via
/schedule-types/{id}/special-slots
- Fields: name, description, minute_in_window (0-59 inside the
parent maintenance window), estimated_duration, priority,
event_data (JSON merged into materialised slot), is_active
- Unique constraint (schedule_type_id, name) — name is the stable
human-readable identifier per cohort
3. **Per-agent materialisation**
- New service app/services/special_slot_materialiser.py
- GET /calendar/sync calls materialise_special_slots_for_claw
(idempotent, one row per agent per template per date)
- GET /calendar/day calls materialise_special_slots_for_user
- Materialised rows are slot_type=system, event_type=system_event,
is_admin_locked=true, special_slot_id pointing back to template
- Plugin's runSync picks them up like any other due slot via the
normal real-slots query path
4. **Admin-locked enforcement**
- New TimeSlot columns: is_admin_locked, special_slot_id (FK to
schedule_type_special_slots, ON DELETE SET NULL)
- PATCH /calendar/slots/{id}: refuses any edit on admin-locked
slots (423)
- POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
(423)
- PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
ongoing/paused/finished/aborted statuses (423 on other transitions)
5. **Maintenance-window guard on slot creation**
- POST /calendar/slots: rejects slot_type=system outright (only
materialiser may create system slots) and rejects any non-system
slot whose [scheduled_at, +duration] intersects the calling
user's schedule_type maintenance window (422). Handles 23->0 wrap
6. **Schema response**
- TimeSlotResponse / CalendarSlotItem now include is_admin_locked
and special_slot_id so clients can render the lock indicator and
trace back to the template
## Migration
Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The oidc-binding PUT/DELETE endpoints allowed any account.create holder
(non-admin role 'account-manager') to bind an attacker-controlled OIDC
identity to the admin account (or unbind admin, reopening the OIDC-only
bootstrap window) — full admin takeover.
Non-admin callers may now only manage bindings of non-privileged
accounts: requests targeting an is_admin user, the built-in
acc-mgr/deleted-user, or any holder of account.create / user.reset-apikey
are rejected with 403. Global admins remain unrestricted, so the
intended "account-manager binds normal users" capability is preserved.
Found by post-feature security audit. Verified locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In OIDC-only mode, before any admin is linked, an IdP user whose token
carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE /
oidc_settings.admin_role) auto-connects to the unbound hf admin on
first OIDC sign-in, then the window self-closes once any admin is
bound. Roles are scanned across userinfo + the (unverified) access
token: realm_access.roles, resource_access.*.roles, roles/role/groups.
Adds admin_role to settings model/env/effective/API and to the wizard
bootstrap config. Replaces the manual admin-subject approach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init_wizard applies config['oidc'] on first init: creates the
oidc_settings row and, when admin_subject is given, binds the
bootstrap admin so OIDC-only deployments are reachable. Idempotent —
an existing row / admin binding is preserved (later admin edits via
the API survive restarts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test points for OIDC login, user binding, HARBORFORGE_OIDC_ONLY mode,
and the admin OIDC settings page, with local verification status.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist OIDC config in a single-row oidc_settings table; non-empty DB
fields override the OIDC_* env vars (env = bootstrap default). The
Authlib client is rebuilt when config changes.
- GET/PUT /auth/oidc/settings — admin only, via JWT OR API key. The
API-key path is the recovery channel when OIDC-only mode is on and
OIDC is misconfigured (avoids total lockout).
- client_secret is write-only: never returned (has_client_secret bool),
preserved when the field is left blank on update.
- /auth/config, login/link/callback now use the effective (DB|env)
config so enabling OIDC needs no redeploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
issues the existing HS256 JWT on success. Unbound identities are
rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
JWT or API key; 409 on conflict). Self-link /auth/oidc/link
(non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
SessionMiddleware for OIDC state. Fixed _user_response to expose
the new binding fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the auth/RBAC/SSRF hardening in this branch: mandatory strong
SECRET_KEY (server refuses weak/default), admin-only + masked /api-keys,
admin-only /webhooks with SSRF guard, project role hierarchy, and auth
added to previously-open endpoints. Fixed stale Issues→tasks model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns today's slots for all agents on a claw instance, keyed by
agent_id. Used by HF Plugin to maintain a local schedule cache
instead of per-agent heartbeat.
Also records heartbeat for all agents on the instance.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add deleted-user as a built-in account (no permissions, cannot log in)
created during init_wizard, protected from deletion like acc-mgr
- On user delete, reassign all foreign key references to deleted-user
then delete the original user, instead of failing on IntegrityError
- API keys, notifications, and project memberships are deleted outright
since they're meaningless without the real user
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change dependency from get_current_user (OAuth2 only) to
get_current_user_or_apikey, enabling account-manager API key
to reset user API keys for provisioning workflows.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>