31 Commits

Author SHA1 Message Date
9feff8e008 feat(knowledge-base): KnowledgeBase feature — models, CRUD API, RBAC
New entities mirroring the Project shape:
- knowledge_bases (human code, title, description, created_by, timestamps)
- knowledge_topics (UNIQUE(topic, knowledge_base_id))
- knowledge_categories (self-referential parent; UNIQUE(topic_id, parent, name),
  with an app-level check for the NULL-parent case MySQL can't enforce)
- knowledge_facts (category_id NULL → fact lives directly on the topic)
- project_knowledge_bases (M2M project ↔ knowledge base)

Adds full CRUD for KB/topic/category/fact, a nested /tree aggregate,
project link/unlink/list, KB-code generation (same algorithm as project
codes), and category cycle-prevention. Four global permissions
(knowledge-base.create/read/update/delete) seeded in init_bootstrap and
granted to admin/mgr/dev/general-agent/guest as appropriate. New tables
auto-create via Base.metadata.create_all; router wired in main.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:03:14 +01:00
h z
88779d2db0 Merge pull request 'fix(users): admin-gated /users routes accept api-key auth' (#23) from fix/users-require-admin-accept-apikey into main 2026-05-29 07:55:45 +00:00
cacb1d2652 fix(users): admin-gated /users routes accept api-key auth
Local `require_admin` in users.py depended on `get_current_user`, which
is OAuth2 JWT only. That made every admin-gated /users route (list, get,
patch update, bind-agent, etc.) reject api-key clients with 401 even when
the api-key resolves to an is_admin=True user.

Switch to `get_current_user_or_apikey` (the one in deps.py) so X-API-Key
and Bearer-as-apikey fallback both work. The admin gate itself still
reads User.is_admin — only the auth carrier broadens. Matches the auth
pattern schedule_type.py and other admin routes already use.

Surfaced when sherlock (agent-resource-director) tried `hf user list` for
the recruitment workflow Step 3 verify and got 401 "Could not validate
credentials" despite a valid provisioned api-key.
2026-05-29 08:55:28 +01:00
d2b83ad58d 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>
2026-05-24 22:09:34 +01:00
01f6b562e1 fix(bootstrap): seed 3 perms used in code but missing from catalog
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>
2026-05-24 21:18:30 +01:00
595391b41b feat(users): auto-default agent accounts to general-agent role
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>
2026-05-24 19:38:06 +01:00
54feb9686c fix(cli): import all model modules so SA relationship resolution works
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>
2026-05-24 19:10:26 +01:00
5ea2cdfc9e feat(backend)!: kill AbstractWizard, env-driven config + hf-cli
Drops the AbstractWizard config-volume bootstrap entirely. All deploy-time
config now comes from docker env vars (.env). First-deploy admin user + OIDC
provider config are operator-driven via `docker exec hf_backend hf-cli ...`.

Backend changes:
- entrypoint.sh: drop config-wait loop, just exec uvicorn
- app/core/config.py: drop _resolve_db_url + OIDC_* env vars (DB only now);
  keep HARBORFORGE_OIDC_ONLY (deploy-time policy)
- app/init_wizard.py → app/init_bootstrap.py: drop load_config / admin / OIDC /
  default-project bootstrap; keep idempotent startup seed (permissions,
  default roles, acc-mgr + deleted-user builtins)
- app/main.py: /config/status now returns {initialized: <admin exists>};
  startup() imports init_bootstrap.run_bootstrap
- app/api/routers/oidc.py: get_effective_oidc reads DB only (no env fallback)
- app/services/harborforge_config.py: removed (replaced by direct env reads)
- app/services/discord_wakeup.py: HF_DISCORD_GUILD_ID / HF_DISCORD_BOT_TOKEN env
- app/api/routers/users.py + tests/conftest.py: rename init_wizard refs

New hf-cli surface (app/cli/, invoked via /usr/local/bin/hf-cli shim):
  hf-cli admin create-user --email <e> [--username <u>] [--password <p>]
                            [--oidc-issuer <url> --oidc-subject <sub>]
  hf-cli admin list
  hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
  hf-cli admin reset-password --username <u> --password <p>
  hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
  hf-cli config oidc [--issuer/...] [--client-id/...] [--client-secret/...]
                     [--redirect-uri/...] [--enabled true|false] [--show-secret]

Bootstrap migration on existing deployments: existing admin / OIDC settings
in the DB are preserved across the cutover; only the wizard config-volume
+ wizard sidecar services need to be removed from compose. Restart picks
up the new entrypoint + skips the config wait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:01:37 +01:00
422b2fa7b7 Merge pull request 'feat: GET /agent/status + idempotent POST same-state' (#22) from feat/get-agent-status into main 2026-05-22 21:59:10 +00:00
e80ead528d fix(calendar): /agent/status idempotent + 409 on bad transition
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>
2026-05-22 22:46:30 +01:00
f1aafb86df feat(calendar): GET /agent/status — read-only status query for plugin gate
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>
2026-05-22 22:34:10 +01:00
65905e4831 Merge pull request 'feat(schedule_type): minute-precision windows + variable maintenance length' (#21) from feat/schedule-type-minutes into main 2026-05-22 19:19:21 +00:00
345e0f3a04 feat(schedule_type): minute-precision windows + variable maintenance length
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>
2026-05-22 20:18:21 +01:00
e5e81d418d Merge pull request 'feat(users): PATCH /users/{id}/bind-agent to backfill agents row' (#20) from feat/user-bind-agent into main 2026-05-22 18:58:25 +00:00
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
dcaaa4259a feat(calendar): maintenance window + schedule_type special slots
## 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>
2026-05-22 19:18:42 +01:00
c6d2ecbf95 Merge pull request 'feature/oidc-login' (#17) from feature/oidc-login into main
Reviewed-on: #17
2026-05-17 21:27:39 +00:00
5a5e3fa2eb fix(security): block OIDC-binding privilege escalation
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>
2026-05-17 22:07:43 +01:00
1c91cb32fc feat(auth): OIDC-only admin-role bootstrap auto-connect
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>
2026-05-17 21:05:39 +01:00
f64e2a24f8 feat(init): bootstrap OIDC from wizard config
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>
2026-05-17 20:50:59 +01:00
ece2b550fc docs: OIDC feature test plan / test points
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>
2026-05-17 20:40:26 +01:00
f8126d0cbc feat(auth): admin-configurable OIDC provider (oidc_settings)
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>
2026-05-17 20:29:15 +01:00
54b6103880 feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY
- 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>
2026-05-17 20:22:04 +01:00
d2fafdfe9c Merge security/critical-auth-fixes into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:59 +01:00
b7ae20e43f Merge pull request 'zhi-2026-04-18' (#16) from zhi-2026-04-18 into main
Reviewed-on: #16
2026-05-01 07:24:35 +00:00
69c4e17d0f Merge branch 'main' into zhi-2026-04-18 2026-05-01 07:24:28 +00:00
zhi
8ab9cae474 feat: schedule type system for work/entertainment periods
- New model: ScheduleType (name, work_from/to, entertainment_from/to)
- Agent.schedule_type_id FK to schedule_types
- CRUD API: GET/POST/PATCH/DELETE /schedule-types/
- Agent assignment: PUT /schedule-types/agent/{agent_id}/assign
- Agent self-query: GET /schedule-types/agent/me
- Permissions: schedule_type.read, schedule_type.manage
- Migration: adds schedule_type_id column to agents table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:20:51 +00:00
zhi
5b7169a3cf feat: add /calendar/sync endpoint for multi-agent schedule sync
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>
2026-04-19 09:30:57 +00:00
38 changed files with 3845 additions and 301 deletions

View File

@@ -1,11 +1,34 @@
# HarborForge Environment Variables
# HarborForge Backend Environment Variables (v0.4.0+ — wizard removed)
# Database
# --- Database (used by both the mysql container and the backend) -----------
MYSQL_ROOT_PASSWORD=harborforge_root
MYSQL_DATABASE=harborforge
MYSQL_USER=harborforge
MYSQL_PASSWORD=harborforge_pass
# Full DSN used by the backend container. Default points to a service
# named "mysql" on the same docker network. Override if your DB is elsewhere.
DATABASE_URL=mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge
# Application
# --- Application ----------------------------------------------------------
# Must be 32+ chars and not a placeholder; use: openssl rand -hex 32
SECRET_KEY=change-me-use-openssl-rand-hex-32
LOG_LEVEL=INFO
# When true: password login is disabled, all sign-in goes through OIDC,
# user creation ignores any password (passwordless users that can only
# authenticate via OIDC binding or API keys). Frontend hides password UI.
HARBORFORGE_OIDC_ONLY=false
# --- Discord wakeup (optional; previously in wizard config) ---------------
# Used by /agents/{id}/wakeup to spin a private Discord channel + DM.
HARBORFORGE_DISCORD_GUILD_ID=
HARBORFORGE_DISCORD_BOT_TOKEN=
# --- OIDC issuer / client_id / client_secret / redirect_uri ---------------
# NOT env vars in v0.4.0+. Configure via:
# docker exec hf-backend hf-cli config oidc \
# --issuer https://login.example.com/realms/foo \
# --client-id harborforge --client-secret <s> \
# --redirect-uri https://hf-api.example.com/auth/oidc/callback \
# --post-login-redirect https://hf.example.com/oidc/callback \
# --enabled true

View File

@@ -42,5 +42,17 @@ COPY requirements.txt ./
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# Install hf-cli as a /usr/local/bin shim that re-enters the app package
# (so `docker exec hf-backend hf-cli admin create-user ...` works). The
# CLI reads the same DATABASE_URL / SECRET_KEY env as the backend.
RUN printf '#!/bin/sh\nexec python -m app.cli "$@"\n' > /usr/local/bin/hf-cli && \
chmod +x /usr/local/bin/hf-cli
# OIDC-only mode: when "true", password login is rejected, user creation
# ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var.
ARG HARBORFORGE_OIDC_ONLY=false
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -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")

View File

@@ -11,13 +11,15 @@ 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"])
@router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password or ""):
raise HTTPException(status_code=401, detail="Incorrect username or password",
@@ -78,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."""

View File

@@ -21,6 +21,11 @@ from app.core.config import get_db
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
from app.models.models import User
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.models.schedule_type import ScheduleType
from app.services.special_slot_materialiser import (
materialise_special_slots_for_claw,
materialise_special_slots_for_user,
)
from app.schemas.calendar import (
AgentHeartbeatResponse,
AgentStatusUpdateRequest,
@@ -47,6 +52,7 @@ from app.schemas.calendar import (
)
from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import (
AgentStatusError,
record_heartbeat,
transition_to_busy,
transition_to_idle,
@@ -143,6 +149,8 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at,
updated_at=slot.updated_at,
)
@@ -168,6 +176,48 @@ def create_slot(
"""
target_date = payload.date or date_type.today()
# --- Maintenance-window guard ---
# Non-`system` slots may not be placed inside the schedule_type's
# 1-hour maintenance window. The window is admin-territory, reserved
# for materialised special slots from `schedule_type_special_slots`.
# `system` slot_type is itself reserved server-side (the materialiser
# is the only legitimate caller) — refuse it here outright so the
# public API cannot manufacture a fake admin-locked slot.
if payload.slot_type == SlotType.SYSTEM:
raise HTTPException(
status_code=422,
detail=(
"slot_type='system' is reserved for schedule_type special slots "
"and cannot be created via this endpoint"
),
)
_agent_for_user = (
db.query(Agent).filter(Agent.user_id == current_user.id).first()
)
if _agent_for_user and _agent_for_user.schedule_type_id:
st = (
db.query(ScheduleType)
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
.first()
)
if st and _scheduled_inside_window(
payload.scheduled_at,
payload.estimated_duration,
st.maintenance_from,
st.maintenance_to,
):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException(
status_code=422,
detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window "
f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved"
),
)
# --- Overlap check (hard reject) ---
conflicts = check_overlap_for_create(
db,
@@ -236,6 +286,8 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at,
updated_at=slot.updated_at,
)
@@ -289,7 +341,47 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
return agent
def _scheduled_inside_window(
scheduled_at,
estimated_duration_minutes: int,
window_from_min: int,
window_to_min: int,
) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from, to).
Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
case where the window crosses UTC midnight (e.g. 23:30→01:00).
"""
start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1)
if window_to_min > window_from_min:
# normal same-day window
return start_min < window_to_min and end_min > window_from_min
# wrap-around: window = [from..1440) [0..to)
return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
# Admin-locked special slots accept only these agent-driven status
# transitions; movement / cancellation / arbitrary status edits are
# rejected because the schedule_type owner is the source of truth.
_ADMIN_LOCKED_ALLOWED_STATUSES = {
SlotStatusEnum.ONGOING,
SlotStatusEnum.PAUSED,
SlotStatusEnum.FINISHED,
SlotStatusEnum.ABORTED,
}
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
if getattr(slot, "is_admin_locked", False):
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (special slot); only "
f"ongoing/paused/finished/aborted are allowed via agent-update"
),
)
slot.status = payload.status.value
if payload.started_at is not None:
slot.started_at = payload.started_at
@@ -361,6 +453,68 @@ def agent_heartbeat(
)
@router.get(
"/sync",
summary="Sync today's schedules for all agents on a claw instance",
)
def sync_schedules(
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return today's slots for all agents belonging to the given claw instance.
Used by the HF OpenClaw plugin to maintain a local schedule cache.
Returns a dict of { agent_id: [slots] } for all agents with matching
claw_identifier.
"""
today = date_type.today()
# Materialise today's special slots for every agent on this claw
# before reading. This is idempotent — re-runs against an already-
# materialised (agent, date, template) are no-ops. Plugin's runSync
# picks them up like any other slot via the normal real_slots query.
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
# Find all agents on this claw instance
agents = (
db.query(Agent)
.filter(Agent.claw_identifier == x_claw_identifier)
.all()
)
schedules: dict[str, list[dict]] = {}
for agent in agents:
# Get real slots for today
real_slots = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == agent.user_id,
TimeSlot.date == today,
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
)
.all()
)
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
# Get virtual plan slots
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
for vs in virtual_slots:
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
schedules[agent.agent_id] = items
# Record heartbeat for liveness
for agent in agents:
record_heartbeat(db, agent)
db.commit()
return {
"schedules": schedules,
"date": today.isoformat(),
"agent_count": len(agents),
}
@router.patch(
"/slots/{slot_id}/agent-update",
response_model=TimeSlotEditResponse,
@@ -408,6 +562,29 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.get(
"/agent/status",
summary="Read an agent's current runtime status (no side effects)",
)
def get_agent_status(
agent_id: str = Query(..., description="Target agent_id"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
triage on-call gate, etc.) can decide whether the agent is currently
eligible without flipping their state.
No-op for unknown agents — returns 404 with `{detail: 'Agent not
found'}` so the caller can decide whether to fail-open or fail-closed.
"""
agent = _require_agent(db, agent_id, x_claw_identifier)
return {
"agent_id": agent.agent_id,
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
}
@router.post(
"/agent/status",
summary="Update agent runtime status from plugin",
@@ -418,19 +595,31 @@ def update_agent_status(
):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip()
if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value:
transition_to_busy(db, agent, slot_type=SlotType.WORK)
elif target == AgentStatus.ON_CALL.value:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
# Idempotent same-state transition: a 'busy → busy' request is a
# no-op rather than a 500. Lets plugin status gates / cli `--set`
# be safe to fire-and-forget without first reading current state.
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
if current == target:
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
try:
if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value:
transition_to_busy(db, agent, slot_type=SlotType.WORK)
elif target == AgentStatus.ON_CALL.value:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
@@ -458,6 +647,10 @@ def get_calendar_day(
"""
target_date = date or date_type.today()
# Materialise today's special slots for this user before reading,
# so the day-view returns them alongside any user-created slots.
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
# 1. Fetch real slots for the day
real_slots = (
db.query(TimeSlot)
@@ -533,6 +726,20 @@ def edit_real_slot(
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
# Special slots materialised from a schedule_type template are
# admin-owned; agents may complete/abort/pause/resume via the
# plugin-facing agent-update endpoint but cannot edit time/type/
# duration/event-data via this user-facing edit endpoint.
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can edit it"
),
)
# --- Past-slot guard ---
try:
guard_edit_real_slot(db, slot)
@@ -700,6 +907,16 @@ def cancel_real_slot(
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can cancel it"
),
)
# --- Past-slot guard ---
try:
guard_cancel_real_slot(db, slot)

View File

@@ -0,0 +1,735 @@
"""Knowledge Base router with global-permission RBAC.
Permissions (global, granted via the Role Editor; admins auto-pass):
knowledge-base.create create a knowledge base
knowledge-base.read read any knowledge base / topic / category / fact
knowledge-base.update edit a KB and its topic/category/fact structure,
and link/unlink knowledge bases to projects
knowledge-base.delete delete a knowledge base
There is no per-KB membership model (unlike projects) — access is purely by
the four global permissions above.
"""
import re
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.models import models
from app.models import knowledge as kb_models
from app.schemas import knowledge as kb_schemas
from app.api.deps import get_current_user_or_apikey
router = APIRouter(tags=["KnowledgeBase"])
PERM_CREATE = "knowledge-base.create"
PERM_READ = "knowledge-base.read"
PERM_UPDATE = "knowledge-base.update"
PERM_DELETE = "knowledge-base.delete"
# ---------------------------------------------------------------------------
# Permission helper (global perms only)
# ---------------------------------------------------------------------------
def _require_perm(db: Session, user: models.User, perm_name: str) -> None:
if user.is_admin:
return
from app.models.role_permission import Permission, RolePermission
has = (
db.query(Permission.id)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == perm_name,
)
.first()
if user.role_id
else None
)
if not has:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {perm_name} required",
)
# ---------------------------------------------------------------------------
# Knowledge-base code generation (same rules as project_code)
# ---------------------------------------------------------------------------
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
def _split_words(name: str):
segments = WORD_SEGMENT_RE.findall(name or "")
words = []
for seg in segments:
for part in CAMEL_RE.findall(seg):
if part.isupper() and len(part) > 1:
words.extend(list(part))
else:
words.append(part)
return words
def _code_exists(db: Session, code: str) -> bool:
return (
db.query(kb_models.KnowledgeBase)
.filter(kb_models.KnowledgeBase.knowledge_base_code == code)
.first()
is not None
)
def _next_counter(db: Session, prefix: str, width: int) -> str:
if width <= 0:
return ""
counter = (
db.query(kb_models.KnowledgeBaseCodeCounter)
.filter(kb_models.KnowledgeBaseCodeCounter.prefix == prefix)
.first()
)
if not counter:
counter = kb_models.KnowledgeBaseCodeCounter(prefix=prefix, next_value=0)
db.add(counter)
db.flush()
value = counter.next_value
counter.next_value += 1
db.flush()
return format(value, "x").upper().zfill(width)
def _generate_with_counter(db: Session, prefix: str, width: int) -> str:
while True:
suffix = _next_counter(db, prefix, width)
code = (prefix + suffix).upper()
if not _code_exists(db, code):
return code
def _generate_kb_code(db: Session, title: str) -> str:
words = _split_words(title)
if not words:
return _generate_with_counter(db, "UN", 4)
if len(words) == 1:
letters = "".join(c for c in words[0] if c.isalpha()).upper()
if not letters:
return _generate_with_counter(db, "UN", 4)
if len(letters) >= 6:
code = letters[:6]
if _code_exists(db, code):
return _generate_with_counter(db, letters[:2], 4)
return code
prefix = letters
return _generate_with_counter(db, prefix, 6 - len(prefix))
total_letters = sum(len(w) for w in words)
if len(words) > 6:
code = "".join(w[0] for w in words[:6]).upper()
if _code_exists(db, code):
return _generate_with_counter(db, code[:2], 4)
return code
if total_letters < 6:
prefix = "".join(words).upper()
return _generate_with_counter(db, prefix, 6 - len(prefix))
if total_letters == 6:
code = "".join(words).upper()
if _code_exists(db, code):
return _generate_with_counter(db, code[:2], 4)
return code
# total_letters > 6: initials, then fill from a counter on collision
code = "".join(w[0] for w in words).upper()[:6]
if not _code_exists(db, code):
return code
return _generate_with_counter(db, code[:2], 4)
# ---------------------------------------------------------------------------
# Resolvers
# ---------------------------------------------------------------------------
def _resolve_kb(db: Session, identifier: str) -> kb_models.KnowledgeBase:
kb = None
try:
kb = db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id == int(identifier)).first()
except (ValueError, TypeError):
kb = (
db.query(kb_models.KnowledgeBase)
.filter(kb_models.KnowledgeBase.knowledge_base_code == str(identifier))
.first()
)
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
return kb
def _resolve_project(db: Session, identifier: str) -> models.Project:
project = None
try:
project = db.query(models.Project).filter(models.Project.id == int(identifier)).first()
except (ValueError, TypeError):
project = db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def _get_topic(db: Session, topic_id: int) -> kb_models.KnowledgeTopic:
topic = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
def _get_category(db: Session, category_id: int) -> kb_models.KnowledgeCategory:
cat = db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
return cat
def _descendant_category_ids(db: Session, category_id: int) -> List[int]:
"""Return [category_id, ...all nested descendants] (deepest last)."""
collected = [category_id]
frontier = [category_id]
while frontier:
children = (
db.query(kb_models.KnowledgeCategory.id)
.filter(kb_models.KnowledgeCategory.parent.in_(frontier))
.all()
)
child_ids = [c.id for c in children]
if not child_ids:
break
collected.extend(child_ids)
frontier = child_ids
return collected
# ===========================================================================
# Knowledge Base CRUD
# ===========================================================================
@router.post("/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
def create_knowledge_base(
payload: kb_schemas.KnowledgeBaseCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_CREATE)
kb = kb_models.KnowledgeBase(
title=payload.title,
description=payload.description,
created_by=current_user.id,
knowledge_base_code=_generate_kb_code(db, payload.title),
)
db.add(kb)
db.commit()
db.refresh(kb)
return kb
@router.get("/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
def list_knowledge_bases(
skip: int = 0,
limit: int = 100,
project: Optional[str] = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
q = db.query(kb_models.KnowledgeBase)
if project is not None:
proj = _resolve_project(db, project)
linked_ids = [
row.knowledge_base_id
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
.filter(kb_models.ProjectKnowledgeBase.project_id == proj.id)
.all()
]
if not linked_ids:
return []
q = q.filter(kb_models.KnowledgeBase.id.in_(linked_ids))
return q.order_by(kb_models.KnowledgeBase.id).offset(skip).limit(limit).all()
@router.get("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
def get_knowledge_base(
kb_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
return _resolve_kb(db, kb_id)
@router.patch("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
def update_knowledge_base(
kb_id: str,
payload: kb_schemas.KnowledgeBaseUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
kb = _resolve_kb(db, kb_id)
data = payload.model_dump(exclude_unset=True)
for field, value in data.items():
setattr(kb, field, value)
db.commit()
db.refresh(kb)
return kb
@router.delete("/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_knowledge_base(
kb_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_DELETE)
kb = _resolve_kb(db, kb_id)
topic_ids = [
t.id
for t in db.query(kb_models.KnowledgeTopic.id)
.filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id)
.all()
]
if topic_ids:
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).delete(synchronize_session=False)
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).delete(synchronize_session=False)
db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id.in_(topic_ids)).delete(synchronize_session=False)
db.query(kb_models.ProjectKnowledgeBase).filter(kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id).delete(synchronize_session=False)
db.delete(kb)
db.commit()
return None
# ===========================================================================
# Tree (read-only aggregate)
# ===========================================================================
@router.get("/knowledge-bases/{kb_id}/tree", response_model=kb_schemas.KnowledgeBaseTree)
def get_knowledge_base_tree(
kb_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
kb = _resolve_kb(db, kb_id)
topics = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
topic_ids = [t.id for t in topics]
cats = (
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).all()
if topic_ids else []
)
facts = (
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).all()
if topic_ids else []
)
# Index facts by (topic_id, category_id) and categories by (topic_id, parent)
facts_by_cat: dict = {}
facts_topic_direct: dict = {}
for f in facts:
fr = kb_schemas.KnowledgeFactResponse.model_validate(f)
if f.category_id is None:
facts_topic_direct.setdefault(f.topic_id, []).append(fr)
else:
facts_by_cat.setdefault(f.category_id, []).append(fr)
cats_by_parent: dict = {}
for c in cats:
cats_by_parent.setdefault((c.topic_id, c.parent), []).append(c)
def build_category(cat) -> kb_schemas.CategoryTreeNode:
children = cats_by_parent.get((cat.topic_id, cat.id), [])
return kb_schemas.CategoryTreeNode(
id=cat.id,
name=cat.name,
parent=cat.parent,
topic_id=cat.topic_id,
description=cat.description,
categories=[build_category(ch) for ch in children],
facts=facts_by_cat.get(cat.id, []),
)
topic_nodes = []
for t in topics:
top_level_cats = cats_by_parent.get((t.id, None), [])
topic_nodes.append(
kb_schemas.TopicTreeNode(
id=t.id,
topic=t.topic,
knowledge_base_id=t.knowledge_base_id,
description=t.description,
categories=[build_category(c) for c in top_level_cats],
facts=facts_topic_direct.get(t.id, []),
)
)
return kb_schemas.KnowledgeBaseTree(
id=kb.id,
knowledge_base_code=kb.knowledge_base_code,
title=kb.title,
description=kb.description,
topics=topic_nodes,
)
# ===========================================================================
# Topics
# ===========================================================================
@router.get("/knowledge-bases/{kb_id}/topics", response_model=List[kb_schemas.KnowledgeTopicResponse])
def list_topics(
kb_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
kb = _resolve_kb(db, kb_id)
return db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
@router.post("/knowledge-bases/{kb_id}/topics", response_model=kb_schemas.KnowledgeTopicResponse, status_code=status.HTTP_201_CREATED)
def create_topic(
kb_id: str,
payload: kb_schemas.KnowledgeTopicCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
kb = _resolve_kb(db, kb_id)
existing = (
db.query(kb_models.KnowledgeTopic)
.filter(
kb_models.KnowledgeTopic.knowledge_base_id == kb.id,
kb_models.KnowledgeTopic.topic == payload.topic,
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
topic = kb_models.KnowledgeTopic(
topic=payload.topic,
description=payload.description,
knowledge_base_id=kb.id,
created_by=current_user.id,
)
db.add(topic)
db.commit()
db.refresh(topic)
return topic
@router.get("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
def get_topic(
topic_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
return _get_topic(db, topic_id)
@router.patch("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
def update_topic(
topic_id: int,
payload: kb_schemas.KnowledgeTopicUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
topic = _get_topic(db, topic_id)
data = payload.model_dump(exclude_unset=True)
if "topic" in data and data["topic"] and data["topic"] != topic.topic:
clash = (
db.query(kb_models.KnowledgeTopic)
.filter(
kb_models.KnowledgeTopic.knowledge_base_id == topic.knowledge_base_id,
kb_models.KnowledgeTopic.topic == data["topic"],
kb_models.KnowledgeTopic.id != topic.id,
)
.first()
)
if clash:
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
for field, value in data.items():
setattr(topic, field, value)
db.commit()
db.refresh(topic)
return topic
@router.delete("/knowledge-topics/{topic_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_topic(
topic_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
topic = _get_topic(db, topic_id)
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id == topic.id).delete(synchronize_session=False)
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic.id).delete(synchronize_session=False)
db.delete(topic)
db.commit()
return None
# ===========================================================================
# Categories
# ===========================================================================
def _check_category_unique(db: Session, topic_id: int, parent: Optional[int], name: str, exclude_id: Optional[int] = None):
q = db.query(kb_models.KnowledgeCategory).filter(
kb_models.KnowledgeCategory.topic_id == topic_id,
kb_models.KnowledgeCategory.name == name,
)
if parent is None:
q = q.filter(kb_models.KnowledgeCategory.parent.is_(None))
else:
q = q.filter(kb_models.KnowledgeCategory.parent == parent)
if exclude_id is not None:
q = q.filter(kb_models.KnowledgeCategory.id != exclude_id)
if q.first():
raise HTTPException(status_code=400, detail="A category with this name already exists under the same parent")
@router.get("/knowledge-topics/{topic_id}/categories", response_model=List[kb_schemas.KnowledgeCategoryResponse])
def list_categories(
topic_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
_get_topic(db, topic_id)
return db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic_id).all()
@router.post("/knowledge-categories", response_model=kb_schemas.KnowledgeCategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
payload: kb_schemas.KnowledgeCategoryCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
_get_topic(db, payload.topic_id)
if payload.parent is not None:
parent_cat = _get_category(db, payload.parent)
if parent_cat.topic_id != payload.topic_id:
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
_check_category_unique(db, payload.topic_id, payload.parent, payload.name)
cat = kb_models.KnowledgeCategory(
name=payload.name,
description=payload.description,
parent=payload.parent,
topic_id=payload.topic_id,
created_by=current_user.id,
last_updated_by=current_user.id,
)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
@router.get("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
def get_category(
category_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
return _get_category(db, category_id)
@router.patch("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
def update_category(
category_id: int,
payload: kb_schemas.KnowledgeCategoryUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
cat = _get_category(db, category_id)
data = payload.model_dump(exclude_unset=True)
new_parent = data.get("parent", cat.parent) if "parent" in data else cat.parent
if "parent" in data and data["parent"] is not None:
if data["parent"] == cat.id:
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
parent_cat = _get_category(db, data["parent"])
if parent_cat.topic_id != cat.topic_id:
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
# Prevent cycles: new parent must not be a descendant of this category
if data["parent"] in _descendant_category_ids(db, cat.id):
raise HTTPException(status_code=400, detail="Cannot move a category under one of its own descendants")
new_name = data.get("name", cat.name)
if ("name" in data and data["name"] != cat.name) or ("parent" in data and new_parent != cat.parent):
_check_category_unique(db, cat.topic_id, new_parent, new_name, exclude_id=cat.id)
for field, value in data.items():
setattr(cat, field, value)
cat.last_updated_by = current_user.id
db.commit()
db.refresh(cat)
return cat
@router.delete("/knowledge-categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(
category_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
cat = _get_category(db, category_id)
ids = _descendant_category_ids(db, cat.id)
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.category_id.in_(ids)).delete(synchronize_session=False)
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id.in_(ids)).delete(synchronize_session=False)
db.commit()
return None
# ===========================================================================
# Facts
# ===========================================================================
@router.post("/knowledge-facts", response_model=kb_schemas.KnowledgeFactResponse, status_code=status.HTTP_201_CREATED)
def create_fact(
payload: kb_schemas.KnowledgeFactCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
_get_topic(db, payload.topic_id)
if payload.category_id is not None:
cat = _get_category(db, payload.category_id)
if cat.topic_id != payload.topic_id:
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
fact = kb_models.KnowledgeFact(
fact=payload.fact,
topic_id=payload.topic_id,
category_id=payload.category_id,
)
db.add(fact)
db.commit()
db.refresh(fact)
return fact
@router.get("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
def get_fact(
fact_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
if not fact:
raise HTTPException(status_code=404, detail="Fact not found")
return fact
@router.patch("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
def update_fact(
fact_id: int,
payload: kb_schemas.KnowledgeFactUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
if not fact:
raise HTTPException(status_code=404, detail="Fact not found")
data = payload.model_dump(exclude_unset=True)
if "category_id" in data and data["category_id"] is not None:
cat = _get_category(db, data["category_id"])
if cat.topic_id != fact.topic_id:
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
for field, value in data.items():
setattr(fact, field, value)
db.commit()
db.refresh(fact)
return fact
@router.delete("/knowledge-facts/{fact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_fact(
fact_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
if not fact:
raise HTTPException(status_code=404, detail="Fact not found")
db.delete(fact)
db.commit()
return None
# ===========================================================================
# Project <-> KnowledgeBase links
# ===========================================================================
@router.get("/projects/{project_id}/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
def list_project_knowledge_bases(
project_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_READ)
project = _resolve_project(db, project_id)
linked_ids = [
row.knowledge_base_id
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
.filter(kb_models.ProjectKnowledgeBase.project_id == project.id)
.all()
]
if not linked_ids:
return []
return db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id.in_(linked_ids)).all()
@router.post("/projects/{project_id}/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
def link_knowledge_base_to_project(
project_id: str,
payload: kb_schemas.ProjectKnowledgeBaseLink,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
project = _resolve_project(db, project_id)
kb = _resolve_kb(db, payload.knowledge_base)
existing = (
db.query(kb_models.ProjectKnowledgeBase)
.filter(
kb_models.ProjectKnowledgeBase.project_id == project.id,
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
)
.first()
)
if not existing:
db.add(kb_models.ProjectKnowledgeBase(project_id=project.id, knowledge_base_id=kb.id))
db.commit()
return kb
@router.delete("/projects/{project_id}/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
def unlink_knowledge_base_from_project(
project_id: str,
kb_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
_require_perm(db, current_user, PERM_UPDATE)
project = _resolve_project(db, project_id)
kb = _resolve_kb(db, kb_id)
db.query(kb_models.ProjectKnowledgeBase).filter(
kb_models.ProjectKnowledgeBase.project_id == project.id,
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
).delete(synchronize_session=False)
db.commit()
return None

346
app/api/routers/oidc.py Normal file
View File

@@ -0,0 +1,346 @@
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
Provider config (issuer / client_id / client_secret / redirect_uri /
scopes / post_login_redirect / admin_role / enabled) lives entirely in
the `oidc_settings` DB table (single row, id=1) and is set via either
the admin UI or `docker exec hf-backend hf-cli config oidc ...`.
HARBORFORGE_OIDC_ONLY is the only OIDC-related env var (deploy-time
policy: when true, password login is disabled).
Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.models.oidc_settings import OidcSettings
from app.api.deps import create_access_token, get_current_user, get_current_user_or_apikey
router = APIRouter(prefix="/auth", tags=["Auth"])
logger = logging.getLogger("harborforge.oidc")
# ---- effective config (DB row overrides env) ------------------------------
class EffectiveOidc:
def __init__(self, enabled, issuer, client_id, client_secret,
redirect_uri, scopes, post_login_redirect, admin_role):
self.enabled = enabled
self.issuer = issuer
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scopes = scopes or "openid email profile"
self.post_login_redirect = post_login_redirect
self.admin_role = (admin_role or "admin").strip() or "admin"
@property
def configured(self) -> bool:
return bool(self.enabled and self.issuer and self.client_id)
def fingerprint(self) -> str:
return "|".join([
str(self.enabled), self.issuer or "", self.client_id or "",
self.client_secret or "", self.redirect_uri or "", self.scopes or "",
])
def get_effective_oidc(db: Session) -> EffectiveOidc:
"""DB row is the only source of truth — no env fallback. If the row is
absent OIDC is treated as unconfigured (login attempts will 503)."""
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if row is None:
return EffectiveOidc(False, "", "", "", "", "", "", "admin")
return EffectiveOidc(
bool(row.enabled),
row.issuer or "",
row.client_id or "",
row.client_secret or "",
row.redirect_uri or "",
row.scopes or "",
row.post_login_redirect or "",
getattr(row, "admin_role", None) or "admin",
)
# Authlib client cache, rebuilt when the effective config changes.
_oauth = None
_oauth_fp = None
def _client(cfg: EffectiveOidc):
global _oauth, _oauth_fp
if not cfg.configured:
raise HTTPException(status_code=503, detail="OIDC is not configured")
fp = cfg.fingerprint()
if _oauth is None or _oauth_fp != fp:
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=cfg.issuer.rstrip("/") + "/.well-known/openid-configuration",
client_id=cfg.client_id,
client_secret=cfg.client_secret,
client_kwargs={"scope": cfg.scopes},
)
_oauth, _oauth_fp = oauth, fp
return _oauth.oidc
def _invalidate_client():
global _oauth, _oauth_fp
_oauth = None
_oauth_fp = None
def _collect_roles(claims: dict, token: dict) -> set[str]:
"""Roles from common OIDC claim shapes, across the ID-token/userinfo
claims and the (unverified) access token — Keycloak puts realm/client
roles in the access token by default."""
pools = [claims if isinstance(claims, dict) else {}]
at = token.get("access_token")
if at:
try:
from jose import jwt as _jwt
pools.append(_jwt.get_unverified_claims(at))
except Exception:
pass
roles: set[str] = set()
for p in pools:
if not isinstance(p, dict):
continue
ra = p.get("realm_access")
if isinstance(ra, dict):
roles.update(ra.get("roles") or [])
res = p.get("resource_access")
if isinstance(res, dict):
for v in res.values():
if isinstance(v, dict):
roles.update(v.get("roles") or [])
for key in ("roles", "role", "groups"):
val = p.get(key)
if isinstance(val, str):
roles.add(val)
elif isinstance(val, (list, tuple)):
roles.update(str(x) for x in val)
return {str(r).strip().lstrip("/").lower() for r in roles if r}
def _frontend(cfg: EffectiveOidc, qs: dict | None = None, fragment: str | None = None) -> str:
base = cfg.post_login_redirect or "/"
url = base
if qs:
url += ("&" if "?" in base else "?") + urlencode(qs)
if fragment:
url += "#" + fragment
return url
# ---- public auth config ---------------------------------------------------
@router.get("/config")
def auth_config(db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
return {
"oidc_enabled": cfg.configured,
"oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY),
"password_login": not bool(settings.HARBORFORGE_OIDC_ONLY),
"oidc_login_url": "/auth/oidc/login",
}
# ---- sign-in / link flows -------------------------------------------------
@router.get("/oidc/login")
async def oidc_login(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session.pop("hf_oidc_uid", None)
request.session["hf_oidc_mode"] = "login"
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/link")
async def oidc_link(request: Request, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode")
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session["hf_oidc_mode"] = "link"
request.session["hf_oidc_uid"] = current_user.id
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
mode = request.session.pop("hf_oidc_mode", "login")
link_uid = request.session.pop("hf_oidc_uid", None)
try:
token = await oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(_frontend(cfg, {"oidc_error": "exchange_failed"}))
claims = token.get("userinfo") or {}
if not claims:
try:
claims = await oidc.userinfo(token=token)
except Exception:
claims = {}
subject = claims.get("sub")
issuer = claims.get("iss") or cfg.issuer
if not subject:
return RedirectResponse(_frontend(cfg, {"oidc_error": "no_subject"}))
if mode == "link":
if settings.HARBORFORGE_OIDC_ONLY or link_uid is None:
return RedirectResponse(_frontend(cfg, {"oidc_error": "link_not_allowed"}))
user = db.query(models.User).filter(models.User.id == link_uid).first()
if not user:
return RedirectResponse(_frontend(cfg, {"oidc_error": "user_gone"}))
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
return RedirectResponse(_frontend(cfg, {"oidc_error": "already_bound"}))
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
return RedirectResponse(_frontend(cfg, {"oidc_linked": "1"}))
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
# OIDC-only bootstrap: before any admin is linked, an IdP user whose
# token carries the configured admin role auto-connects to the unbound
# hf admin. Self-closes once any admin is bound.
if user is None and settings.HARBORFORGE_OIDC_ONLY:
any_admin_bound = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.oidc_subject.isnot(None),
).first()
if not any_admin_bound and cfg.admin_role.lower() in _collect_roles(claims, token):
taken = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if taken is None:
boot = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.is_active == True, # noqa: E712
models.User.oidc_subject.is_(None),
).order_by(models.User.id).first()
if boot is not None:
boot.oidc_issuer = issuer
boot.oidc_subject = subject
db.commit()
logger.info("OIDC bootstrap: auto-connected admin '%s' via admin role", boot.username)
user = boot
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend(cfg, {"oidc_error": "not_linked"}))
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return RedirectResponse(_frontend(cfg, fragment=urlencode({"token": access_token})))
# ---- admin: OIDC provider settings ----------------------------------------
def _require_admin_any(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
"""Admin via JWT OR API key. The API-key path is the recovery channel
when OIDC-only mode is on and OIDC is not yet/incorrectly configured."""
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
class OidcSettingsIn(BaseModel):
enabled: bool | None = None
issuer: str | None = None
client_id: str | None = None
client_secret: str | None = None # blank/omitted = keep existing
redirect_uri: str | None = None
scopes: str | None = None
post_login_redirect: str | None = None
admin_role: str | None = None
class OidcSettingsOut(BaseModel):
enabled: bool
issuer: str | None
client_id: str | None
has_client_secret: bool
redirect_uri: str | None
scopes: str | None
post_login_redirect: str | None
admin_role: str
oidc_only: bool # read-only (deploy env)
effective_enabled: bool # provider actually usable
source: str # "db" or "env"
@router.get("/oidc/settings", response_model=OidcSettingsOut)
def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
cfg = get_effective_oidc(db)
return OidcSettingsOut(
enabled=bool(row.enabled) if row else False,
issuer=(row.issuer if row else None) or None,
client_id=(row.client_id if row else None) or None,
has_client_secret=bool(row.client_secret if row else None),
redirect_uri=(row.redirect_uri if row else None) or None,
scopes=(row.scopes if row else None) or None,
post_login_redirect=(row.post_login_redirect if row else None) or None,
admin_role=cfg.admin_role,
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
effective_enabled=cfg.configured,
source="db",
)
@router.put("/oidc/settings", response_model=OidcSettingsOut)
def update_oidc_settings(payload: OidcSettingsIn, db: Session = Depends(get_db),
_: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if row is None:
row = OidcSettings(id=1, enabled=False)
db.add(row)
if payload.enabled is not None:
row.enabled = payload.enabled
if payload.issuer is not None:
row.issuer = payload.issuer.strip() or None
if payload.client_id is not None:
row.client_id = payload.client_id.strip() or None
# client_secret: only overwrite when a non-empty value is supplied
if payload.client_secret:
row.client_secret = payload.client_secret
if payload.redirect_uri is not None:
row.redirect_uri = payload.redirect_uri.strip() or None
if payload.scopes is not None:
row.scopes = payload.scopes.strip() or None
if payload.post_login_redirect is not None:
row.post_login_redirect = payload.post_login_redirect.strip() or None
if payload.admin_role is not None:
row.admin_role = payload.admin_role.strip() or None
db.commit()
_invalidate_client()
return get_oidc_settings(db=db, _=_)

View File

@@ -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:

View File

@@ -0,0 +1,235 @@
"""ScheduleType API router.
CRUD for schedule types (work/entertainment time periods)
and agent schedule type assignment.
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.agent import Agent
from app.models.schedule_type import ScheduleType
from app.models.role_permission import Permission, RolePermission
from app.schemas.schedule_type import (
ScheduleTypeCreate,
ScheduleTypeUpdate,
ScheduleTypeResponse,
AgentScheduleTypeAssign,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
def _attach_derived(st: ScheduleType) -> ScheduleType:
"""Attach derived fields (maintenance_duration_minutes) so the
pydantic ScheduleTypeResponse picks them up via from_attributes.
Pydantic with from_attributes reads attributes off the ORM object;
setting a transient attr here avoids having to convert through dict.
"""
if st is not None:
st.maintenance_duration_minutes = st.compute_maintenance_duration()
return st
# ---------------------------------------------------------------------------
# Schedule Type CRUD
# ---------------------------------------------------------------------------
@router.get(
"/",
response_model=List[ScheduleTypeResponse],
summary="List all schedule types",
)
def list_schedule_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
return [_attach_derived(st) for st in db.query(ScheduleType).all()]
@router.post(
"/",
response_model=ScheduleTypeResponse,
summary="Create a schedule type",
)
def create_schedule_type(
payload: ScheduleTypeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
if existing:
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
st = ScheduleType(
name=payload.name,
work_from=payload.work_from,
work_to=payload.work_to,
entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
)
db.add(st)
db.commit()
db.refresh(st)
return _attach_derived(st)
@router.patch(
"/{schedule_type_id}",
response_model=ScheduleTypeResponse,
summary="Update a schedule type",
)
def update_schedule_type(
schedule_type_id: int,
payload: ScheduleTypeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
update_fields = payload.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(st, field, value)
# Re-validate maintenance after merge (partial updates can put the row
# into an invalid window combo that the pydantic schema couldn't catch
# because it only saw one field).
from app.schemas.schedule_type import _validate_maintenance_window
try:
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
except ValueError as e:
db.rollback()
raise HTTPException(422, str(e))
db.commit()
db.refresh(st)
return _attach_derived(st)
@router.delete(
"/{schedule_type_id}",
summary="Delete a schedule type",
)
def delete_schedule_type(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
# Check if any agents are using this schedule type
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
if agents_using > 0:
raise HTTPException(
409,
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
)
db.delete(st)
db.commit()
return {"ok": True, "deleted": schedule_type_id}
# ---------------------------------------------------------------------------
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
# ---------------------------------------------------------------------------
@router.get(
"/agent/me",
response_model=ScheduleTypeResponse | None,
summary="Get my schedule type",
)
def get_my_schedule_type(
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = (
db.query(Agent)
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
.first()
)
if not agent:
raise HTTPException(404, "Agent not found")
if not agent.schedule_type_id:
return None
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
@router.put(
"/agent/{agent_id}/assign",
summary="Assign a schedule type to an agent",
)
def assign_schedule_type(
agent_id: str,
payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
if not agent:
raise HTTPException(404, f"Agent '{agent_id}' not found")
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
if not st:
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
agent.schedule_type_id = st.id
db.commit()
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}

View File

@@ -0,0 +1,226 @@
"""Special-slot CRUD for a ScheduleType (admin-only).
A "special slot" is a recurring slot template tied to a ScheduleType.
The system materialises one `time_slots` row per agent on that
schedule_type per date, scheduled inside the schedule_type's
maintenance window. Materialised rows are `is_admin_locked=true` —
agents can complete / abort / pause / resume them but cannot move
or cancel them.
All endpoints require `schedule_type.manage` (admin auto-grants).
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.role_permission import Permission, RolePermission
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
from app.schemas.schedule_type_special_slot import (
SpecialSlotCreate,
SpecialSlotUpdate,
SpecialSlotResponse,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers — mirror schedule_type.py's local helpers so this router
# doesn't have to depend on internal symbols of the other router.
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, f"ScheduleType {schedule_type_id} not found")
return st
def _validate_fits_window(
minute_in_window: int,
estimated_duration: int,
maintenance_duration_minutes: int,
) -> None:
"""Reject special slots that wouldn't fit inside the parent's maintenance window."""
if minute_in_window + estimated_duration > maintenance_duration_minutes:
raise HTTPException(
422,
(
f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > "
f"maintenance window {maintenance_duration_minutes}min"
),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/{schedule_type_id}/special-slots",
response_model=List[SpecialSlotResponse],
summary="List special slots for a schedule type",
)
def list_special_slots(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
_fetch_schedule_type(db, schedule_type_id)
return (
db.query(ScheduleTypeSpecialSlot)
.filter(ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id)
.order_by(
ScheduleTypeSpecialSlot.minute_in_window.asc(),
ScheduleTypeSpecialSlot.id.asc(),
)
.all()
)
@router.post(
"/{schedule_type_id}/special-slots",
response_model=SpecialSlotResponse,
summary="Create a special slot for a schedule type (admin)",
)
def create_special_slot(
schedule_type_id: int,
payload: SpecialSlotCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_duration())
dup = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
ScheduleTypeSpecialSlot.name == payload.name,
)
.first()
)
if dup:
raise HTTPException(
409,
f"special slot '{payload.name}' already exists for schedule_type {schedule_type_id}",
)
slot = ScheduleTypeSpecialSlot(
schedule_type_id=schedule_type_id,
name=payload.name,
description=payload.description,
minute_in_window=payload.minute_in_window,
estimated_duration=payload.estimated_duration,
priority=payload.priority,
event_data=payload.event_data,
is_active=payload.is_active,
created_by_user_id=current_user.id,
)
db.add(slot)
db.commit()
db.refresh(slot)
return slot
@router.patch(
"/{schedule_type_id}/special-slots/{slot_id}",
response_model=SpecialSlotResponse,
summary="Update a special slot (admin)",
)
def update_special_slot(
schedule_type_id: int,
slot_id: int,
payload: SpecialSlotUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
parent = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
for field, value in update_fields.items():
setattr(slot, field, value)
db.commit()
db.refresh(slot)
return slot
@router.delete(
"/{schedule_type_id}/special-slots/{slot_id}",
summary="Delete a special slot (admin)",
)
def delete_special_slot(
schedule_type_id: int,
slot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
db.delete(slot)
db.commit()
return {"ok": True, "deleted": slot_id}

View File

@@ -8,8 +8,8 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.core.config import get_db, settings
from app.init_bootstrap import DELETED_USER_USERNAME
from app.models import models
from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission
@@ -32,12 +32,18 @@ def _user_response(user: models.User) -> dict:
"role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
"created_at": user.created_at,
}
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
@@ -66,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()
@@ -110,8 +134,14 @@ 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)
hashed_password = get_password_hash(user.password) if user.password else None
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.
if settings.HARBORFORGE_OIDC_ONLY:
hashed_password = None
else:
hashed_password = get_password_hash(user.password) if user.password else None
db_user = models.User(
username=user.username,
email=user.email,
@@ -191,7 +221,7 @@ def update_user(
if payload.full_name is not None:
user.full_name = payload.full_name
if payload.password is not None and payload.password.strip():
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
user.hashed_password = get_password_hash(payload.password)
if payload.role_id is not None:
@@ -213,6 +243,71 @@ def update_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}
@@ -318,7 +413,7 @@ def delete_user(
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Run init_wizard first.",
detail="Built-in deleted-user account not found. Backend startup failed to seed it; restart the container.",
)
_reassign_user_references(db, user.id, deleted_user.id)
@@ -414,3 +509,92 @@ def list_user_worklogs(
if current_user.id != user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
# ---- OIDC identity binding ------------------------------------------------
class OidcBindingRequest(BaseModel):
issuer: str
subject: str
class OidcBindingResponse(BaseModel):
user_id: int
username: str
oidc_issuer: str | None = None
oidc_subject: str | None = None
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
"""Global admins may (un)bind anyone. Non-admin account managers may
only operate on non-privileged accounts — never on an admin or another
privileged account — otherwise binding an attacker-controlled OIDC
identity to an admin would be a privilege-escalation primitive."""
if getattr(caller, "is_admin", False):
return
privileged = (
getattr(target, "is_admin", False)
or target.username in ("acc-mgr", "deleted-user")
or _has_global_permission(db, target, "account.create")
or _has_global_permission(db, target, "user.reset-apikey")
)
if privileged:
raise HTTPException(
status_code=403,
detail="Only a global admin may manage the OIDC binding of a privileged account",
)
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def bind_user_oidc(
identifier: str,
payload: OidcBindingRequest,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Bind an hf user to an external OIDC identity (issuer + subject).
Admin or account-manager (JWT or API key). Account managers may not
target privileged/admin accounts. One OIDC identity maps to at most
one user."""
issuer = (payload.issuer or "").strip()
subject = (payload.subject or "").strip()
if not issuer or not subject:
raise HTTPException(status_code=400, detail="issuer and subject are required")
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def unbind_user_oidc(
identifier: str,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Remove a user's OIDC binding. Admin or account-manager; account
managers may not target privileged/admin accounts."""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
user.oidc_issuer = None
user.oidc_subject = None
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=None, oidc_subject=None)

10
app/cli/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""hf-cli — operator commands run inside the backend container.
Subjects:
admin — bootstrap / manage the initial admin user
config — runtime config (OIDC, etc.)
Invoked via the shim at /usr/local/bin/hf-cli (Dockerfile-installed):
docker exec hf-backend hf-cli admin create-user --email me@example.com --password '...'
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ... --enabled true
"""

68
app/cli/__main__.py Normal file
View File

@@ -0,0 +1,68 @@
"""hf-cli entry point. Dispatches to subject-specific modules."""
import sys
def _load_all_models() -> None:
"""Import every model module so SQLAlchemy's declarative registry
resolves cross-table relationships (e.g. User.role, User.agent).
main.py's startup() does the same thing for the web server; the CLI
skips startup() but still queries User → would otherwise hit
`KeyError: 'Agent'` when SA tries to resolve relationship targets.
Keep this list in sync with main.py's startup import list.
"""
from app.models import ( # noqa: F401
models, webhook, apikey, activity, milestone, notification, worklog,
monitor, role_permission, task, support, meeting, proposal, propose,
essential, agent, calendar, minimum_workload, schedule_type,
schedule_type_special_slot, oidc_settings,
)
_load_all_models()
USAGE = """Usage:
hf-cli admin create-user --email <e> [--username <u>] [--full-name <n>]
[--password <p>] [--oidc-issuer <url> --oidc-subject <sub>]
hf-cli admin list
hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
hf-cli admin reset-password --username <u> --password <p>
hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
hf-cli config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]
[--redirect-uri <url>] [--post-login-redirect <url>]
[--scopes "openid email profile"] [--admin-role <role>]
[--enabled true|false] [--show-secret]
Reads DATABASE_URL + SECRET_KEY from the same env as the backend. Run
inside the backend container: `docker exec hf-backend hf-cli ...`.
"""
def main() -> int:
args = sys.argv[1:]
if len(args) < 1:
sys.stderr.write(USAGE)
return 1
subject = args[0]
rest = args[1:]
if subject == "admin":
from app.cli import admin
return admin.dispatch(rest)
if subject == "config":
from app.cli import config
return config.dispatch(rest)
if subject in ("-h", "--help", "help"):
sys.stdout.write(USAGE)
return 0
sys.stderr.write(f"unknown subject: {subject}\n\n")
sys.stderr.write(USAGE)
return 1
if __name__ == "__main__":
sys.exit(main())

269
app/cli/admin.py Normal file
View File

@@ -0,0 +1,269 @@
"""hf-cli admin … — bootstrap and manage the deployment's admin user."""
import argparse
import json
import sys
from sqlalchemy.exc import IntegrityError
from app.api.deps import get_password_hash
from app.core.config import SessionLocal, settings
from app.models import models
from app.models.role_permission import Role
def _open_db():
return SessionLocal()
def _emit(payload: dict) -> None:
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
# ---------------------------------------------------------------------------
# create-user
# ---------------------------------------------------------------------------
def _cmd_create_user(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog="hf-cli admin create-user")
p.add_argument("--email", required=True)
p.add_argument("--username", default=None,
help="Defaults to email's local-part if omitted.")
p.add_argument("--full-name", default="Admin")
p.add_argument("--password", default=None,
help="Required when HARBORFORGE_OIDC_ONLY=false. Ignored "
"when OIDC_ONLY=true (use --oidc-issuer/--oidc-subject).")
p.add_argument("--oidc-issuer", default=None,
help="Bind the new admin to this OIDC issuer at creation. "
"Required in OIDC_ONLY mode for the bootstrap admin.")
p.add_argument("--oidc-subject", default=None,
help="OIDC subject claim (sub) to bind the new admin to.")
args = p.parse_args(argv)
username = args.username or args.email.split("@", 1)[0]
oidc_only = bool(settings.HARBORFORGE_OIDC_ONLY)
if oidc_only:
if not (args.oidc_issuer and args.oidc_subject):
sys.stderr.write(
"HARBORFORGE_OIDC_ONLY=true: must pass --oidc-issuer and "
"--oidc-subject so the new admin can sign in.\n"
)
return 2
hashed_password = None
else:
if not args.password:
sys.stderr.write("--password is required when OIDC_ONLY is false.\n")
return 2
hashed_password = get_password_hash(args.password)
if (args.oidc_issuer and not args.oidc_subject) or (args.oidc_subject and not args.oidc_issuer):
sys.stderr.write("--oidc-issuer and --oidc-subject must be passed together.\n")
return 2
db = _open_db()
try:
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
sys.stderr.write(f"user '{username}' already exists (id={existing.id})\n")
return 3
admin_role = db.query(Role).filter(Role.name == "admin").first()
if not admin_role:
sys.stderr.write(
"admin role not found — backend startup seed should create it. "
"Restart the container then retry.\n"
)
return 4
user = models.User(
username=username,
email=args.email,
full_name=args.full_name,
hashed_password=hashed_password,
is_admin=True,
is_active=True,
role_id=admin_role.id,
oidc_issuer=(args.oidc_issuer or None),
oidc_subject=(args.oidc_subject or None),
)
db.add(user)
try:
db.commit()
except IntegrityError as e:
db.rollback()
sys.stderr.write(f"DB integrity error: {e.orig}\n")
return 5
db.refresh(user)
_emit({
"ok": True,
"created": True,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_admin": user.is_admin,
"role_id": user.role_id,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
"has_password": user.hashed_password is not None,
},
})
return 0
finally:
db.close()
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
def _cmd_list(_argv: list[str]) -> int:
db = _open_db()
try:
admins = (
db.query(models.User)
.filter(models.User.is_admin == True) # noqa: E712
.order_by(models.User.id.asc())
.all()
)
_emit({
"ok": True,
"count": len(admins),
"admins": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"is_active": u.is_active,
"oidc_bound": bool(u.oidc_issuer and u.oidc_subject),
"has_password": u.hashed_password is not None,
}
for u in admins
],
})
return 0
finally:
db.close()
# ---------------------------------------------------------------------------
# set-role
# ---------------------------------------------------------------------------
def _cmd_set_role(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog="hf-cli admin set-role")
p.add_argument("--username", required=True)
p.add_argument("--role", required=True)
args = p.parse_args(argv)
db = _open_db()
try:
user = db.query(models.User).filter(models.User.username == args.username).first()
if not user:
sys.stderr.write(f"user '{args.username}' not found\n")
return 3
role = db.query(Role).filter(Role.name == args.role).first()
if not role:
sys.stderr.write(f"role '{args.role}' not found\n")
return 4
user.role_id = role.id
user.is_admin = (args.role == "admin")
db.commit()
_emit({
"ok": True,
"user": {"id": user.id, "username": user.username, "role": role.name, "is_admin": user.is_admin},
})
return 0
finally:
db.close()
# ---------------------------------------------------------------------------
# reset-password
# ---------------------------------------------------------------------------
def _cmd_reset_password(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog="hf-cli admin reset-password")
p.add_argument("--username", required=True)
p.add_argument("--password", required=True)
args = p.parse_args(argv)
if settings.HARBORFORGE_OIDC_ONLY:
sys.stderr.write("HARBORFORGE_OIDC_ONLY=true: password login is disabled.\n")
return 2
db = _open_db()
try:
user = db.query(models.User).filter(models.User.username == args.username).first()
if not user:
sys.stderr.write(f"user '{args.username}' not found\n")
return 3
user.hashed_password = get_password_hash(args.password)
db.commit()
_emit({"ok": True, "user": {"id": user.id, "username": user.username, "password_reset": True}})
return 0
finally:
db.close()
# ---------------------------------------------------------------------------
# bind-oidc — attach an OIDC identity to an existing admin
# ---------------------------------------------------------------------------
def _cmd_bind_oidc(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog="hf-cli admin bind-oidc")
p.add_argument("--username", required=True)
p.add_argument("--oidc-issuer", required=True)
p.add_argument("--oidc-subject", required=True)
args = p.parse_args(argv)
db = _open_db()
try:
user = db.query(models.User).filter(models.User.username == args.username).first()
if not user:
sys.stderr.write(f"user '{args.username}' not found\n")
return 3
clash = db.query(models.User).filter(
models.User.oidc_issuer == args.oidc_issuer,
models.User.oidc_subject == args.oidc_subject,
models.User.id != user.id,
).first()
if clash:
sys.stderr.write(f"OIDC subject already bound to '{clash.username}' (id={clash.id})\n")
return 4
user.oidc_issuer = args.oidc_issuer
user.oidc_subject = args.oidc_subject
db.commit()
_emit({
"ok": True,
"user": {
"id": user.id,
"username": user.username,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
},
})
return 0
finally:
db.close()
# ---------------------------------------------------------------------------
# dispatcher
# ---------------------------------------------------------------------------
ACTIONS = {
"create-user": _cmd_create_user,
"list": _cmd_list,
"set-role": _cmd_set_role,
"reset-password": _cmd_reset_password,
"bind-oidc": _cmd_bind_oidc,
}
def dispatch(argv: list[str]) -> int:
if not argv:
sys.stderr.write("admin: missing action; one of: " + ", ".join(ACTIONS) + "\n")
return 1
action, rest = argv[0], argv[1:]
fn = ACTIONS.get(action)
if not fn:
sys.stderr.write(f"admin: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
return 1
return fn(rest)

108
app/cli/config.py Normal file
View File

@@ -0,0 +1,108 @@
"""hf-cli config … — runtime configuration stored in DB.
Currently only the OIDC provider config has a CLI surface (it used to
live in the AbstractWizard config). Mirrors dialectic-cli's
`config oidc` shape: only the flags you pass are mutated, the rest stays
unchanged. Prints the post-update row with client_secret masked unless
--show-secret is given.
"""
import argparse
import json
import sys
from app.core.config import SessionLocal
from app.models.oidc_settings import OidcSettings
def _emit(payload: dict) -> None:
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
def _bool(v: str) -> bool:
return v.lower() in ("1", "true", "yes", "on")
def _cmd_oidc(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog="hf-cli config oidc")
p.add_argument("--issuer", default=None)
p.add_argument("--client-id", default=None)
p.add_argument("--client-secret", default=None)
p.add_argument("--redirect-uri", default=None)
p.add_argument("--post-login-redirect", default=None)
p.add_argument("--scopes", default=None,
help='Default: "openid email profile"')
p.add_argument("--admin-role", default=None,
help="OIDC role name that bootstraps an unbound hf admin "
"on first OIDC-only login. Default: admin.")
p.add_argument("--enabled", default=None,
help="true|false. Without this flag the row's existing "
"value is preserved.")
p.add_argument("--show-secret", action="store_true",
help="Reveal client_secret in the output (local audit "
"only — never paste into chat).")
args = p.parse_args(argv)
db = SessionLocal()
try:
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if row is None:
row = OidcSettings(id=1, enabled=False)
db.add(row)
if args.issuer is not None:
row.issuer = args.issuer.strip() or None
if args.client_id is not None:
row.client_id = args.client_id.strip() or None
if args.client_secret is not None:
row.client_secret = args.client_secret or None
if args.redirect_uri is not None:
row.redirect_uri = args.redirect_uri.strip() or None
if args.post_login_redirect is not None:
row.post_login_redirect = args.post_login_redirect.strip() or None
if args.scopes is not None:
row.scopes = args.scopes.strip() or None
if args.admin_role is not None:
row.admin_role = args.admin_role.strip() or None
if args.enabled is not None:
row.enabled = _bool(args.enabled)
db.commit()
db.refresh(row)
out: dict = {
"enabled": bool(row.enabled),
"issuer": row.issuer,
"client_id": row.client_id,
"redirect_uri": row.redirect_uri,
"post_login_redirect": row.post_login_redirect,
"scopes": row.scopes,
"admin_role": row.admin_role,
}
if args.show_secret:
out["client_secret"] = row.client_secret
elif row.client_secret:
out["client_secret"] = "***set***"
else:
out["client_secret"] = None
_emit({"ok": True, "config": out})
return 0
finally:
db.close()
ACTIONS = {
"oidc": _cmd_oidc,
}
def dispatch(argv: list[str]) -> int:
if not argv:
sys.stderr.write("config: missing action; one of: " + ", ".join(ACTIONS) + "\n")
return 1
action, rest = argv[0], argv[1:]
fn = ACTIONS.get(action)
if not fn:
sys.stderr.write(f"config: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
return 1
return fn(rest)

View File

@@ -1,34 +1,13 @@
import os
import json
"""Backend runtime settings — env-only (no wizard / no config volume).
OIDC issuer/client_id/etc. live in the `oidc_settings` DB table set
via `hf-cli config oidc ...`. The OIDC_ONLY flag remains env-driven
because it's a deploy-time policy, not a per-tenant runtime config.
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pydantic_settings import BaseSettings
from typing import Optional
def _resolve_db_url(env_url: str) -> str:
"""Read DB config from wizard config volume if available, else use env."""
config_dir = os.getenv("CONFIG_DIR", "/config")
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
config_path = os.path.join(config_dir, config_file)
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
cfg = json.load(f)
db_cfg = cfg.get("database")
if db_cfg:
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
except Exception:
pass
return env_url
class Settings(BaseSettings):
@@ -38,6 +17,11 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless users that only sign
# in via a bound OIDC identity / API keys), frontend hides password UI.
HARBORFORGE_OIDC_ONLY: bool = False
class Config:
env_file = ".env"
@@ -60,9 +44,7 @@ if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
"Refusing to start with a default/short key."
)
# Resolve DB URL: wizard config volume > env > default
_db_url = _resolve_db_url(settings.DATABASE_URL)
engine = create_engine(_db_url, pool_pre_ping=True)
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View File

@@ -1,105 +1,49 @@
"""
HarborForge initialization from AbstractWizard config volume.
HarborForge unconditional startup seeds runs every time backend boots.
Reads config from shared volume (written by AbstractWizard).
On startup, creates admin user and default project if not exists.
Seeds default permissions, default roles, the `acc-mgr` built-in (account
provisioning agent), and the `deleted-user` foreign-key sink. Idempotent;
existing rows are left alone.
Wizard/.json config bootstrap has been removed entirely as of v0.4.0.
First-deploy admin user, OIDC settings, and discord webhook config all
moved to operator-driven flows:
docker exec hf-backend hf-cli admin create-user --email ... --password ...
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ...
Builtin accounts created here:
- acc-mgr (account-manager role) cannot log in, used by the
account-creation API as a system principal
- deleted-user FK sink so user delete doesn't cascade
The bootstrap admin user is NOT created here that's CLI-driven so
operators pick the email/password themselves.
"""
import os
import json
import logging
from sqlalchemy.orm import Session
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.init")
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
logger = logging.getLogger("harborforge.bootstrap")
def load_config() -> dict | None:
"""Load initialization config from shared volume."""
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
if not os.path.exists(config_path):
logger.info("No config file at %s, skipping initialization", config_path)
return None
try:
with open(config_path, "r") as f:
return json.load(f)
except Exception as e:
logger.warning("Failed to read config %s: %s", config_path, e)
return None
def get_db_url(config: dict) -> str | None:
"""Build DATABASE_URL from wizard config, or fall back to env."""
db_cfg = config.get("database")
if not db_cfg:
return os.getenv("DATABASE_URL")
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
"""Create admin user if not exists."""
username = admin_cfg.get("username", "admin")
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("Admin user '%s' already exists (id=%d), skipping", username, existing.id)
return existing
password = admin_cfg.get("password", "changeme")
user = models.User(
username=username,
email=admin_cfg.get("email", f"{username}@harborforge.local"),
full_name=admin_cfg.get("full_name", "Admin"),
hashed_password=get_password_hash(password),
is_admin=True,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created admin user '%s' (id=%d)", username, user.id)
return user
def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
"""Create default project if configured and not exists."""
name = project_cfg.get("name")
if not name:
return
existing = db.query(models.Project).filter(models.Project.name == name).first()
if existing:
logger.info("Project '%s' already exists (id=%d), skipping", name, existing.id)
return
project = models.Project(
name=name,
description=project_cfg.get("description", ""),
owner_name=project_cfg.get("owner") or owner_name or "",
owner_id=owner_id,
)
db.add(project)
db.commit()
db.refresh(project)
logger.info("Created default project '%s' (id=%d)", name, project.id)
# Default permissions that will be created if not exist
# ---------------------------------------------------------------------------
# Permissions catalog (canonical; new perms get added on every release)
# ---------------------------------------------------------------------------
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"),
# Knowledge base permissions
("knowledge-base.read", "View knowledge bases", "knowledge-base"),
("knowledge-base.create", "Create a knowledge base", "knowledge-base"),
("knowledge-base.update", "Edit a knowledge base and its structure", "knowledge-base"),
("knowledge-base.delete", "Delete a knowledge base", "knowledge-base"),
# Task/Milestone permissions
("task.create", "Create tasks", "task"),
("task.read", "View tasks", "task"),
@@ -138,11 +82,17 @@ 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"),
]
def init_default_permissions(db: Session) -> list[Permission]:
"""Create default permissions if they don't exist. Returns all permissions."""
"""Insert any missing perms from DEFAULT_PERMISSIONS. Returns all rows."""
created = []
for name, description, category in DEFAULT_PERMISSIONS:
existing = db.query(Permission).filter(Permission.name == name).first()
@@ -151,21 +101,17 @@ def init_default_permissions(db: Session) -> list[Permission]:
db.add(perm)
created.append(perm)
logger.info("Created permission '%s'", name)
if created:
db.commit()
# Return all permissions
return db.query(Permission).all()
# ---------------------------------------------------------------------------
# Default role permission mapping
# Default roles + permission set per role
# ---------------------------------------------------------------------------
# mgr: project management + all milestone/task/proposal actions
_MGR_PERMISSIONS = {
"project.read", "project.write", "project.manage_members",
"project.read", "project.write", "project.create", "project.manage_members",
"knowledge-base.read", "knowledge-base.create", "knowledge-base.update", "knowledge-base.delete",
"task.create", "task.read", "task.write", "task.delete",
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
"milestone.freeze", "milestone.start", "milestone.close",
@@ -176,9 +122,9 @@ _MGR_PERMISSIONS = {
"user.reset-self-apikey",
}
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal
_DEV_PERMISSIONS = {
"project.read",
"knowledge-base.read", "knowledge-base.update",
"task.create", "task.read", "task.write",
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
@@ -192,18 +138,32 @@ _ACCOUNT_MANAGER_PERMISSIONS = {
"user.reset-apikey",
}
# Role definitions: (name, description, permission_set)
# 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",
"knowledge-base.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
("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),
("guest", "Guest - read-only access", None), # special: *.read only
("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_PERMISSIONS),
("guest", "Guest - read-only access", None), # special: *.read only
]
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role:
"""Get or create a role by name."""
role = db.query(Role).filter(Role.name == name).first()
if not role:
role = Role(name=name, description=description, is_global=is_global)
@@ -215,13 +175,8 @@ def _ensure_role(db: Session, name: str, description: str, is_global: bool = Tru
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
"""Ensure *role* has exactly the permissions in *target_perm_names*.
* ``None`` means **all** permissions (admin).
* The special sentinel ``"__read_only__"`` is handled by the caller passing
just the ``*.read`` names.
Only adds missing permissions; never removes manually-granted ones (additive).
"""
"""Additive: grants missing perms, never revokes manually-granted ones.
``target_perm_names is None`` means **all** perms (admin)."""
all_perms = db.query(Permission).all()
perm_by_name = {p.name: p for p in all_perms}
@@ -235,45 +190,40 @@ def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str]
for pid in wanted_ids - existing_ids:
db.add(RolePermission(role_id=role.id, permission_id=pid))
added += 1
if added:
db.commit()
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
def init_admin_role(db: Session, admin_user: models.User) -> None:
"""Create default roles (admin / mgr / dev / guest) with preset permissions."""
def init_default_roles(db: Session) -> None:
"""Create default roles (admin/account-manager/mgr/dev/guest) + permissions."""
all_perms = db.query(Permission).all()
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
for name, description, perm_set in _DEFAULT_ROLES:
role = _ensure_role(db, name, description)
if name == "guest":
_sync_role_permissions(db, role, read_perm_names)
else:
_sync_role_permissions(db, role, perm_set)
logger.info("Default roles ready (admin / account-manager / mgr / dev / guest)")
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
# ---------------------------------------------------------------------------
# Built-in user accounts (system principals, cannot log in)
# ---------------------------------------------------------------------------
DELETED_USER_USERNAME = "deleted-user"
def init_acc_mgr_user(db: Session) -> models.User | None:
"""Create the built-in acc-mgr user if not exists.
This user:
- Has role 'account-manager' (can only create accounts)
- Cannot log in (no password, hashed_password=None)
- Cannot be deleted (enforced in delete endpoint)
- Is created automatically after wizard initialization
"""
"""The account-manager system principal. Holds the `account-manager`
role so the account-creation API can attribute new users to it. No
password, no OIDC binding cannot log in."""
username = "acc-mgr"
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
return existing
# Find account-manager role
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
if not acc_mgr_role:
logger.warning("account-manager role not found, skipping acc-mgr user creation")
@@ -283,7 +233,7 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
username=username,
email="acc-mgr@harborforge.internal",
full_name="Account Manager",
hashed_password=None, # Cannot log in — no password
hashed_password=None,
is_admin=False,
is_active=True,
role_id=acc_mgr_role.id,
@@ -295,21 +245,13 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
"""FK sink for deleted users — when a real user is deleted, all FK
references reassign here instead of cascading."""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
@@ -328,36 +270,17 @@ def init_deleted_user(db: Session) -> models.User | None:
return user
def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
if not config:
return
# ---------------------------------------------------------------------------
# Top-level bootstrap entry point — called from main.py startup
# ---------------------------------------------------------------------------
def run_bootstrap(db: Session) -> None:
"""Idempotent startup seed. Safe to call on every boot.
logger.info("Running HarborForge initialization from wizard config")
# Initialize default permissions and admin role (always run)
all_perms = init_default_permissions(db)
logger.info("Default permissions initialized: %d total", len(all_perms))
# Admin user
admin_cfg = config.get("admin")
admin_user = None
if admin_cfg:
admin_user = init_admin_user(db, admin_cfg)
# Create admin role and assign to admin user
if admin_user:
init_admin_role(db, admin_user)
# Built-in acc-mgr user (after roles are created)
Does NOT create the admin user that's CLI-driven (see hf-cli admin
create-user) so operators pick credentials.
"""
init_default_permissions(db)
init_default_roles(db)
init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
logger.info("Initialization complete")
logger.info("Bootstrap seeds complete")

View File

@@ -1,6 +1,9 @@
"""HarborForge API — Agent/人类协同任务管理平台"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.core.config import settings
app = FastAPI(
title="HarborForge API",
@@ -17,6 +20,17 @@ app.add_middleware(
allow_headers=["*"],
)
# Short-lived signed session cookie — only used to carry the OIDC
# state/nonce between /auth/oidc/login and the callback.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc",
same_site="lax",
https_only=False,
max_age=600,
)
# Health & version (kept at top level)
@app.get("/health", tags=["System"])
def health_check():
@@ -28,24 +42,22 @@ def version():
@app.get("/config/status", tags=["System"])
def config_status():
"""Check if HarborForge has been initialized (reads from config volume).
Frontend uses this instead of contacting the wizard directly."""
import os, json
config_dir = os.getenv("CONFIG_DIR", "/config")
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
config_path = os.path.join(config_dir, config_file)
if not os.path.exists(config_path):
return {"initialized": False}
"""Has the deployment been bootstrapped (admin user exists)?
Frontend hits this on mount to decide whether to show login or a
"no admin yet, run hf-cli admin create-user" placeholder. With the
wizard removed in v0.4.0 the only deploy-time bootstrap step is the
operator running `docker exec hf-backend hf-cli admin create-user ...`
once; this endpoint just reports whether that has happened.
"""
from app.core.config import SessionLocal
from app.models import models
db = SessionLocal()
try:
with open(config_path, "r") as f:
cfg = json.load(f)
return {
"initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
}
except Exception:
return {"initialized": False}
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
return {"initialized": admin_count > 0}
finally:
db.close()
# Register routers
from app.api.routers.auth import router as auth_router
@@ -63,9 +75,14 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
from app.api.routers.schedule_type import router as schedule_type_router
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router
from app.api.routers.knowledge import router as knowledge_router
app.include_router(auth_router)
app.include_router(oidc_router)
app.include_router(tasks_router)
app.include_router(projects_router)
app.include_router(users_router)
@@ -80,7 +97,10 @@ app.include_router(proposes_router) # legacy compat
app.include_router(milestone_actions_router)
app.include_router(meetings_router)
app.include_router(essentials_router)
app.include_router(schedule_type_router)
app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router)
app.include_router(knowledge_router)
# Auto schema migration for lightweight deployments
@@ -275,6 +295,18 @@ def _migrate_schema():
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
# --- users OIDC binding (issuer + subject), unique together ---
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
# --- oidc_settings.admin_role (added after the table shipped) ---
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
# --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
@@ -363,6 +395,67 @@ def _migrate_schema():
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
# --- agents: add schedule_type_id FK ---
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the maintenance
# duration invariant (1-180min) is enforced at the schema
# level for any NEW rows by ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
))
if not _has_column(db, "schedule_types", "maintenance_to"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
))
# --- minutes-since-midnight migration (PR #21+) ---
# The 6 schedule_type window columns used to hold *hours*
# (0-23). PR #21 changed semantics to *minutes since UTC
# midnight* (0-1439). Detect the legacy regime by checking
# if ANY row has all 6 values ≤ 23 — if so, multiply each
# by 60 to convert. Idempotent: post-conversion values are
# all ≥ 0 and usually well above 23, so guard never fires
# twice.
row = db.execute(text(
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
"FROM schedule_types"
)).fetchone()
if row is not None and row.m is not None and row.m <= 23:
db.execute(text(
"UPDATE schedule_types SET "
" work_from = work_from * 60, "
" work_to = work_to * 60, "
" entertainment_from = entertainment_from * 60, "
" entertainment_to = entertainment_to * 60, "
" maintenance_from = maintenance_from * 60, "
" maintenance_to = maintenance_to * 60"
))
# --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
))
if not _has_column(db, "time_slots", "special_slot_id"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
))
# Index for the materialiser's idempotency lookup
db.execute(text(
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
))
# --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps
# to that table go in this block.
db.commit()
except Exception as e:
db.rollback()
@@ -397,15 +490,17 @@ def _sync_default_user_roles(db):
@app.on_event("startup")
def startup():
from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings, knowledge
Base.metadata.create_all(bind=engine)
_migrate_schema()
# Initialize from AbstractWizard (admin user, default project, etc.)
from app.init_wizard import run_init
# Idempotent startup seed: permissions, default roles, built-in
# accounts (acc-mgr, deleted-user). The admin user + OIDC config are
# NOT created here — they're operator-driven via hf-cli.
from app.init_bootstrap import run_bootstrap
db = SessionLocal()
try:
run_init(db)
run_bootstrap(db)
_sync_default_user_roles(db)
finally:
db.close()

View File

@@ -131,6 +131,15 @@ class Agent(Base):
comment="rate_limit | billing — why the agent is exhausted",
)
# -- schedule type ------------------------------------------------------
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id"),
nullable=True,
comment="FK to schedule_types — defines work/entertainment periods",
)
# -- timestamps ---------------------------------------------------------
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -138,3 +147,4 @@ class Agent(Base):
# -- relationships ------------------------------------------------------
user = relationship("User", back_populates="agent", uselist=False)
schedule_type = relationship("ScheduleType", lazy="joined")

View File

@@ -178,11 +178,37 @@ class TimeSlot(Base):
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
)
# -----------------------------------------------------------------
# Admin-locked slots are materialised from a ScheduleTypeSpecialSlot
# template. The agent can complete / abort / pause / resume them but
# cannot edit their time, type, duration, or cancel them outright —
# the slot exists because admin decided every agent on the parent
# schedule_type should run it. See `_apply_agent_slot_update` for
# the enforcement.
# -----------------------------------------------------------------
is_admin_locked = Column(
Boolean,
nullable=False,
server_default="0",
comment="True for slots materialised from a schedule_type special slot template.",
)
# Pointer back to the template that materialised this slot. NULL for
# all user-created or plan-generated slots. Lets us cascade updates
# and surface 'why is this on my calendar' to the agent.
special_slot_id = Column(
Integer,
ForeignKey("schedule_type_special_slots.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ----------------------------------------------------------
plan = relationship("SchedulePlan", back_populates="materialized_slots")
special_slot = relationship("ScheduleTypeSpecialSlot")
# ---------------------------------------------------------------------------

101
app/models/knowledge.py Normal file
View File

@@ -0,0 +1,101 @@
"""Knowledge Base models.
Mirrors the Project feature's shape (human-friendly *code*, creator FK,
created/updated timestamps). Hierarchy is:
knowledge_base
└─ knowledge_topic (unique per (topic, knowledge_base_id))
├─ knowledge_fact (category_id NULL → fact lives on the topic)
└─ knowledge_category (parent NULL → top-level category in topic)
├─ knowledge_fact
└─ knowledge_category (parent → nested)
`project_knowledge_base` is the M2M link between projects and knowledge bases.
Relationships are intentionally kept minimal (no ORM cascade on the
self-referential category tree); deletion ordering is handled explicitly in
the router to stay clear of FK-ordering surprises under MySQL.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class KnowledgeBase(Base):
__tablename__ = "knowledge_bases"
id = Column(Integer, primary_key=True, index=True)
knowledge_base_code = Column(String(16), unique=True, index=True, nullable=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
creator = relationship("User", foreign_keys=[created_by])
class KnowledgeTopic(Base):
__tablename__ = "knowledge_topics"
__table_args__ = (
UniqueConstraint("topic", "knowledge_base_id", name="uq_knowledge_topic_kb"),
)
id = Column(Integer, primary_key=True, index=True)
topic = Column(String(200), nullable=False)
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
description = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
creator = relationship("User", foreign_keys=[created_by])
class KnowledgeCategory(Base):
__tablename__ = "knowledge_categories"
__table_args__ = (
# NOTE: MySQL treats NULLs as distinct in a UNIQUE index, so this only
# enforces uniqueness for non-NULL `parent`. Top-level categories
# (parent IS NULL) are de-duped in the router (application-level check).
UniqueConstraint("topic_id", "parent", "name", name="uq_knowledge_category_triple"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
parent = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
description = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
last_updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
class KnowledgeFact(Base):
__tablename__ = "knowledge_facts"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
fact = Column(Text, nullable=False)
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ProjectKnowledgeBase(Base):
__tablename__ = "project_knowledge_bases"
__table_args__ = (
UniqueConstraint("project_id", "knowledge_base_id", name="uq_project_knowledge_base"),
)
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
class KnowledgeBaseCodeCounter(Base):
__tablename__ = "knowledge_base_code_counters"
id = Column(Integer, primary_key=True, index=True)
prefix = Column(String(16), unique=True, index=True, nullable=False)
next_value = Column(Integer, default=0)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
@@ -66,6 +66,9 @@ class Project(Base):
class User(Base):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
)
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
@@ -73,6 +76,10 @@ class User(Base):
hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
# OIDC binding: an hf user is linked to at most one external OIDC identity
# (issuer + subject). Unique together so one IdP identity maps to one user.
oidc_issuer = Column(String(255), nullable=True)
oidc_subject = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.core.config import Base
class OidcSettings(Base):
"""Single-row (id=1) runtime OIDC configuration.
When a row exists its non-empty fields override the OIDC_* env vars,
so the provider can be configured from the admin UI without a redeploy.
"""
__tablename__ = "oidc_settings"
id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, default=False, nullable=False)
issuer = Column(String(255), nullable=True)
client_id = Column(String(255), nullable=True)
client_secret = Column(String(512), nullable=True)
redirect_uri = Column(String(512), nullable=True)
scopes = Column(String(255), nullable=True)
post_login_redirect = Column(String(512), nullable=True)
# OIDC role name that, in OIDC-only mode, auto-connects an unbound
# hf admin on first login (bootstrap). Default "admin".
admin_role = Column(String(128), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,83 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance
windows for agents who reference this type. All bounds are stored as
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
sub-hour boundaries are exact.
Maintenance window length is variable (1-180 minutes) and admin-owned;
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
maintenance window was hard-fixed at exactly 1 hour. The additive
migration in `_migrate_schema()` multiplies legacy values by 60 so
existing rows convert transparently.
"""
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleType(Base):
"""Work/entertainment/maintenance period definition."""
__tablename__ = "schedule_types"
id = Column(Integer, primary_key=True, index=True)
name = Column(
String(64),
nullable=False,
unique=True,
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
)
# Minutes since UTC midnight, 0-1439 inclusive.
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
# Maintenance window — admin-owned, variable length (1-180 min).
# Default 8:009:00 UTC = 480540 minutes for existing rows.
maintenance_from = Column(
Integer,
nullable=False,
server_default="480",
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
)
maintenance_to = Column(
Integer,
nullable=False,
server_default="540",
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
special_slots = relationship(
"ScheduleTypeSpecialSlot",
back_populates="schedule_type",
cascade="all, delete-orphan",
)
# ---------------------------------------------------------------
# Convenience methods used by the API layer + materialiser.
# ---------------------------------------------------------------
def compute_maintenance_duration(self) -> int:
"""Maintenance window length in minutes (handles 23→0 wrap)."""
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
if win_to > win_from:
return start_min < win_to and end_min > win_from
# wrap window crosses midnight: [win_from..1440) [0..win_to)
return start_min < win_to or end_min > win_from

View File

@@ -0,0 +1,116 @@
"""ScheduleTypeSpecialSlot — admin-managed slot template tied to a ScheduleType.
A "special slot" is a recurring slot template that the system materializes
into every matching agent's `time_slots` row each day. It exists for tasks
that admin wants to enforce across an entire schedule type cohort, e.g.:
* `plan-schedule` — daily planning slot all agents on this type must run
* `secret-rotation-window` — security maintenance
* `policy-update` — read updated agent policies
Rules:
* Only admins (`schedule_type.manage` permission) may create / edit /
delete special slots.
* The slot's `minute_in_window` offset must place it inside the parent
schedule_type's maintenance window (`maintenance_from..maintenance_from+59`).
* Materialised `time_slots` rows from a special slot carry
`is_admin_locked=true` so the agent-side `PATCH .../agent-update`
refuses status/time edits other than complete/abort/pause/resume.
* Materialisation produces one `time_slots` row per agent using this
schedule_type per date, with `slot_type=system`, `event_type=system_event`,
`event_data={"special_slot_id": <id>, "special_slot_name": "<name>",
"source": "schedule_type_special_slot", ...admin-supplied...}`.
"""
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, DateTime, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleTypeSpecialSlot(Base):
"""Admin-managed daily slot template attached to a ScheduleType."""
__tablename__ = "schedule_type_special_slots"
__table_args__ = (
# One slot template per (schedule_type, name) so admin can use the
# `name` field as a stable, human-readable identifier for the cohort.
UniqueConstraint("schedule_type_id", "name", name="uq_special_slot_type_name"),
)
id = Column(Integer, primary_key=True, index=True)
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(
String(64),
nullable=False,
comment="Short identifier, e.g. 'plan-schedule', 'secret-rotation'",
)
description = Column(
String(512),
nullable=True,
comment="Human-readable note on what this slot is for",
)
minute_in_window = Column(
Integer,
nullable=False,
server_default="0",
comment=(
"Minute offset (0-59) inside the schedule_type maintenance window. "
"The materialised time_slot's scheduled_at becomes "
"maintenance_from:minute_in_window:00 UTC."
),
)
estimated_duration = Column(
Integer,
nullable=False,
server_default="15",
comment="Duration in minutes. Must fit inside the maintenance window.",
)
priority = Column(
Integer,
nullable=False,
server_default="50",
comment="Wake priority — higher value wakes first if multiple slots are due.",
)
event_data = Column(
JSON,
nullable=True,
comment=(
"Admin-supplied JSON payload that gets merged into every "
"materialised slot's event_data. Use this to pass a workflow "
"tag, suggested_workload, or any other context the agent "
"should see in its wakeup message."
),
)
is_active = Column(
Boolean,
nullable=False,
server_default="1",
comment="Soft-disable without deleting; inactive templates are skipped during materialisation.",
)
created_by_user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
schedule_type = relationship("ScheduleType", back_populates="special_slots")

View File

@@ -144,6 +144,8 @@ class TimeSlotResponse(BaseModel):
priority: int
status: str
plan_id: Optional[int] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
@@ -226,6 +228,8 @@ class CalendarSlotItem(BaseModel):
priority: int
status: str
plan_id: Optional[int] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None

166
app/schemas/knowledge.py Normal file
View File

@@ -0,0 +1,166 @@
"""Pydantic schemas for the Knowledge Base feature."""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# --------------------------------------------------------------------------
# Knowledge Base
# --------------------------------------------------------------------------
class KnowledgeBaseBase(BaseModel):
title: str
description: Optional[str] = None
class KnowledgeBaseCreate(KnowledgeBaseBase):
pass
class KnowledgeBaseUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
class KnowledgeBaseResponse(BaseModel):
id: int
knowledge_base_code: Optional[str] = None
title: str
description: Optional[str] = None
created_by: int
created_at: Optional[datetime] = None
last_updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# --------------------------------------------------------------------------
# Topic
# --------------------------------------------------------------------------
class KnowledgeTopicBase(BaseModel):
topic: str
description: Optional[str] = None
class KnowledgeTopicCreate(KnowledgeTopicBase):
pass
class KnowledgeTopicUpdate(BaseModel):
topic: Optional[str] = None
description: Optional[str] = None
class KnowledgeTopicResponse(BaseModel):
id: int
topic: str
knowledge_base_id: int
description: Optional[str] = None
created_by: int
created_at: Optional[datetime] = None
last_updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# --------------------------------------------------------------------------
# Category
# --------------------------------------------------------------------------
class KnowledgeCategoryBase(BaseModel):
name: str
description: Optional[str] = None
class KnowledgeCategoryCreate(KnowledgeCategoryBase):
topic_id: int
parent: Optional[int] = None
class KnowledgeCategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
parent: Optional[int] = None
class KnowledgeCategoryResponse(BaseModel):
id: int
name: str
parent: Optional[int] = None
topic_id: int
description: Optional[str] = None
created_by: Optional[int] = None
last_updated_by: Optional[int] = None
class Config:
from_attributes = True
# --------------------------------------------------------------------------
# Fact
# --------------------------------------------------------------------------
class KnowledgeFactBase(BaseModel):
fact: str
class KnowledgeFactCreate(KnowledgeFactBase):
topic_id: int
category_id: Optional[int] = None
class KnowledgeFactUpdate(BaseModel):
fact: Optional[str] = None
category_id: Optional[int] = None
class KnowledgeFactResponse(BaseModel):
id: int
category_id: Optional[int] = None
topic_id: int
fact: str
last_updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# --------------------------------------------------------------------------
# Project <-> KnowledgeBase link
# --------------------------------------------------------------------------
class ProjectKnowledgeBaseLink(BaseModel):
# Accept either a numeric id or a knowledge_base_code (mirrors how
# projects are referenced elsewhere).
knowledge_base: str
# --------------------------------------------------------------------------
# Nested tree (read-only aggregate)
# --------------------------------------------------------------------------
class CategoryTreeNode(BaseModel):
id: int
name: str
parent: Optional[int] = None
topic_id: int
description: Optional[str] = None
categories: List["CategoryTreeNode"] = []
facts: List[KnowledgeFactResponse] = []
class TopicTreeNode(BaseModel):
id: int
topic: str
knowledge_base_id: int
description: Optional[str] = None
categories: List[CategoryTreeNode] = []
facts: List[KnowledgeFactResponse] = []
class KnowledgeBaseTree(BaseModel):
id: int
knowledge_base_code: Optional[str] = None
title: str
description: Optional[str] = None
topics: List[TopicTreeNode] = []
CategoryTreeNode.model_rebuild()

View File

@@ -0,0 +1,83 @@
"""Schemas for ScheduleType CRUD.
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
A maintenance window of variable length is allowed (1-180 minutes,
handles 23→0 wrap).
"""
from pydantic import BaseModel, Field, model_validator
from typing import Optional
_MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
def _maintenance_duration(maint_from: int, maint_to: int) -> int:
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
dur = _maintenance_duration(maint_from, maint_to)
if dur < 1 or dur > 180:
raise ValueError(
f"maintenance window duration must be in [1, 180] minutes; "
f"got {dur} (from={maint_from}, to={maint_to})"
)
def _validate_minute_field(name: str, value: int) -> None:
if value < 0 or value >= _MAX_MIN:
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
work_to: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
@model_validator(mode="after")
def _check_maintenance(self):
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
@model_validator(mode="after")
def _check_maintenance(self):
# Only validate when both fields are present together; partial-
# update validation against the merged row happens at apply time.
if self.maintenance_from is not None and self.maintenance_to is not None:
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
class ScheduleTypeResponse(BaseModel):
id: int
name: str
work_from: int
work_to: int
entertainment_from: int
entertainment_to: int
maintenance_from: int
maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config:
from_attributes = True
class AgentScheduleTypeAssign(BaseModel):
schedule_type_name: str = Field(..., description="Name of the schedule type to assign")

View File

@@ -0,0 +1,43 @@
"""Schemas for ScheduleTypeSpecialSlot CRUD (admin-only)."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: int = Field(0, ge=0, le=179, description="Minute offset (0-179) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
priority: int = Field(50, ge=0, le=99)
event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True
class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=179)
estimated_duration: Optional[int] = Field(None, ge=1, le=180)
priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None
class SpecialSlotResponse(BaseModel):
id: int
schedule_type_id: int
name: str
description: Optional[str]
minute_in_window: int
estimated_duration: int
priority: int
event_data: Optional[dict[str, Any]]
is_active: bool
created_by_user_id: int
created_at: datetime
class Config:
from_attributes = True

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime, time
from enum import Enum
@@ -186,6 +186,19 @@ class UserUpdate(BaseModel):
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):
id: int
is_active: bool
@@ -194,6 +207,8 @@ class UserResponse(UserBase):
role_name: Optional[str] = None
agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_subject: Optional[str] = None
created_at: datetime
class Config:

View File

@@ -1,17 +1,25 @@
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any
import requests
from fastapi import HTTPException
from app.services.harborforge_config import get_discord_wakeup_config
DISCORD_API_BASE = "https://discord.com/api/v10"
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
def _discord_config() -> dict[str, str | None]:
"""Discord wakeup is configured via env vars (previously read from the
AbstractWizard config file). Returns guild_id+bot_token or Nones."""
return {
"guild_id": os.getenv("HARBORFORGE_DISCORD_GUILD_ID") or None,
"bot_token": os.getenv("HARBORFORGE_DISCORD_BOT_TOKEN") or None,
}
def _headers(bot_token: str) -> dict[str, str]:
return {
"Authorization": f"Bot {bot_token}",
@@ -34,7 +42,7 @@ def _ensure_category(guild_id: str, bot_token: str) -> str | None:
def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]:
cfg = get_discord_wakeup_config()
cfg = _discord_config()
guild_id = cfg.get("guild_id")
bot_token = cfg.get("bot_token")
if not guild_id or not bot_token:

View File

@@ -1,26 +0,0 @@
import json
import os
from typing import Any
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
def load_runtime_config() -> dict[str, Any]:
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
if not os.path.exists(config_path):
return {}
try:
with open(config_path, "r") as f:
return json.load(f)
except Exception:
return {}
def get_discord_wakeup_config() -> dict[str, str | None]:
cfg = load_runtime_config()
discord_cfg = cfg.get("discord") or {}
return {
"guild_id": discord_cfg.get("guild_id"),
"bot_token": discord_cfg.get("bot_token"),
}

View File

@@ -0,0 +1,175 @@
"""Materialise schedule_type special slots into per-agent time_slots rows.
A ScheduleTypeSpecialSlot is a template — it lives on the schedule_type.
For an agent on that schedule_type to actually be woken, the system must
emit a row in `time_slots` with `slot_type=system`, `is_admin_locked=true`,
`special_slot_id=<template_id>` for the agent's `user_id` on the target
date. This module is the single materialisation point.
Called from:
* GET /calendar/day — before returning slots, materialise today's special
slots for the calling user.
* GET /calendar/sync — before returning per-claw schedules, materialise
today's special slots for every agent on this claw whose schedule_type
has any active special slot template.
Idempotent: re-running on the same (agent, date, special_slot_template)
is a no-op — uniqueness is enforced via SELECT-then-insert. We do not add
a DB-level unique constraint because the time_slots table is already
indexed by (user_id, date) and an extra composite index is overkill for
the low cardinality of (agents × special-slot-templates) per day.
"""
from __future__ import annotations
from datetime import date as date_type, time as time_type
from typing import Iterable
from sqlalchemy.orm import Session
from app.models.agent import Agent
from app.models.calendar import TimeSlot, SlotType, SlotStatus, EventType
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
def materialise_special_slots_for_user(
db: Session,
user_id: int,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for one agent (identified by user_id).
Returns the list of newly created rows (may be empty if all already exist
or the agent has no schedule_type / no active templates).
"""
agent = db.query(Agent).filter(Agent.user_id == user_id).first()
if not agent or not agent.schedule_type_id:
return []
return _materialise_for_agent(db, agent, target_date, commit=commit)
def materialise_special_slots_for_claw(
db: Session,
claw_identifier: str,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for every agent on a claw instance.
Used by the multi-agent `/calendar/sync` endpoint so plugin-driven
`runSync` cycles see the special slots without each agent having to
hit `/calendar/day` first.
"""
agents = (
db.query(Agent)
.filter(
Agent.claw_identifier == claw_identifier,
Agent.schedule_type_id.isnot(None),
)
.all()
)
created: list[TimeSlot] = []
for agent in agents:
created.extend(_materialise_for_agent(db, agent, target_date, commit=False))
if commit and created:
db.commit()
return created
def _materialise_for_agent(
db: Session,
agent: Agent,
target_date: date_type,
commit: bool,
) -> list[TimeSlot]:
st: ScheduleType | None = (
db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
)
if not st:
return []
templates: Iterable[ScheduleTypeSpecialSlot] = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == st.id,
ScheduleTypeSpecialSlot.is_active.is_(True),
)
.all()
)
created: list[TimeSlot] = []
for tpl in templates:
if _already_materialised(db, agent.user_id, target_date, tpl.id):
continue
slot = _build_time_slot_from_template(
user_id=agent.user_id,
target_date=target_date,
schedule_type=st,
template=tpl,
)
db.add(slot)
created.append(slot)
if commit and created:
db.commit()
for slot in created:
db.refresh(slot)
return created
def _already_materialised(
db: Session,
user_id: int,
target_date: date_type,
template_id: int,
) -> bool:
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == target_date,
TimeSlot.special_slot_id == template_id,
)
.first()
is not None
)
def _build_time_slot_from_template(
*,
user_id: int,
target_date: date_type,
schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot,
) -> TimeSlot:
# schedule_type.maintenance_from is minutes-since-UTC-midnight; the
# template's minute_in_window is an offset inside that window. Combined
# offset must fit in [0, 1440) and produce a wall-clock time_type.
total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0)
# Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {})
merged_event_data.setdefault("source", "schedule_type_special_slot")
merged_event_data["special_slot_id"] = template.id
merged_event_data["special_slot_name"] = template.name
merged_event_data["schedule_type_id"] = schedule_type.id
merged_event_data["schedule_type_name"] = schedule_type.name
return TimeSlot(
user_id=user_id,
date=target_date,
slot_type=SlotType.SYSTEM,
estimated_duration=template.estimated_duration,
scheduled_at=scheduled_at,
attended=False,
event_type=EventType.SYSTEM_EVENT,
event_data=merged_event_data,
priority=template.priority,
status=SlotStatus.NOT_STARTED,
is_admin_locked=True,
special_slot_id=template.id,
)

99
docs/oidc-test-plan.md Normal file
View File

@@ -0,0 +1,99 @@
# OIDC 接入 — 测试点描述
覆盖范围OIDC 登录、hf 用户与 OIDC 身份绑定、`HARBORFORGE_OIDC_ONLY`
模式、管理员 OIDC 配置页面。涉及仓库分支 `feature/oidc-login`
`HarborForge.Backend` + `HarborForge.Frontend`)。
“本地状态” 列:✅ 已用真实 Keycloak 在本地栈端到端验证;🟡 已用接口/构建
验证但未走真实 IdP UI⬜ 待测。
## 0. 测试环境
- 后端 `http://127.0.0.1:18000`、前端 `http://127.0.0.1:13000`(本地验证栈)
- IdPKeycloak 容器realm `hf`confidential client `hf-client`
- IdP 测试用户:`tester` / `Test123!`emailVerified=true
- 关键约束:**issuer URL 必须浏览器与后端容器都能用同一地址访问**
(否则 token `iss` 校验失败)。本地用宿主机 IP 统一两端。
- 配置项(运行时 env 或 DBDB 覆盖 env
`OIDC_ENABLED / OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET /
OIDC_REDIRECT_URI / OIDC_SCOPES / OIDC_POST_LOGIN_REDIRECT`
部署级 `HARBORFORGE_OIDC_ONLY`Docker ARG/ENV前后端均有
## 1. 管理员 OIDC 配置(页面 + API
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|1.1|读取初始配置|`GET /auth/oidc/settings`admin|返回 `source=env``has_client_secret=false``effective_enabled` 反映 env|✅|
|1.2|保存配置|`PUT /auth/oidc/settings` 填 issuer/client/secret/redirect/scopes/post_login|200`source=db``effective_enabled=true`|✅|
|1.3|client_secret 只写不回显|保存后再 `GET`|响应无明文 secret`has_client_secret=true`|✅|
|1.4|secret 留空保留原值|`PUT` 不带 `client_secret`|原 secret 不变,登录仍可用|🟡|
|1.5|配置即时生效|保存后 `GET /auth/config`|`oidc_enabled` 立即变化,无需重启|✅|
|1.6|页面仅 admin 可见|非 admin 访问 `/settings/oidc`|被重定向到 `/`侧栏无入口API 返回 401/403|🟡|
|1.7|页面展示 Callback URL|打开 OIDC 设置页|醒目显示需在 IdP 注册的 redirect/callback URL、当前状态与配置来源|🟡|
## 2. OIDC 登录流程(授权码)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|2.1|发起登录|`GET /auth/oidc/login`|302 跳转到 IdP authorize 端点state/nonce 入会话 cookie|✅|
|2.2|IdP 登录成功回跳|在 IdP 输入 `tester/Test123!`|302 回 `…/auth/oidc/callback?code=…&state=…`|✅|
|2.3|回调换码并签发|后端处理 callback|校验 ID token(JWKS)→定位绑定用户→签发 hf JWT→302 到前端 `post_login#token=…`|✅|
|2.4|登录态可用|用返回 token 调 `GET /auth/me`|返回被绑定的 hf 用户|✅|
|2.5|前端按钮|登录页点 “Sign in with SSO”|全页跳转到后端 `/auth/oidc/login`|🟡|
|2.6|未配置 OIDC|`OIDC` 关闭时访问 `/auth/oidc/login`|503 “OIDC is not configured”|🟡|
|2.7|token 交换失败|code 失效/被篡改|回前端 `?oidc_error=exchange_failed`,登录页提示|⬜|
## 3. hf 用户 ↔ OIDC 身份绑定
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|3.1|管理员绑定|`PUT /users/{id}/oidc-binding` {issuer,subject}admin 或 acc-manager|200用户响应含 `oidc_issuer/oidc_subject`|✅|
|3.2|未绑定身份拒绝登录|解绑后用该 IdP 账号登录|回 `?oidc_error=not_linked`**不签发 token**(不自动开号)|✅|
|3.3|身份唯一性|把同一 (issuer,subject) 绑到另一用户|409 冲突|✅|
|3.4|解绑|`DELETE /users/{id}/oidc-binding`|200绑定清空|✅|
|3.5|绑定鉴权|无凭据 / 普通用户调用绑定 API|401 / 403|✅|
|3.6|API key 通道|admin 的 API key 调用绑定/配置 API|200支持 JWT 或 API key|✅|
|3.7|自助绑定(非 ONLY|登录用户点侧栏 “Link OIDC account” 走一次 OIDC|回 `?oidc_linked=1`,当前账号绑定成功|🟡|
|3.8|自助绑定冲突|自助绑定到已被占用的身份|`?oidc_error=already_bound`|⬜|
|3.9|OIDC_ONLY 下禁自助|ONLY 模式访问 `/auth/oidc/link`|403仅管理员 API 可绑)|🟡|
## 4. HARBORFORGE_OIDC_ONLY 模式
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|4.1|配置反映|ONLY=true 时 `GET /auth/config`|`oidc_only=true``password_login=false`|✅|
|4.2|禁用密码登录|`POST /auth/token`|403 “Password login is disabled (OIDC only)”|✅|
|4.3|建用户忽略密码|`POST /users` 带 password|201但 DB `hashed_password=NULL`(无密码用户)|✅|
|4.4|改用户忽略密码|`PATCH /users/{id}` 带 password|密码不被设置|🟡|
|4.5|无密码用户仍可用|对该用户绑定 OIDC、生成 API key|绑定 200、`reset-apikey` 200可经 OIDC 登录|✅|
|4.6|前端隐藏密码 UI|ONLY 模式打开登录页 / 用户管理页|无用户名密码框;用户管理无密码/重置密码组件|🟡|
|4.7|防锁死恢复|ONLY 模式且 OIDC 配错|管理员 API key 仍可调 `GET/PUT /auth/oidc/settings` 修复|✅|
## 5. 回归(不破坏既有功能)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|5.1|默认模式密码登录|未开 ONLY 时 `POST /auth/token`|正常 200 签发 token|✅|
|5.2|用户响应新增字段|`GET /users` `/users/{id}` `/auth/me`|含 `oidc_issuer/oidc_subject`(未绑定为 null|✅|
|5.3|启动迁移幂等|新旧库重复启动后端|`users.oidc_*` 列与 `oidc_settings` 表存在,无报错|✅|
|5.4|前端构建|`npm run build`Docker 镜像)|TS 编译通过|✅|
|5.5|后端导入|镜像内 `import app.main`|无导入错误OIDC 路由注册|✅|
|5.6|镜像参数|前后端 Dockerfile|含 `ARG/ENV HARBORFORGE_OIDC_ONLY`|✅|
## 6. 安全检查点
- ID token 经 IdP JWKS 校验Authlib discovery + nonce签发的是 hf 原有
HS256 JWT依赖强 `SECRET_KEY`(弱 key 后端拒绝启动)。
- `client_secret` 持久化在 DB接口永不回显。
- 绑定/配置接口强制 admin或 account-manager 绑定),支持 API key 作为
OIDC_ONLY 下的恢复通道。
- 未绑定 OIDC 身份一律拒绝,不自动开号(防越权开户)。
- `redirect_uri` 必须在 IdP 精确注册;后端回跳前端地址来自服务端配置
`post_login_redirect`),不接受客户端传入,避免开放重定向。
## 7. 已知限制 / 待补
- 真实浏览器交互点按钮、IdP 同意页、SameSite cookie 行为)需人工过一遍
(本地已用 curl 无头跑通授权码全流程,逻辑等价)。
- 多 IdP / 多 issuer、token 刷新、登出联动SLO未实现超出本次范围。
- 自助绑定相关 UI3.7/3.8)建议人工在浏览器复核。

View File

@@ -1,19 +1,5 @@
#!/bin/sh
# Wait for wizard config before starting uvicorn
CONFIG_DIR="${CONFIG_DIR:-/config}"
CONFIG_FILE="${CONFIG_FILE:-harborforge.json}"
CONFIG_PATH="$CONFIG_DIR/$CONFIG_FILE"
echo "HarborForge Backend - waiting for config..."
echo " Config path: $CONFIG_PATH"
while true; do
if [ -f "$CONFIG_PATH" ]; then
echo " Config found! Starting backend..."
break
fi
echo " Config not ready, waiting 5s... (run setup wizard via SSH tunnel)"
sleep 5
done
# HarborForge backend entrypoint. All config comes from env vars (DATABASE_URL,
# SECRET_KEY, HARBORFORGE_OIDC_ONLY, etc.). First-deploy admin user + OIDC
# issuer config are operator-driven via `docker exec hf-backend hf-cli ...`.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -12,3 +12,5 @@ alembic==1.13.1
python-dotenv==1.0.0
httpx==0.27.0
requests==2.31.0
authlib==1.3.2
itsdangerous==2.2.0

View File

@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Patch the production engine/SessionLocal BEFORE importing app so that
# startup events (Base.metadata.create_all, init_wizard, etc.) use the
# startup events (Base.metadata.create_all, init_bootstrap, etc.) use the
# in-memory SQLite database instead of trying to connect to MySQL.
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,147 @@
"""Knowledge Base API tests — CRUD, hierarchy, uniqueness, tree, links, RBAC."""
from tests.conftest import auth_header
def _create_kb(client, token, title="Infra Runbook", description="ops notes"):
r = client.post(
"/knowledge-bases",
json={"title": title, "description": description},
headers=auth_header(token),
)
assert r.status_code == 201, r.text
return r.json()
class TestKnowledgeBaseCRUD:
def test_create_generates_code(self, client, seed):
kb = _create_kb(client, seed["admin_token"], title="Infra Runbook")
assert kb["title"] == "Infra Runbook"
assert kb["knowledge_base_code"] # auto-generated, non-empty
assert kb["created_by"] == seed["admin_user"].id
def test_create_requires_permission(self, client, seed):
# dev role has no knowledge-base.create
r = client.post(
"/knowledge-bases",
json={"title": "Nope"},
headers=auth_header(seed["dev_token"]),
)
assert r.status_code == 403
def test_get_by_id_and_code(self, client, seed):
kb = _create_kb(client, seed["admin_token"])
by_id = client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(seed["admin_token"]))
by_code = client.get(f"/knowledge-bases/{kb['knowledge_base_code']}", headers=auth_header(seed["admin_token"]))
assert by_id.status_code == 200 and by_code.status_code == 200
assert by_id.json()["id"] == by_code.json()["id"] == kb["id"]
def test_update_and_list(self, client, seed):
kb = _create_kb(client, seed["admin_token"])
r = client.patch(
f"/knowledge-bases/{kb['id']}",
json={"description": "updated"},
headers=auth_header(seed["admin_token"]),
)
assert r.status_code == 200 and r.json()["description"] == "updated"
lst = client.get("/knowledge-bases", headers=auth_header(seed["admin_token"]))
assert lst.status_code == 200 and any(k["id"] == kb["id"] for k in lst.json())
def test_delete_cascades(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Net"}, headers=auth_header(token)).json()
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "x"}, headers=auth_header(token))
r = client.delete(f"/knowledge-bases/{kb['id']}", headers=auth_header(token))
assert r.status_code == 204
assert client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(token)).status_code == 404
assert client.get(f"/knowledge-topics/{topic['id']}", headers=auth_header(token)).status_code == 404
class TestHierarchy:
def test_topic_unique_per_kb(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
r1 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
r2 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
assert r1.status_code == 201 and r2.status_code == 400
def test_category_triple_unique_and_nesting(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
# top-level category
c1 = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
assert c1.status_code == 201
# duplicate top-level (parent NULL) rejected at app level
dup = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
assert dup.status_code == 400
# nested category with same name under different parent is allowed
child = client.post(
"/knowledge-categories",
json={"topic_id": topic["id"], "name": "DNS", "parent": c1.json()["id"]},
headers=auth_header(token),
)
assert child.status_code == 201
def test_no_cycle_on_reparent(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
a = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "A"}, headers=auth_header(token)).json()
b = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "B", "parent": a["id"]}, headers=auth_header(token)).json()
# try to move A under its descendant B -> rejected
r = client.patch(f"/knowledge-categories/{a['id']}", json={"parent": b["id"]}, headers=auth_header(token))
assert r.status_code == 400
def test_tree_shape(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
cat = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "C"}, headers=auth_header(token)).json()
# fact directly on topic
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "topic-fact"}, headers=auth_header(token))
# fact under category
client.post("/knowledge-facts", json={"topic_id": topic["id"], "category_id": cat["id"], "fact": "cat-fact"}, headers=auth_header(token))
tree = client.get(f"/knowledge-bases/{kb['id']}/tree", headers=auth_header(token)).json()
assert len(tree["topics"]) == 1
t = tree["topics"][0]
assert [f["fact"] for f in t["facts"]] == ["topic-fact"]
assert len(t["categories"]) == 1
assert [f["fact"] for f in t["categories"][0]["facts"]] == ["cat-fact"]
class TestProjectLinks:
def test_link_unlink(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
# link by code
r = client.post(
"/projects/TPRJ/knowledge-bases",
json={"knowledge_base": kb["knowledge_base_code"]},
headers=auth_header(token),
)
assert r.status_code == 201
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
assert any(k["id"] == kb["id"] for k in linked)
# filter list by project
filtered = client.get(f"/knowledge-bases?project=TPRJ", headers=auth_header(token)).json()
assert any(k["id"] == kb["id"] for k in filtered)
# unlink
r = client.delete(f"/projects/TPRJ/knowledge-bases/{kb['id']}", headers=auth_header(token))
assert r.status_code == 204
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
assert not any(k["id"] == kb["id"] for k in linked)
def test_link_is_idempotent(self, client, seed):
token = seed["admin_token"]
kb = _create_kb(client, token)
for _ in range(2):
r = client.post(
"/projects/TPRJ/knowledge-bases",
json={"knowledge_base": str(kb["id"])},
headers=auth_header(token),
)
assert r.status_code == 201
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
assert sum(1 for k in linked if k["id"] == kb["id"]) == 1