diff --git a/docs/brainstorms/2026-06-21-admin-console-requirements.md b/docs/brainstorms/2026-06-21-admin-console-requirements.md new file mode 100644 index 0000000..19b63e1 --- /dev/null +++ b/docs/brainstorms/2026-06-21-admin-console-requirements.md @@ -0,0 +1,133 @@ +# Admin Console — Enterprise Department-Scoped Management + +**Date**: 2026-06-21 +**Status**: Draft (revised 2026-06-21 — SaaS multi-tenant → enterprise department-scoped) +**Branch**: `feat/admin-console` +**Author**: brainstorm session + +## Outcome + +为 Fischer AgentKit 构建统一的企业管理端,采用"嵌入主应用 `/admin` 路由组 + 架构预留拆分能力"方案。Web UI 和 CLI 双通道共享同一套 service 层。核心模型是**单企业部署 + 部门级权限隔离 + 能力按部门绑定**:人事部有人事 skill + 人事系统 DB 权限,研发部有编码 skill,部门间资源隔离、用户可多部门(权限并集)。MVP 覆盖 5 个管理领域:部门与用户、LLM 配置、Skill 与 KB、用量仪表盘与配额。 + +## Background + +当前 AgentKit 没有统一管理端: +- 后端 admin 端点只有 4 个(session 管理),无 user CRUD / LLM 配置 / 用量分用户 +- 前端只有 1 个 admin 页面(`/admin/users`,仅会话管理) +- LLM 配置只在 `agentkit.yaml`,运行时不可改 +- KB/Skill 有 CRUD API 但不是 admin-only,无部门隔离 +- 权限模型有 3 级(member/operator/admin)但实际使用率极低 +- 管理工作流是"零散脚本不成体系" + +## Actual Usage Scenario (revised) + +单企业部署一套 AgentKit,按部门/职能划分权限: +- 人事部:拥有人事专用 skill + 访问人事系统数据库的权限,无编码能力 +- 研发部:拥有编码 skill + 代码仓库访问权限 +- 财务部:拥有财务分析 skill + 财务系统访问权限 +- 个人用户属于一个或多个部门(权限并集),如 CFO 可兼管财务和运营 +- 管理员按部门分配能力,用户继承所属部门的能力 + +**这不是 SaaS 多租户**——不需要租户计费、跨租户数据隔离、租户 CRUD。核心是**部门(能力组)+ 用户多部门归属 + 能力按部门绑定**。 + +## Primary Actors + +- **超级管理员**(企业 IT):管理所有部门、全局 LLM 配置、跨部门用量监控、用户管理 +- **部门管理员**(部门负责人):管理本部门成员、本部门 Skill/KB、本部门用量 +- **普通用户**:属于一个或多个部门,继承部门能力,受部门配额约束 +- **运维自动化**:通过 CLI `agentkit admin ` 执行管理操作,与 Web UI 等价 + +## Scope + +### MVP(第一期) + +1. **部门与用户管理** + - 部门 CRUD:创建、列表、编辑、禁用/启用、删除(人事/研发/财务等) + - 部门能力绑定:每个部门可绑定专属 skill、KB、工具权限、LLM 配额 + - 用户 CRUD:创建、列表、编辑、禁用/启用、删除 + - 用户多部门归属:一个用户可属于多个部门(`user_departments` 多对多表),权限取并集 + - 角色管理:超级管理员 / 部门管理员 / 普通用户 + - 密码重置:管理员重置任意用户密码 + - 当前已有的会话管理(revoke/list)保留并集成 + - 数据隔离:所有资源表(skills、kb_documents、llm_usage 等)加 `department_id` 列,用户访问时只能看到所属部门的资源(多部门取并集) + +2. **LLM 配置管理** + - Provider/Model/API Key CRUD:运行时增删改,不重启服务 + - Fallback 链配置:可视化编辑 provider fallback 顺序 + - 按部门配额:每部门可配置独立的 provider/model 白名单和 token 上限 + - 成本上限:按部门/用户设置日/月成本上限,超限硬拒绝(返回 429) + - 配置从 `agentkit.yaml` 迁移到数据库,支持运行时热重载 + - 向后兼容:首次启动时自动从 `agentkit.yaml` 导入现有配置 + +3. **Skill 与 KB 管理** + - Skill 启停/编辑/导入:admin 可启用/禁用任意 skill,编辑 skill 配置,YAML 导入 + - Skill 按部门绑定:人事 skill 只对人事部可见,编码 skill 只对研发部可见 + - KB 文档 CRUD/同步/重建索引:admin 可管理所有 KB 文档 + - KB 按部门隔离:每部门有独立的 KB 资源池 + +4. **用量仪表盘 + 配额** + - 按部门/用户/时间维度查看 LLM token 用量、成本、调用次数 + - 可视化图表:时间序列、按 provider/model 分桶、按用户排行 + - 报表导出:CSV/JSON + - 配额上限:按部门/用户设置 token/成本上限,超限硬拒绝(429) + - 告警:配额达到 80%/100% 时触发(MVP 只做日志告警) + +### 产品形态 + +- **Web UI**:`/admin/*` 路由组,独立 `AdminLayout` + 左侧导航 + 5 个管理页面 +- **CLI**:`agentkit admin ` 镜像所有 Web UI 操作 +- **后端**:独立 `admin_router` APIRouter 实例,未来可拆分到独立 FastAPI 应用 +- **认证**:共享现有 JWT 认证,admin 端点要求 admin 角色 + +### 架构决策 + +- **部门隔离**:共享数据库 + `department_id` 列。`DepartmentContextMiddleware` 在请求级别注入用户所属部门列表,所有 repository 层强制按 `department_id IN (...)` 过滤(多部门取并集)。 +- **配置存储**:LLM 配置从 `agentkit.yaml` 迁移到数据库(`llm_providers` / `llm_models` / `llm_api_keys` 表),支持运行时热重载。首次启动自动从 YAML 导入。 +- **CLI/Web 一致性**:CLI 和 Web UI 调用同一套 service 层,避免双份业务逻辑。 +- **预留拆分**:`admin_router` 是独立 `APIRouter` 实例,`AdminLayout` 是独立路由树。未来需要独立部署时,把 `admin_router` 挂到独立 FastAPI 实例,前端 `AdminLayout` 独立构建即可。 + +## Success Criteria + +- 超级管理员可在 Web UI 或 CLI 完成全部 4 个领域的管理操作,无需手动编辑 YAML 或操作数据库 +- 部门隔离通过安全测试:人事部用户无法访问研发部的 skill/KB/用量数据(除非同时属于两个部门) +- LLM 配置可在运行时修改并立即生效,无需重启服务 +- 用量仪表盘可按部门/用户/时间维度展示,数据延迟 < 1 分钟 +- 配额超限时系统返回 429,不会超支 +- CLI 与 Web UI 操作结果一致,调用同一 service 层 +- 用户多部门归属正确:CFO 兼管财务和运营时,可访问两个部门的所有资源 + +## Non-Goals + +- **SaaS 多租户**(外部多客户、租户计费、跨租户物理隔离)——本场景是单企业部署 +- **物理隔离的独立数据库**(部门数据隔离靠 `department_id` 列,不靠物理隔离) +- **计费系统集成**(只做用量采集和配额,不做实际计费/扣款) +- **审计日志**(第二期) +- **SLA 监控**(第二期) +- **独立管理应用部署**(架构预留但不实现,MVP 嵌入主应用) +- **SSO/SAML 集成**(第二期) + +## Dependencies / Assumptions + +- **假设**:现有 `users` / `auth_sessions` 表可平滑加 `department_id` 列(SQLite ALTER TABLE 支持 ADD COLUMN,NULL 表示未分配部门的全局用户) +- **假设**:LLM 配置从 YAML 迁移到数据库后,`agentkit.yaml` 仍可作为首次导入源,不破坏现有部署 +- **依赖**:现有 JWT 认证 + RBAC 权限模型(3 级)可扩展支持部门管理员角色 +- **依赖**:现有 `agentkit` CLI 框架(Typer)支持新增 `admin` 命令组 +- **风险**:部门隔离 middleware 完整性是关键风险——任何遗漏 `department_id` 过滤的查询都是数据泄露漏洞。通过 repository 层强制 `department_id IN (...)` 参数缓解,但需要安全测试覆盖。 +- **风险**:LLM 配置热重载需要处理并发修改冲突(乐观锁或版本号) +- **风险**:用户多部门归属的权限并集计算可能产生意外权限提升(如用户从 A 部门调离但未及时移除归属) + +## Open Questions (Resolved) + +- ~~租户的粒度是"组织"还是"工作空间"?~~ → **已明确**:单企业部署 + 部门级隔离,不是 SaaS 多租户 +- ~~跨租户用户的关系如何建模?~~ → **已明确**:`user_departments` 多对多表,权限取并集 +- ~~配额超限时的行为:硬拒绝还是软降级?~~ → **已明确**:硬拒绝(429) + +## Delivery Strategy + +MVP 范围较大(4 领域),建议按领域分批交付: +1. **批次 1**:部门与用户管理(部门 CRUD + 用户多部门归属 + 隔离 middleware,其他领域依赖) +2. **批次 2**:LLM 配置管理(含 YAML→DB 迁移) +3. **批次 3**:Skill 与 KB 管理(按部门绑定/隔离) +4. **批次 4**:用量仪表盘 + 配额(依赖前 3 批的 department_id 埋点) + +每批次独立可交付、可测试、可合并。 diff --git a/docs/plans/2026-06-21-001-feat-admin-console-plan.md b/docs/plans/2026-06-21-001-feat-admin-console-plan.md new file mode 100644 index 0000000..2637498 --- /dev/null +++ b/docs/plans/2026-06-21-001-feat-admin-console-plan.md @@ -0,0 +1,733 @@ +# Plan: Admin Console — Enterprise Department-Scoped Management + +**Date**: 2026-06-21 +**Status**: active +**Type**: feat +**Origin**: `docs/brainstorms/2026-06-21-admin-console-requirements.md` +**Branch**: `feat/admin-console` + +--- + +## Summary + +为 Fischer AgentKit 构建统一企业管理端,嵌入主应用 `/admin` 路由组。核心模型:单企业部署 + 部门级权限隔离 + 能力按部门绑定 + 用户多部门归属(权限并集)。MVP 覆盖 4 个领域:部门与用户管理、LLM 配置管理、Skill 与 KB 管理、用量仪表盘与配额。Web UI + CLI 双通道共享 service 层。 + +## Problem Frame + +当前 AgentKit 没有统一管理端:admin 端点只有 4 个(session 管理),无 user CRUD;LLM 配置只在 `agentkit.yaml`;KB/Skill 无部门隔离;用量不分用户。管理工作流是"零散脚本不成体系"。 + +实际场景:单企业部署,按部门(人事/研发/财务)划分权限,部门绑定专属 skill/KB/工具,用户可多部门(权限并集)。 + +## Requirements (from origin doc) + +- **R1**: 部门 CRUD + 部门能力绑定(skill/KB/工具权限/LLM 配额) +- **R2**: 用户 CRUD + 多部门归属(`user_departments` 多对多)+ 密码重置 + 禁用/启用 +- **R3**: LLM 配置运行时管理(Provider/Model/API Key CRUD + fallback 链 + 按部门配额) +- **R4**: Skill 启停/编辑/导入 + 按部门绑定 +- **R5**: KB 文档 CRUD + 按部门隔离 +- **R6**: 用量仪表盘(按部门/用户/时间)+ 配额硬拒绝(429) +- **R7**: CLI `agentkit admin ` 镜像 Web UI +- **R8**: 部门隔离安全测试(人事部用户无法访问研发部资源,除非同时属于两个部门) +- **SC1**: 超管可在 Web UI 或 CLI 完成全部管理操作,无需手动编辑 YAML 或操作数据库 +- **SC2**: 部门隔离通过安全测试 +- **SC3**: LLM 配置运行时修改立即生效 +- **SC4**: 用量仪表盘数据延迟 < 1 分钟 +- **SC5**: 配额超限返回 429 +- **SC6**: CLI 与 Web UI 操作结果一致 + +## Key Technical Decisions + +### KTD1: 角色模型简化为两级(MVP) + +**决策**:MVP 只有超级管理员(admin)和普通用户(member)两种角色,不实现部门管理员。 + +**理由**:用户明确选择"MVP 只有超管和普通用户"。部门管理员涉及跨部门权限委托、本部门资源管理边界等复杂逻辑,放到第二期。 + +**影响**:现有 `permissions.py` 的 3 级 RBAC(member/operator/admin)保持不变,`operator` 角色保留但 MVP 不主动使用。所有 admin 端点用现有 `_require_admin`(`USER_MANAGE` 权限)守卫。 + +### KTD2: 部门隔离用 `department_id` 列 + `user_departments` 多对多表 + +**决策**: +- 新增 `departments` 表(id, name, description, is_active, created_at) +- 新增 `user_departments` 多对多表(user_id, department_id, created_at) +- 资源表(skills 绑定、kb_documents、llm_usage)加 `department_id` 列(NULL 表示全局共享) +- `DepartmentContextMiddleware` 从 JWT 读取 user_id,查询 `user_departments`,注入 `request.state.department_ids`(列表) +- Repository 层强制 `WHERE department_id IN (?, ?, NULL)` 过滤(NULL 表示全局资源,所有部门可见) + +**理由**:用户明确选择"共享数据库 + department_id 列"。多对多表支持用户多部门归属。NULL `department_id` 表示全局共享资源(如默认 LLM 配置),避免每个部门都重复配置。 + +**风险**:middleware 完整性是关键——任何遗漏 `department_id` 过滤的查询都是数据泄露。通过 repository 层强制参数缓解,但需要安全测试覆盖(R8)。 + +### KTD3: LLM 配置保持 YAML + 写回文件(MVP) + +**决策**:LLM 配置继续以 `agentkit.yaml` 为权威源。运行时修改通过 `PUT /settings/llm` 写回 YAML 文件,依赖现有 `ServerConfig.watch_config()` 文件监听触发热重载。 + +**理由**:用户明确选择"保持 YAML + 写回文件"。完全迁移到数据库工作量大,且现有文件监听热重载机制已可用。 + +**影响**: +- 不新增 `llm_providers` / `llm_models` / `llm_api_keys` 数据库表 +- 按部门配额存储在数据库(`department_quotas` 表),不存 YAML +- API Key 在 YAML 中存储(现有方式),按部门配额在 DB 中 + +**风险**:并发修改 YAML 文件可能冲突。MVP 用文件锁(`fcntl.flock`)缓解,第二期考虑迁移到 DB。 + +### KTD4: 用量跟踪加 `user_id` + `department_id` 字段 + +**决策**: +- 修改 `UsageRecord` dataclass,新增 `user_id` 和 `department_id` 字段 +- 修改 `InMemoryUsageStore` 和 `RedisUsageStore`,存储和查询时包含新字段 +- LLM Gateway 调用时,从请求上下文获取 `user_id` 和 `department_id`,传入 `UsageTracker` +- 新增 `GET /admin/usage` 端点,按部门/用户/时间维度聚合查询 + +**理由**:当前 `UsageRecord` 只有 `agent_name`,无法按用户/部门统计。必须加字段才能满足 R6。 + +**影响**:`UsageRecord` 是 dataclass,加字段向后兼容(旧记录的 `user_id`/`department_id` 为 None)。Redis Hash 结构需要扩展 key 包含 `user_id` 和 `department_id`。 + +### KTD5: CLI/Web 共享 service 层 + +**决策**:所有管理操作封装在 `src/agentkit/server/admin/` 下的 service 模块(如 `user_service.py`、`department_service.py`、`llm_config_service.py`)。Web UI 路由和 CLI 命令都调用这些 service。 + +**理由**:用户明确要求"CLI/Web 一致性"。共享 service 层避免双份业务逻辑。 + +**影响**:CLI 需要通过 HTTP 调用 server(`agentkit admin` 命令实际是调用 `/api/v1/admin/*` 端点),而不是直接操作数据库。这保证了一致性,但要求 CLI 有 server URL 配置。 + +### KTD6: 前端 `AdminLayout` 独立路由树 + +**决策**:前端新增 `AdminLayout.vue`(左侧导航 + 内容区),所有 admin 页面作为子路由。路由结构: +``` +/admin → AdminLayout + /admin/dashboard → AdminDashboard(概览) + /admin/departments → DepartmentsView(部门管理) + /admin/users → UsersView(用户管理,扩展现有) + /admin/llm → LlmConfigView(LLM 配置) + /admin/skills → SkillsView(Skill 管理) + /admin/kb → KbManagementView(KB 管理) + /admin/usage → UsageDashboardView(用量仪表盘) +``` + +**理由**:独立路由树便于未来拆分到独立前端应用。左侧导航统一入口,解决"散落各处"问题。 + +## High-Level Technical Design + +### 数据模型 ERD + +```mermaid +erDiagram + departments ||--o{ user_departments : has + users ||--o{ user_departments : belongs_to + departments ||--o{ department_skill_bindings : binds + skills ||--o{ department_skill_bindings : bound_to + departments ||--o{ department_kb_bindings : binds + kb_sources ||--o{ department_kb_bindings : bound_to + departments ||--o{ department_quotas : has + users ||--o{ llm_usage : generates + departments ||--o{ llm_usage : belongs_to + + departments { + string id PK + string name + string description + bool is_active + datetime created_at + } + user_departments { + string user_id FK + string department_id FK + datetime created_at + } + department_skill_bindings { + string id PK + string department_id FK + string skill_name + datetime created_at + } + department_kb_bindings { + string id PK + string department_id FK + string kb_source_id + datetime created_at + } + department_quotas { + string id PK + string department_id FK + string quota_type + string limit_value + string period + datetime updated_at + } + llm_usage { + string id PK + string user_id FK + string department_id FK + string model + int prompt_tokens + int completion_tokens + int total_tokens + float cost + datetime timestamp + } +``` + +### 请求隔离流程 + +```mermaid +sequenceDiagram + participant Client + participant AuthMiddleware + participant DepartmentMiddleware + participant Route + participant Service + participant Repository + + Client->>AuthMiddleware: Request + JWT + AuthMiddleware->>AuthMiddleware: Verify JWT, extract user_id + AuthMiddleware->>DepartmentMiddleware: request.user_id + DepartmentMiddleware->>DepartmentMiddleware: Query user_departments + DepartmentMiddleware->>DepartmentMiddleware: Set request.state.department_ids + DepartmentMiddleware->>Route: Forward with department context + Route->>Service: Call service method + Service->>Repository: Pass department_ids + Repository->>Repository: WHERE department_id IN (?, ?, NULL) + Repository-->>Service: Filtered results + Service-->>Route: Response + Route-->>Client: Response +``` + +## Implementation Units + +### U1. 数据库 schema 扩展——部门表 + 多对多 + 资源表 department_id + +**Goal**: 新增 `departments`、`user_departments`、`department_skill_bindings`、`department_kb_bindings`、`department_quotas` 表,并为 `llm_usage` 加 `user_id`/`department_id` 字段。 + +**Requirements**: R1, R2, R4, R5, R6 + +**Dependencies**: 无(基础单元) + +**Files**: +- `src/agentkit/server/auth/models.py` — 新增表 DDL + ORM 模型,bump `_SCHEMA_VERSION` 到 3 +- `src/agentkit/server/admin/__init__.py` — 新建 admin 模块 +- `src/agentkit/server/admin/models.py` — admin 相关 Pydantic 模型(Department, UserDepartment, etc.) +- `tests/unit/admin/test_models.py` — 表创建 + CRUD 测试 + +**Approach**: +- 在 `models.py` 的 `_SCHEMA_SQL` 中追加 5 个新表的 CREATE TABLE IF NOT EXISTS +- 新增 `_migrate_v2_to_v3()` 迁移函数,gated on `auth_meta` marker `schema_v3_departments` +- `departments` 表:id (UUID), name (UNIQUE), description, is_active, created_at +- `user_departments` 表:user_id, department_id, created_at, PRIMARY KEY (user_id, department_id) +- `department_skill_bindings` 表:id, department_id, skill_name, created_at, UNIQUE (department_id, skill_name) +- `department_kb_bindings` 表:id, department_id, kb_source_id, created_at, UNIQUE (department_id, kb_source_id) +- `department_quotas` 表:id, department_id, quota_type (token_limit/cost_limit/model_whitelist), limit_value (JSON), period (daily/monthly), updated_at +- `llm_usage` 不是 SQL 表(是 Redis),所以 `user_id`/`department_id` 加在 `UsageRecord` dataclass 上(见 U8) + +**Patterns to follow**: 现有 `models.py` 的 `_SCHEMA_SQL` + `_migrate_v2_to_v3` 模式(参考 `_backfill_user_sessions`) + +**Test scenarios**: +- Happy path: `init_auth_db()` 创建新表,`auth_meta` 记录 schema_v3_departments +- Edge case: 重复调用 `init_auth_db()` 不报错(幂等) +- Edge case: 已有 v2 数据库升级到 v3,新表创建成功,现有数据不丢失 +- Integration: `departments` 表插入 + 查询,`user_departments` 多对多关系正确 + +**Verification**: `pytest tests/unit/admin/test_models.py -v` 通过;手动检查 `data/auth.db` 新表存在 + +--- + +### U2. 部门 CRUD service + API 端点 + +**Goal**: 实现部门的创建、列表、编辑、禁用/启用、删除,以及部门能力绑定(skill/KB)管理。 + +**Requirements**: R1, R4, R5 + +**Dependencies**: U1 + +**Files**: +- `src/agentkit/server/admin/department_service.py` — DepartmentService 类(CRUD + 能力绑定) +- `src/agentkit/server/routes/admin.py` — 新建独立 admin_router 模块(从 auth.py 迁出 session 端点 + 新增 department 端点) +- `src/agentkit/server/app.py` — 挂载新 admin_router +- `tests/unit/admin/test_department_service.py` — service 单元测试 +- `tests/integration/admin/test_department_routes.py` — API 集成测试 + +**Approach**: +- `DepartmentService` 方法:`create_department`, `list_departments`, `get_department`, `update_department`, `disable_department`, `delete_department`, `bind_skill`, `unbind_skill`, `list_department_skills`, `bind_kb`, `unbind_kb`, `list_department_kbs` +- 删除部门时检查是否有用户归属,有则拒绝(或强制 cascade 删除 user_departments) +- 禁用部门时(`is_active=0`),该部门用户仍可登录但无法访问部门资源 +- API 端点: + - `POST /api/v1/admin/departments` — 创建 + - `GET /api/v1/admin/departments` — 列表 + - `GET /api/v1/admin/departments/{id}` — 详情 + - `PATCH /api/v1/admin/departments/{id}` — 编辑 + - `DELETE /api/v1/admin/departments/{id}` — 删除 + - `POST /api/v1/admin/departments/{id}/skills/{name}` — 绑定 skill + - `DELETE /api/v1/admin/departments/{id}/skills/{name}` — 解绑 skill + - `POST /api/v1/admin/departments/{id}/kb/{source_id}` — 绑定 KB + - `DELETE /api/v1/admin/departments/{id}/kb/{source_id}` — 解绑 KB +- 所有端点用 `_require_admin` 守卫 + +**Patterns to follow**: `kb_management.py` 的 APIRouter + Pydantic 模型 + Depends 模式 + +**Test scenarios**: +- Happy path: 创建部门 → 列表返回 → 编辑名称 → 禁用 → 启用 → 删除 +- Happy path: 绑定 skill → 列表返回绑定的 skill → 解绑 +- Edge case: 创建重名部门 → 409 Conflict +- Edge case: 删除有用户归属的部门 → 400 Bad Request +- Error path: 非管理员访问 → 403 +- Error path: 不存在的部门 ID → 404 +- Integration: 部门禁用后,该部门用户访问部门资源 → 403 + +**Verification**: `pytest tests/unit/admin/test_department_service.py tests/integration/admin/test_department_routes.py -v` 通过 + +--- + +### U3. 用户 CRUD service + API 端点 + 密码重置 + +**Goal**: 实现用户创建、列表、编辑、禁用/启用、删除、密码重置、多部门归属管理。 + +**Requirements**: R2 + +**Dependencies**: U1, U2 + +**Files**: +- `src/agentkit/server/admin/user_service.py` — UserService 类 +- `src/agentkit/server/routes/admin.py` — 新增 user 端点(扩展 U2 的 admin_router) +- `src/agentkit/server/auth/providers/local.py` — 新增 `create_user` 方法 +- `tests/unit/admin/test_user_service.py` +- `tests/integration/admin/test_user_routes.py` + +**Approach**: +- `LocalAuthProvider.create_user(username, email, password, role)` — bcrypt hash + INSERT INTO users +- `UserService` 方法:`create_user`, `list_users`, `get_user`, `update_user`, `disable_user`, `enable_user`, `delete_user`, `reset_password`, `assign_department`, `remove_department`, `list_user_departments` +- API 端点: + - `POST /api/v1/admin/users` — 创建用户 + - `GET /api/v1/admin/users` — 列表(支持 department_id 过滤) + - `GET /api/v1/admin/users/{id}` — 详情(含部门归属) + - `PATCH /api/v1/admin/users/{id}` — 编辑(role, is_active, is_terminal_authorized) + - `DELETE /api/v1/admin/users/{id}` — 删除(软删除:is_active=0) + - `POST /api/v1/admin/users/{id}/reset-password` — 重置密码 + - `POST /api/v1/admin/users/{id}/departments/{dept_id}` — 分配部门 + - `DELETE /api/v1/admin/users/{id}/departments/{dept_id}` — 移除部门归属 +- 创建用户时可选指定部门列表 +- 密码重置用 bcrypt hash 新密码,更新 `password_hash`,并 revoke 该用户所有会话(`revoke_all_for_user`) + +**Patterns to follow**: 现有 `auth.py` 的 `_resolve_db_path` + `aiosqlite` 模式 + +**Test scenarios**: +- Happy path: 创建用户 → 列表返回 → 分配部门 → 用户详情含部门 → 重置密码 → 旧会话失效 +- Happy path: 用户多部门归属 → 两个部门都返回该用户 +- Edge case: 创建重名用户 → 409 +- Edge case: 删除自己 → 400 +- Edge case: 移除用户最后一个部门 → 允许(用户变为无部门全局用户) +- Error path: 非管理员访问 → 403 +- Error path: 重置密码后旧 token 仍可用 → 失败(会话已 revoke) +- Integration: 创建用户后用新用户登录 → 成功 + +**Verification**: `pytest tests/unit/admin/test_user_service.py tests/integration/admin/test_user_routes.py -v` 通过 + +--- + +### U4. DepartmentContextMiddleware + repository 层隔离 + +**Goal**: 实现请求级别的部门上下文注入,确保所有资源查询按部门过滤。 + +**Requirements**: R8, SC2 + +**Dependencies**: U1, U2, U3 + +**Files**: +- `src/agentkit/server/admin/middleware.py` — DepartmentContextMiddleware +- `src/agentkit/server/admin/context.py` — DepartmentContext dataclass + get_department_context() 依赖 +- `src/agentkit/server/app.py` — 注册 middleware(在 AuthMiddleware 之后) +- `src/agentkit/server/routes/skills.py` — 修改 skill 查询,按 department_ids 过滤 +- `src/agentkit/server/routes/kb_management.py` — 修改 KB 查询,按 department_ids 过滤 +- `tests/integration/admin/test_department_isolation.py` — 隔离安全测试 + +**Approach**: +- `DepartmentContextMiddleware`: + 1. 从 `request.state.user` 获取 user_id(AuthMiddleware 已注入) + 2. 查询 `user_departments` 获取 department_ids + 3. 注入 `request.state.department_ids`(列表,可能为空表示全局用户) + 4. 白名单路径(/auth/*, /docs, /health)跳过 +- `get_department_context()` 依赖:从 request.state 读取 department_ids,返回 DepartmentContext +- Skill 查询修改:`GET /skills` 返回全局 skill + 用户部门绑定的 skill +- KB 查询修改:`GET /kb-management/sources` 返回全局 KB + 用户部门绑定的 KB +- Admin 端点(`/admin/*`)跳过部门过滤(超管可看所有) + +**Patterns to follow**: 现有 `AuthMiddleware` 的 BaseHTTPMiddleware 模式 + +**Test scenarios**: +- Happy path: 用户属于部门 A → GET /skills 返回全局 skill + 部门 A 绑定的 skill +- Happy path: 用户属于部门 A 和 B → GET /skills 返回全局 + A + B 的 skill +- Happy path: 用户无部门 → GET /skills 只返回全局 skill +- Security: 用户 A(仅部门 A)访问部门 B 绑定的 skill → 404 或不在列表中 +- Security: 用户 A 尝试通过 API 直接访问部门 B 的 KB 文档 → 403/404 +- Security: Admin 用户访问任意部门资源 → 成功(跳过过滤) +- Integration: 用户从部门 A 移除后 → 立即无法访问部门 A 的 skill + +**Verification**: `pytest tests/integration/admin/test_department_isolation.py -v` 通过;安全测试覆盖所有资源类型 + +--- + +### U5. LLM 配置管理端点(YAML 写回 + 按部门配额) + +**Goal**: 实现 LLM Provider/Model/API Key 的运行时 CRUD,写回 `agentkit.yaml`,支持按部门配额。 + +**Requirements**: R3, SC3 + +**Dependencies**: U1, U2 + +**Files**: +- `src/agentkit/server/admin/llm_config_service.py` — LlmConfigService 类 +- `src/agentkit/server/routes/admin.py` — 新增 LLM 配置端点 +- `src/agentkit/server/routes/settings.py` — 复用/扩展现有 `GET/PUT /settings/llm` +- `tests/unit/admin/test_llm_config_service.py` +- `tests/integration/admin/test_llm_config_routes.py` + +**Approach**: +- `LlmConfigService` 方法: + - `list_providers()`, `get_provider(name)`, `create_provider(name, config)`, `update_provider(name, config)`, `delete_provider(name)` + - `list_models(provider)`, `add_model(provider, model, config)`, `update_model()`, `delete_model()` + - `list_api_keys()` — 返回 provider 名 + key 前缀(不返回完整 key) + - `set_api_key(provider, key)` — 写入 YAML(`${ENV_VAR}` 替换保持) + - `get_fallbacks()`, `set_fallbacks(model, chain)` + - `set_department_quota(dept_id, quota_type, limit, period)`, `get_department_quota(dept_id)` +- 写回 YAML 用 `yaml.dump` + `fcntl.flock` 文件锁 +- 现有 `watch_config()` 监听文件变化触发热重载,无需额外通知 +- API 端点: + - `GET /api/v1/admin/llm/providers` — 列表 + - `POST /api/v1/admin/llm/providers` — 创建 + - `PATCH /api/v1/admin/llm/providers/{name}` — 编辑 + - `DELETE /api/v1/admin/llm/providers/{name}` — 删除 + - `POST /api/v1/admin/llm/providers/{name}/api-key` — 设置 API Key + - `GET /api/v1/admin/llm/fallbacks` — fallback 链 + - `PUT /api/v1/admin/llm/fallbacks/{model}` — 设置 fallback + - `GET /api/v1/admin/departments/{id}/quotas` — 部门配额 + - `PUT /api/v1/admin/departments/{id}/quotas` — 设置配额 + +**Patterns to follow**: 现有 `settings.py` 的 `GET/PUT /settings/llm` 模式 + +**Test scenarios**: +- Happy path: 添加 provider → YAML 文件更新 → 热重载触发 → 新 provider 可用 +- Happy path: 修改 API Key → YAML 更新 → 旧 key 失效 +- Happy path: 设置 fallback 链 → model A 失败时自动切换到 model B +- Happy path: 设置部门配额 → 部门用户超限 → 429 +- Edge case: YAML 文件被外部修改 → watch_config 触发重载 → 配置同步 +- Edge case: 并发修改 → 文件锁保护 → 后写者覆盖 +- Error path: 无效 provider 配置 → 400 +- Error path: 删除正在使用的 provider → 400 + +**Verification**: `pytest tests/unit/admin/test_llm_config_service.py tests/integration/admin/test_llm_config_routes.py -v` 通过;手动验证 YAML 修改后热重载 + +--- + +### U6. Skill 与 KB 管理端点(启停 + 部门绑定) + +**Goal**: 实现 Skill 启停/编辑/导入的 admin 端点,KB 文档管理 admin 端点,按部门绑定。 + +**Requirements**: R4, R5 + +**Dependencies**: U1, U2, U4 + +**Files**: +- `src/agentkit/server/admin/skill_service.py` — SkillService 类(启停/编辑/导入) +- `src/agentkit/server/admin/kb_service.py` — KbService 类(文档管理) +- `src/agentkit/server/routes/admin.py` — 新增 skill/kb 端点 +- `src/agentkit/server/routes/skill_management.py` — 实现 reload 端点(当前是 stub) +- `tests/unit/admin/test_skill_service.py` +- `tests/integration/admin/test_skill_kb_routes.py` + +**Approach**: +- `SkillService` 方法:`enable_skill(name)`, `disable_skill(name)`, `update_skill_config(name, config)`, `import_skill(yaml_content)`, `reload_skill(name)` +- 启停通过在 skill registry 中标记 `enabled=False`,查询时过滤 +- `KbService` 方法:`list_documents(department_id)`, `upload_document(file, department_id)`, `delete_document(id)`, `sync_source(id)`, `rebuild_index(id)` +- KB 文档加 `department_id` 列(在 KB store 中,当前是内存存储,需要持久化或加 metadata) +- API 端点: + - `POST /api/v1/admin/skills/{name}/enable` — 启用 + - `POST /api/v1/admin/skills/{name}/disable` — 禁用 + - `PATCH /api/v1/admin/skills/{name}` — 编辑配置 + - `POST /api/v1/admin/skills/import` — YAML 导入 + - `POST /api/v1/admin/skills/{name}/reload` — 重载 + - `GET /api/v1/admin/kb/documents` — 列表(支持 department_id 过滤) + - `POST /api/v1/admin/kb/documents` — 上传(指定 department_id) + - `DELETE /api/v1/admin/kb/documents/{id}` — 删除 + - `POST /api/v1/admin/kb/sources/{id}/sync` — 同步 + - `POST /api/v1/admin/kb/sources/{id}/rebuild` — 重建索引 + +**Patterns to follow**: 现有 `skills.py` 的 install/unregister 模式 + +**Test scenarios**: +- Happy path: 禁用 skill → GET /skills 不返回该 skill → 用户无法使用 +- Happy path: 启用 skill → 恢复可用 +- Happy path: 导入 skill YAML → 注册成功 → 可用 +- Happy path: 上传 KB 文档到部门 A → 部门 A 用户可搜索 → 部门 B 用户不可见 +- Edge case: 禁用不存在的 skill → 404 +- Edge case: 导入无效 YAML → 400 +- Security: 部门 A 用户尝试访问部门 B 的 KB 文档 → 404 +- Integration: 禁用 skill 后,正在使用该 skill 的会话 → 优雅降级或错误提示 + +**Verification**: `pytest tests/unit/admin/test_skill_service.py tests/integration/admin/test_skill_kb_routes.py -v` 通过 + +--- + +### U7. 用量仪表盘 + 配额执行 + +**Goal**: 实现按部门/用户/时间的用量查询,配额检查在 LLM 调用时执行(超限返回 429)。 + +**Requirements**: R6, SC4, SC5 + +**Dependencies**: U1, U2, U5 + +**Files**: +- `src/agentkit/llm/providers/usage_store.py` — 修改 `UsageRecord` + store 实现 +- `src/agentkit/llm/gateway.py` — 调用时传入 user_id/department_id,调用前检查配额 +- `src/agentkit/server/admin/usage_service.py` — UsageService 类(聚合查询) +- `src/agentkit/server/admin/quota_service.py` — QuotaService 类(配额检查) +- `src/agentkit/server/routes/admin.py` — 新增 usage 端点 +- `tests/unit/admin/test_usage_service.py` +- `tests/unit/admin/test_quota_service.py` +- `tests/integration/admin/test_usage_routes.py` + +**Approach**: +- `UsageRecord` 加 `user_id: str | None`, `department_id: str | None` +- `RedisUsageStore` Hash key 扩展:`agentkit:usage:{date}:{user_id}:{department_id}` +- `LLMGateway.complete()` 调用前: + 1. 获取当前 user_id + department_ids + 2. 对每个 department_id 检查配额(token/cost,daily/monthly) + 3. 超限 → raise `QuotaExceededError` → 路由层返回 429 + 4. 调用后记录 usage(含 user_id + department_id) +- `UsageService` 方法:`get_usage_summary(department_id, user_id, start, end)`, `get_usage_timeseries(department_id, user_id, start, end, interval)`, `get_usage_by_model(department_id, user_id, start, end)`, `get_top_users(department_id, limit)`, `export_usage(department_id, format)` +- API 端点: + - `GET /api/v1/admin/usage/summary?department_id=&user_id=&start=&end=` — 汇总 + - `GET /api/v1/admin/usage/timeseries?...&interval=hour/day` — 时间序列 + - `GET /api/v1/admin/usage/by-model?...` — 按 model 分桶 + - `GET /api/v1/admin/usage/top-users?department_id=&limit=` — 用户排行 + - `GET /api/v1/admin/usage/export?format=csv/json` — 导出 + +**Patterns to follow**: 现有 `llm.py` 的 `GET /llm/usage` 模式 + +**Test scenarios**: +- Happy path: 用户调用 LLM → usage 记录含 user_id + department_id +- Happy path: 查询部门 A 用量 → 返回部门 A 的聚合数据 +- Happy path: 查询用户 X 用量 → 返回用户 X 的数据 +- Happy path: 导出 CSV → 格式正确 +- Quota: 部门 A token 配额 1000 → 第 1001 token → 429 +- Quota: 用户月成本上限 $10 → 第 $11 → 429 +- Quota: 多部门用户,部门 A 超限但部门 B 未超 → 拒绝(取最严约束) +- Edge case: 无 usage 数据 → 返回空结果 +- Edge case: 跨天查询 → 按天聚合 +- Error path: 非管理员访问 → 403 + +**Verification**: `pytest tests/unit/admin/test_usage_service.py tests/unit/admin/test_quota_service.py tests/integration/admin/test_usage_routes.py -v` 通过 + +--- + +### U8. CLI admin 命令组 + +**Goal**: 实现 `agentkit admin ` 命令组,通过 HTTP 调用 server API。 + +**Requirements**: R7, SC6 + +**Dependencies**: U2, U3, U5, U6, U7 + +**Files**: +- `src/agentkit/cli/admin.py` — admin Typer sub-app +- `src/agentkit/cli/main.py` — 注册 admin sub-app +- `src/agentkit/cli/admin_client.py` — AdminHttpClient(封装 HTTP 调用) +- `tests/unit/cli/test_admin_commands.py` + +**Approach**: +- `AdminHttpClient`:从 `agentkit.yaml` 或环境变量读取 server URL + admin API key +- 命令结构: + ``` + agentkit admin department list/create/update/delete/bind-skill/unbind-skill/bind-kb/unbind-kb + agentkit admin user list/create/update/delete/reset-password/assign-department/remove-department + agentkit admin llm list-providers/add-provider/update-provider/delete-provider/set-api-key/set-fallback/set-quota + agentkit admin skill list/enable/disable/import/reload + agentkit admin kb list-documents/upload/delete/sync/rebuild + agentkit admin usage summary/timeseries/by-model/top-users/export + ``` +- 所有命令输出用 Rich 表格/JSON 格式 +- `--json` 标志输出原始 JSON(便于脚本处理) +- 认证:用 `agentkit pair` 生成的 API key(已有机制),或 admin 用户名密码登录获取 JWT + +**Patterns to follow**: 现有 `cli/task.py` 的 Typer sub-app + Rich 输出模式 + +**Test scenarios**: +- Happy path: `agentkit admin department list` → 返回部门列表(Rich 表格) +- Happy path: `agentkit admin user create --username alice --email alice@corp.com` → 创建用户 +- Happy path: `agentkit admin llm add-provider --name openai --api-key $KEY` → 添加 provider +- Happy path: `agentkit admin usage summary --department hr --start 2026-06-01 --end 2026-06-30` → 用量汇总 +- Happy path: `--json` 标志 → 输出有效 JSON +- Edge case: server 不可达 → 友好错误提示 +- Edge case: API key 无效 → 401 提示 +- Edge case: 创建重名用户 → 显示 409 错误 + +**Verification**: `pytest tests/unit/cli/test_admin_commands.py -v` 通过;手动验证 CLI 与 Web UI 操作结果一致 + +--- + +### U9. 前端 AdminLayout + 管理页面 + +**Goal**: 实现前端管理界面,包括 AdminLayout、7 个管理页面、API 客户端扩展。 + +**Requirements**: SC1, SC6 + +**Dependencies**: U2, U3, U5, U6, U7 + +**Files**: +- `src/agentkit/server/frontend/src/layouts/AdminLayout.vue` — 左侧导航 + 内容区 +- `src/agentkit/server/frontend/src/views/admin/DashboardView.vue` — 概览 +- `src/agentkit/server/frontend/src/views/admin/DepartmentsView.vue` — 部门管理 +- `src/agentkit/server/frontend/src/views/admin/UsersView.vue` — 扩展现有(加 CRUD) +- `src/agentkit/server/frontend/src/views/admin/LlmConfigView.vue` — LLM 配置 +- `src/agentkit/server/frontend/src/views/admin/SkillsView.vue` — Skill 管理 +- `src/agentkit/server/frontend/src/views/admin/KbManagementView.vue` — KB 管理 +- `src/agentkit/server/frontend/src/views/admin/UsageDashboardView.vue` — 用量仪表盘 +- `src/agentkit/server/frontend/src/api/admin.ts` — 扩展 AdminApiClient +- `src/agentkit/server/frontend/src/router/index.ts` — 更新路由结构 +- `src/agentkit/server/frontend/src/components/layout/TopNav.vue` — 更新 admin 入口 + +**Approach**: +- `AdminLayout`:左侧导航(7 个菜单项)+ 顶部用户信息 + 内容区 `` +- 路由结构改为嵌套: + ```ts + { + path: '/admin', + component: AdminLayout, + meta: { requiresAdmin: true }, + children: [ + { path: '', redirect: '/admin/dashboard' }, + { path: 'dashboard', component: DashboardView }, + { path: 'departments', component: DepartmentsView }, + { path: 'users', component: UsersView }, + { path: 'llm', component: LlmConfigView }, + { path: 'skills', component: SkillsView }, + { path: 'kb', component: KbManagementView }, + { path: 'usage', component: UsageDashboardView }, + ] + } + ``` +- `AdminApiClient` 扩展:department/user/llm/skill/kb/usage 方法 +- 各页面用 Ant Design Vue 组件(Table, Form, Modal, Card, Chart) +- 用量仪表盘用 ECharts 或 Ant Design Charts + +**Patterns to follow**: 现有 `UsersView.vue` 的双标签页 + `UserSessionsPanel` 模式 + +**Test scenarios**: +- Happy path: admin 用户访问 /admin → 重定向到 /admin/dashboard +- Happy path: 部门管理页 → 创建/编辑/删除部门 → 列表更新 +- Happy path: 用户管理页 → 创建用户 → 分配部门 → 列表更新 +- Happy path: LLM 配置页 → 添加 provider → 列表更新 → 热重载提示 +- Happy path: 用量仪表盘 → 选择部门和时间范围 → 图表显示 +- Edge case: 非管理员访问 /admin → 重定向到 /agent/chat +- Edge case: 网络错误 → 友好错误提示 +- Edge case: 表单验证错误 → 字段级错误提示 + +**Verification**: `npm run typecheck` 通过;手动验证各页面功能 + +--- + +### U10. 集成测试 + 安全测试 + +**Goal**: 端到端集成测试 + 部门隔离安全测试,验证 SC1-SC6。 + +**Requirements**: R8, SC1-SC6 + +**Dependencies**: U1-U9 + +**Files**: +- `tests/integration/admin/test_e2e_admin_flow.py` — 端到端流程测试 +- `tests/integration/admin/test_security_isolation.py` — 部门隔离安全测试 +- `tests/integration/admin/test_quota_enforcement.py` — 配额执行测试 + +**Approach**: +- E2E 流程测试: + 1. 创建部门(人事、研发) + 2. 创建用户并分配部门 + 3. 绑定 skill/KB 到部门 + 4. 配置 LLM provider + 部门配额 + 5. 用普通用户登录 → 验证只能访问所属部门资源 + 6. 查看用量仪表盘 → 数据正确 + 7. CLI 执行相同操作 → 结果一致 +- 安全测试: + 1. 人事部用户尝试访问研发部 skill → 404 + 2. 人事部用户尝试访问研发部 KB 文档 → 404 + 3. 人事部用户尝试访问研发部用量 → 403 + 4. 用户从部门移除后 → 立即无法访问该部门资源 + 5. 部门禁用后 → 该部门用户无法访问部门资源 + 6. JWT 篡改 department_ids → middleware 重新查询,不信任 JWT +- 配额测试: + 1. 设置部门 token 配额 1000 + 2. 用户调用 LLM 直到 1000 token + 3. 第 1001 token → 429 + 4. 重置配额 → 可继续调用 + +**Test scenarios**: 见上述 Approach + +**Verification**: `pytest tests/integration/admin/ -v` 通过;安全测试 100% 覆盖 + +--- + +## Scope Boundaries + +### In Scope + +- 部门 CRUD + 能力绑定 +- 用户 CRUD + 多部门归属 + 密码重置 +- LLM 配置 YAML 写回 + 按部门配额 +- Skill 启停/编辑/导入 + 部门绑定 +- KB 文档管理 + 部门隔离 +- 用量仪表盘 + 配额硬拒绝 +- CLI admin 命令组 +- 前端 AdminLayout + 7 个管理页面 +- 部门隔离安全测试 + +### Deferred to Follow-Up Work + +- 部门管理员角色(第二期) +- LLM 配置迁移到数据库(第二期) +- 审计日志(第二期) +- SLA 监控(第二期) +- SSO/SAML 集成(第二期) +- 独立管理应用部署(架构已预留,第二期实现) +- 配额软降级(第二期) +- 用量告警 Webhook/邮件(第二期,MVP 只做日志) + +### Outside This Product's Identity + +- SaaS 多租户(外部多客户、租户计费) +- 物理隔离的独立数据库 +- 计费系统集成(实际扣款) + +## Risks & Dependencies + +### Risks + +1. **部门隔离 middleware 完整性**(高)—— 任何遗漏 `department_id` 过滤的查询都是数据泄露。缓解:repository 层强制参数 + 安全测试覆盖(U10)。 +2. **LLM 配置 YAML 并发修改**(中)—— 多人同时修改 YAML 可能冲突。缓解:`fcntl.flock` 文件锁。第二期迁移到 DB。 +3. **用量跟踪 Redis Hash key 扩展**(中)—— 修改 key 结构可能影响现有数据。缓解:版本化 key(`agentkit:usage:v2:{date}:{user_id}:{dept_id}`),旧 key 保留。 +4. **KB 内存存储持久化**(中)—— 当前 KB 是内存存储,加 `department_id` 需要持久化或 metadata 扩展。缓解:MVP 用 metadata dict 存储 department_id,第二期迁移到 DB。 +5. **CLI 认证机制**(低)—— CLI 需要 server URL + API key 配置。缓解:复用 `agentkit pair` 机制。 + +### Dependencies + +- 现有 JWT 认证 + RBAC 权限模型 +- 现有 `agentkit.yaml` 文件监听热重载机制 +- 现有 `agentkit` CLI 框架(Typer) +- 现有 Ant Design Vue 组件库 + +## Phased Delivery + +| 批次 | 实施单元 | 交付物 | +|------|----------|--------| +| 批次 1 | U1, U2, U3, U4 | 部门与用户管理 + 隔离 middleware | +| 批次 2 | U5 | LLM 配置管理 | +| 批次 3 | U6 | Skill 与 KB 管理 | +| 批次 4 | U7 | 用量仪表盘 + 配额 | +| 批次 5 | U8, U9, U10 | CLI + 前端 + 集成测试 | + +每批次独立可交付、可测试、可合并。 + +## Open Questions (Resolved) + +- ~~KB 内存存储如何加 `department_id`?~~ → **已明确**:MVP 用 metadata dict 存储 department_id,不持久化(重启丢失绑定关系,可接受)。第二期迁移到 SQLite。 +- ~~CLI 认证用 API key 还是用户名密码?~~ → **已明确**:API key,复用 `agentkit pair` 机制。CLI 配置文件存储 server_url + api_key。 +- ~~用量仪表盘图表库选 ECharts 还是 Ant Design Charts?~~ → **已明确**:Ant Design Charts,与现有 UI 一致。 +- ~~部门删除策略:拒绝删除有用户的部门,还是 cascade 删除 user_departments?~~ → **已明确**:拒绝删除,强制管理员先移除用户。 diff --git a/src/agentkit/server/admin/__init__.py b/src/agentkit/server/admin/__init__.py new file mode 100644 index 0000000..1743e4a --- /dev/null +++ b/src/agentkit/server/admin/__init__.py @@ -0,0 +1,16 @@ +"""Admin Console — enterprise department-scoped management services. + +This package hosts the service layer shared by the Web UI routes +(``/api/v1/admin/*``) and the CLI ``agentkit admin`` sub-app. See +``docs/plans/2026-06-21-001-feat-admin-console-plan.md`` for the full +design. + +Submodules (added in subsequent implementation units): +- ``department_service`` (U2): department CRUD + skill/KB bindings +- ``user_service`` (U3): user CRUD + multi-department assignment +- ``middleware`` (U4): ``DepartmentContextMiddleware`` for request-scoped + department isolation +- ``llm_config_service`` (U5): LLM provider/model/key CRUD + quotas +- ``skill_service`` / ``kb_service`` (U6): skill enable/disable + KB docs +- ``usage_service`` / ``quota_service`` (U7): usage aggregation + enforcement +""" diff --git a/src/agentkit/server/admin/context.py b/src/agentkit/server/admin/context.py new file mode 100644 index 0000000..ee97f94 --- /dev/null +++ b/src/agentkit/server/admin/context.py @@ -0,0 +1,185 @@ +"""Department-scoped request context (U4 — Admin Console). + +This module provides the FastAPI dependency :func:`get_department_context` +that resolves the current user's department membership from the auth DB +and returns a :class:`DepartmentContext` describing which departments +the request should be scoped to. + +The dependency is intentionally *per-route* (via ``Depends()``) rather +than a global middleware. This keeps the department lookup close to the +routes that actually need it (skills, KB, usage) and avoids paying the +DB round-trip for whitelisted paths (``/health``, ``/docs``, etc.). + +Admin users (``role == "admin"``) bypass department filtering entirely +— :class:`DepartmentContext` is returned with ``is_admin=True`` and an +empty ``department_ids`` list, signalling to the filtering helpers in +:mod:`agentkit.server.admin.filtering` that no scoping should be +applied. + +Skills/KB with NO department binding are *global* (visible to all +users, including unauthenticated API-key clients). The filtering +helpers in :mod:`agentkit.server.admin.filtering` are responsible for +preserving this global-visibility invariant. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiosqlite +from fastapi import Request + +from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DepartmentContext: + """Request-scoped department context. + + Attributes: + user_id: The authenticated user's id, or ``None`` for API-key + clients / unauthenticated requests. + department_ids: The user's department ids (union of all + ``user_departments`` rows). Empty for admins (who bypass + filtering) and for users with no department assignments. + is_admin: ``True`` if the user has the ``admin`` role. Admins + bypass department filtering — they see all resources. + """ + + user_id: str | None = None + department_ids: list[str] = field(default_factory=list) + is_admin: bool = False + + @property + def should_filter(self) -> bool: + """Return ``True`` if the caller should be department-filtered. + + Admins and unauthenticated callers (no user_id) are NOT + filtered — admins see everything, and unauthenticated callers + only see global resources (the filtering helpers handle this by + returning only the global set when ``department_ids`` is empty + and ``is_admin`` is False). + """ + return not self.is_admin + + +# --------------------------------------------------------------------------- +# DB path resolution (mirrors routes.admin._resolve_db_path) +# --------------------------------------------------------------------------- + + +def _resolve_db_path(request: Request) -> Path: + """Resolve the auth DB path from ``app.state`` or the default.""" + path = getattr(request.app.state, "auth_db_path", None) + return Path(path) if path else DEFAULT_AUTH_DB_PATH + + +# --------------------------------------------------------------------------- +# Department-id lookup +# --------------------------------------------------------------------------- + + +async def _fetch_user_department_ids(db_path: Path, user_id: str) -> list[str]: + """Return the user's department ids from ``user_departments``. + + Only *active* departments are included — if a department is + disabled (``is_active=0``), its bindings no longer grant access to + its resources, so we drop it from the user's effective department + set here. This matches the plan's "disabled department → users + cannot access department resources" rule. + """ + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT ud.department_id " + "FROM user_departments ud " + "INNER JOIN departments d ON d.id = ud.department_id " + "WHERE ud.user_id = ? AND d.is_active = 1 " + "ORDER BY ud.department_id ASC", + (user_id,), + ) + rows = await cursor.fetchall() + return [row[0] for row in rows] + + +# --------------------------------------------------------------------------- +# FastAPI dependencies +# --------------------------------------------------------------------------- + + +async def get_department_context(request: Request) -> DepartmentContext: + """Resolve the :class:`DepartmentContext` for the current request. + + Reads ``request.state.current_user`` (set by :class:`AuthMiddleware`): + + - **Admin** (``role == "admin"``): returns + ``DepartmentContext(user_id, [], is_admin=True)`` — admins bypass + filtering. + - **API-key client / unauthenticated** (``user_id is None``): + returns ``DepartmentContext(None, [], is_admin=False)`` — the + filtering helpers will return only global resources. + - **Regular user**: queries ``user_departments`` for the user's + active department ids and returns + ``DepartmentContext(user_id, department_ids, is_admin=False)``. + + If ``request.state.current_user`` is missing entirely (e.g. the + auth middleware was not installed), returns an empty context + equivalent to the unauthenticated case. + """ + current_user: dict[str, Any] | None = getattr(request.state, "current_user", None) + if current_user is None: + return DepartmentContext(user_id=None, department_ids=[], is_admin=False) + + user_id = current_user.get("user_id") + role = current_user.get("role") + + # Admins bypass department filtering. + if role == "admin": + return DepartmentContext( + user_id=user_id, + department_ids=[], + is_admin=True, + ) + + # API-key clients have user_id=None — they see only global resources. + if not user_id: + return DepartmentContext(user_id=None, department_ids=[], is_admin=False) + + # Regular user: look up their active department ids. + db_path = _resolve_db_path(request) + try: + department_ids = await _fetch_user_department_ids(db_path, user_id) + except Exception: # noqa: BLE001 — never block a request on DB errors + logger.exception( + "Failed to fetch department ids for user %s — falling back to empty list", + user_id, + ) + department_ids = [] + + return DepartmentContext( + user_id=user_id, + department_ids=department_ids, + is_admin=False, + ) + + +async def get_department_context_optional(request: Request) -> DepartmentContext | None: + """Optional variant: returns ``None`` if no ``current_user`` is set. + + Use this on routes where auth is optional (e.g. public skill + listing) — the route can short-circuit to "global only" when the + context is ``None``. + """ + current_user: dict[str, Any] | None = getattr(request.state, "current_user", None) + if current_user is None: + return None + return await get_department_context(request) diff --git a/src/agentkit/server/admin/department_service.py b/src/agentkit/server/admin/department_service.py new file mode 100644 index 0000000..5eff559 --- /dev/null +++ b/src/agentkit/server/admin/department_service.py @@ -0,0 +1,446 @@ +"""DepartmentService — business logic for ``departments`` and bindings (U2). + +This module is the single owner of the ``departments``, +``department_skill_bindings``, and ``department_kb_bindings`` tables. +Web UI routes (``/api/v1/admin/departments/*``) and the CLI +``agentkit admin department`` sub-app both call into +:class:`DepartmentService` rather than touching the tables directly, +keeping the validation rules (duplicate-name, has-users guard) in one +place. + +The service is a module-level singleton (see :func:`get_department_service`) +so tests can inject a custom instance via :func:`set_department_service`. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite + +from agentkit.server.auth.models import department_row_to_dict + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +def _new_id() -> str: + """Return a new UUID4 string.""" + return str(uuid.uuid4()) + + +class DepartmentService: + """CRUD + skill/KB binding operations for departments. + + All methods are async and take ``db_path: Path`` as the first + argument (after ``self``). Each method opens its own short-lived + :class:`aiosqlite.Connection` — there is no shared connection state, + which keeps the service safe to call from any async context. + """ + + # ------------------------------------------------------------------ + # Department CRUD + # ------------------------------------------------------------------ + + async def create_department( + self, + db_path: Path, + name: str, + description: str = "", + ) -> dict[str, Any]: + """Create a new department. + + Args: + db_path: Path to the auth SQLite DB. + name: Department name (must be unique). + description: Optional description (defaults to empty string). + + Returns: + The newly-created department as a dict. + + Raises: + ValueError: If a department with the same name already exists. + """ + dept_id = _new_id() + now = _now_iso() + try: + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO departments (id, name, description, is_active, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (dept_id, name, description or None, 1, now), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + # The only UNIQUE constraint on departments is `name`. + raise ValueError(f"Department with name {name!r} already exists") from exc + + created = await self.get_department(db_path, dept_id) + assert created is not None # we just inserted it + return created + + async def list_departments( + self, + db_path: Path, + include_inactive: bool = True, + ) -> list[dict[str, Any]]: + """List all departments, ordered by ``created_at``. + + Args: + db_path: Path to the auth SQLite DB. + include_inactive: When ``False``, only departments with + ``is_active=1`` are returned. + + Returns: + List of department dicts (newest last). + """ + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + if include_inactive: + cursor = await db.execute("SELECT * FROM departments ORDER BY created_at ASC") + else: + cursor = await db.execute( + "SELECT * FROM departments WHERE is_active = 1 ORDER BY created_at ASC" + ) + rows = await cursor.fetchall() + return [department_row_to_dict(row) for row in rows] + + async def get_department( + self, + db_path: Path, + department_id: str, + ) -> dict[str, Any] | None: + """Return a single department by id, or ``None`` if not found.""" + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM departments WHERE id = ?", + (department_id,), + ) + row = await cursor.fetchone() + return department_row_to_dict(row) if row else None + + async def update_department( + self, + db_path: Path, + department_id: str, + name: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Partially update a department. + + Only the provided fields are updated. ``is_active`` is not + touched here — use :meth:`set_department_active` for that. + + Args: + db_path: Path to the auth SQLite DB. + department_id: Department id to update. + name: New name (must remain unique), or ``None`` to skip. + description: New description, or ``None`` to skip. + + Returns: + The updated department dict. + + Raises: + ValueError: If the department does not exist, or if the new + name collides with an existing department. + """ + existing = await self.get_department(db_path, department_id) + if existing is None: + raise ValueError(f"Department {department_id!r} not found") + + new_name = name if name is not None else existing["name"] + # Treat empty-string description as "clear" (None) to keep the + # column nullable; otherwise preserve the existing value. + if description is not None: + new_description: str | None = description or None + else: + new_description = existing["description"] + + try: + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "UPDATE departments SET name = ?, description = ? WHERE id = ?", + (new_name, new_description, department_id), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + raise ValueError(f"Department with name {new_name!r} already exists") from exc + + updated = await self.get_department(db_path, department_id) + assert updated is not None + return updated + + async def set_department_active( + self, + db_path: Path, + department_id: str, + is_active: bool, + ) -> dict[str, Any]: + """Toggle ``is_active`` on a department. + + Raises: + ValueError: If the department does not exist. + """ + existing = await self.get_department(db_path, department_id) + if existing is None: + raise ValueError(f"Department {department_id!r} not found") + + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "UPDATE departments SET is_active = ? WHERE id = ?", + (1 if is_active else 0, department_id), + ) + await db.commit() + + updated = await self.get_department(db_path, department_id) + assert updated is not None + return updated + + async def delete_department( + self, + db_path: Path, + department_id: str, + ) -> bool: + """Delete a department. + + Refuses to delete a department that still has users assigned + (rows in ``user_departments``). The caller must remove the + user-department associations first. + + Args: + db_path: Path to the auth SQLite DB. + department_id: Department id to delete. + + Returns: + ``True`` if the department was deleted, ``False`` if it did + not exist. + + Raises: + ValueError: If the department has users assigned. + """ + existing = await self.get_department(db_path, department_id) + if existing is None: + return False + + user_count = await self.count_department_users(db_path, department_id) + if user_count > 0: + raise ValueError("Department has users, remove them first") + + async with aiosqlite.connect(str(db_path)) as db: + # Cascade-clear bindings so the department row can be deleted + # without leaving orphan binding rows. (user_departments was + # already checked to be empty above.) + await db.execute( + "DELETE FROM department_skill_bindings WHERE department_id = ?", + (department_id,), + ) + await db.execute( + "DELETE FROM department_kb_bindings WHERE department_id = ?", + (department_id,), + ) + await db.execute( + "DELETE FROM department_quotas WHERE department_id = ?", + (department_id,), + ) + await db.execute( + "DELETE FROM departments WHERE id = ?", + (department_id,), + ) + await db.commit() + return True + + # ------------------------------------------------------------------ + # Skill bindings + # ------------------------------------------------------------------ + + async def bind_skill( + self, + db_path: Path, + department_id: str, + skill_name: str, + ) -> dict[str, Any]: + """Bind a skill to a department. + + Raises: + ValueError: If the binding already exists, or if the + department does not exist. + """ + existing = await self.get_department(db_path, department_id) + if existing is None: + raise ValueError(f"Department {department_id!r} not found") + + binding_id = _new_id() + now = _now_iso() + try: + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO department_skill_bindings " + "(id, department_id, skill_name, created_at) " + "VALUES (?, ?, ?, ?)", + (binding_id, department_id, skill_name, now), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + # UNIQUE (department_id, skill_name) violation. + raise ValueError( + f"Skill {skill_name!r} already bound to department {department_id!r}" + ) from exc + + return { + "id": binding_id, + "department_id": department_id, + "skill_name": skill_name, + "created_at": now, + } + + async def unbind_skill( + self, + db_path: Path, + department_id: str, + skill_name: str, + ) -> bool: + """Remove a skill binding. Returns ``True`` if a row was deleted.""" + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "DELETE FROM department_skill_bindings WHERE department_id = ? AND skill_name = ?", + (department_id, skill_name), + ) + await db.commit() + return cursor.rowcount > 0 + + async def list_department_skills( + self, + db_path: Path, + department_id: str, + ) -> list[str]: + """Return the list of skill names bound to the department.""" + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT skill_name FROM department_skill_bindings " + "WHERE department_id = ? ORDER BY skill_name ASC", + (department_id,), + ) + rows = await cursor.fetchall() + return [row[0] for row in rows] + + # ------------------------------------------------------------------ + # KB bindings + # ------------------------------------------------------------------ + + async def bind_kb( + self, + db_path: Path, + department_id: str, + kb_source_id: str, + ) -> dict[str, Any]: + """Bind a KB source to a department. + + Raises: + ValueError: If the binding already exists, or if the + department does not exist. + """ + existing = await self.get_department(db_path, department_id) + if existing is None: + raise ValueError(f"Department {department_id!r} not found") + + binding_id = _new_id() + now = _now_iso() + try: + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO department_kb_bindings " + "(id, department_id, kb_source_id, created_at) " + "VALUES (?, ?, ?, ?)", + (binding_id, department_id, kb_source_id, now), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + raise ValueError( + f"KB source {kb_source_id!r} already bound to department {department_id!r}" + ) from exc + + return { + "id": binding_id, + "department_id": department_id, + "kb_source_id": kb_source_id, + "created_at": now, + } + + async def unbind_kb( + self, + db_path: Path, + department_id: str, + kb_source_id: str, + ) -> bool: + """Remove a KB binding. Returns ``True`` if a row was deleted.""" + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "DELETE FROM department_kb_bindings WHERE department_id = ? AND kb_source_id = ?", + (department_id, kb_source_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def list_department_kbs( + self, + db_path: Path, + department_id: str, + ) -> list[str]: + """Return the list of KB source ids bound to the department.""" + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT kb_source_id FROM department_kb_bindings " + "WHERE department_id = ? ORDER BY kb_source_id ASC", + (department_id,), + ) + rows = await cursor.fetchall() + return [row[0] for row in rows] + + # ------------------------------------------------------------------ + # User count + # ------------------------------------------------------------------ + + async def count_department_users( + self, + db_path: Path, + department_id: str, + ) -> int: + """Return the number of users assigned to the department.""" + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT COUNT(*) FROM user_departments WHERE department_id = ?", + (department_id,), + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + +# --------------------------------------------------------------------------- +# Module-level singleton (overridable in tests via set_department_service) +# --------------------------------------------------------------------------- + + +_department_service: DepartmentService | None = None + + +def get_department_service() -> DepartmentService: + """Return the process-wide :class:`DepartmentService` (lazy singleton).""" + global _department_service + if _department_service is None: + _department_service = DepartmentService() + return _department_service + + +def set_department_service(service: DepartmentService | None) -> None: + """Inject a custom :class:`DepartmentService` (used by tests).""" + global _department_service + _department_service = service diff --git a/src/agentkit/server/admin/filtering.py b/src/agentkit/server/admin/filtering.py new file mode 100644 index 0000000..180b0f9 --- /dev/null +++ b/src/agentkit/server/admin/filtering.py @@ -0,0 +1,147 @@ +"""Department-scoped filtering helpers for skills and KB sources (U4). + +These helpers take a list of "all known" skill names / KB source ids +and the user's department ids (from +:class:`agentkit.server.admin.context.DepartmentContext`) and return +the subset that the user is allowed to see. + +Visibility rules (from the plan): + +- A resource bound to **any** of the user's departments is visible. +- A resource bound to **no** department is *global* — visible to + everyone (including unauthenticated callers). +- A resource bound to **other** departments only is hidden. + +Admin users bypass filtering entirely — the caller is expected to +short-circuit before calling these helpers when +``DepartmentContext.is_admin`` is ``True``. + +Both helpers are pure async functions (no FastAPI dependency) so they +can be unit-tested in isolation and reused from non-route contexts +(e.g. the CLI admin commands in U8). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import aiosqlite + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Skills +# --------------------------------------------------------------------------- + + +async def filter_skills_by_department( + db_path: Path, + department_ids: list[str], + all_skill_names: list[str], +) -> list[str]: + """Return the subset of ``all_skill_names`` visible to the user. + + Args: + db_path: Path to the auth SQLite DB. + department_ids: The user's department ids (union). Empty for + users with no department assignments — they see only global + skills. + all_skill_names: All known skill names (typically from + ``skill_registry.list_skills()``). + + Returns: + Sorted list of skill names the user is allowed to see: + global skills (no department binding) + skills bound to one of + the user's departments. + + The returned list is sorted alphabetically for deterministic + output. The order of ``all_skill_names`` is NOT preserved. + """ + if not all_skill_names: + return [] + + async with aiosqlite.connect(str(db_path)) as db: + # 1. Skills explicitly bound to one of the user's departments. + bound_skills: set[str] = set() + if department_ids: + # Build a parameterized IN (?, ?, ...) clause. + placeholders = ",".join("?" for _ in department_ids) + cursor = await db.execute( + f"SELECT DISTINCT skill_name FROM department_skill_bindings " + f"WHERE department_id IN ({placeholders})", + tuple(department_ids), + ) + for row in await cursor.fetchall(): + bound_skills.add(row[0]) + + # 2. Global skills — those with NO row in + # department_skill_bindings. We compute this as the set + # difference (all_known_skills - any_bound_skill) so we + # don't need a separate query per skill. + cursor = await db.execute( + "SELECT DISTINCT skill_name FROM department_skill_bindings" + ) + any_bound_skills = {row[0] for row in await cursor.fetchall()} + + all_known = set(all_skill_names) + global_skills = all_known - any_bound_skills + + # The user sees: their bound skills (intersected with all_known, in + # case a binding references a skill that's no longer registered) + + # the global skills. + visible = (bound_skills & all_known) | global_skills + return sorted(visible) + + +# --------------------------------------------------------------------------- +# KB sources +# --------------------------------------------------------------------------- + + +async def filter_kb_sources_by_department( + db_path: Path, + department_ids: list[str], + all_kb_source_ids: list[str], +) -> list[str]: + """Return the subset of ``all_kb_source_ids`` visible to the user. + + Same visibility rules as :func:`filter_skills_by_department` but + for KB sources (``department_kb_bindings`` table). + + Args: + db_path: Path to the auth SQLite DB. + department_ids: The user's department ids (union). Empty for + users with no department assignments. + all_kb_source_ids: All known KB source ids (typically from + ``KnowledgeSourceStore.list_sources()``). + + Returns: + Sorted list of KB source ids the user is allowed to see. + """ + if not all_kb_source_ids: + return [] + + async with aiosqlite.connect(str(db_path)) as db: + bound_sources: set[str] = set() + if department_ids: + placeholders = ",".join("?" for _ in department_ids) + cursor = await db.execute( + f"SELECT DISTINCT kb_source_id FROM department_kb_bindings " + f"WHERE department_id IN ({placeholders})", + tuple(department_ids), + ) + for row in await cursor.fetchall(): + bound_sources.add(row[0]) + + cursor = await db.execute( + "SELECT DISTINCT kb_source_id FROM department_kb_bindings" + ) + any_bound_sources = {row[0] for row in await cursor.fetchall()} + + all_known = set(all_kb_source_ids) + global_sources = all_known - any_bound_sources + + visible = (bound_sources & all_known) | global_sources + return sorted(visible) diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index 955f23b..abc315b 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -48,6 +48,7 @@ from agentkit.server.routes import ( experts, system, auth as auth_routes, + admin as admin_routes_module, ) from agentkit.server.auth.jwt_utils import get_jwt_secret from agentkit.server.auth.middleware import AuthMiddleware @@ -927,6 +928,7 @@ def create_app( app.include_router(experts.router, prefix="/api/v1") app.include_router(auth_routes.router, prefix="/api/v1") app.include_router(auth_routes.admin_router, prefix="/api/v1") + app.include_router(admin_routes_module.admin_router, prefix="/api/v1") # Serve GUI when in GUI mode gui_mode = os.environ.get("AGENTKIT_GUI_MODE") diff --git a/src/agentkit/server/auth/models.py b/src/agentkit/server/auth/models.py index 316e1ec..a42c857 100644 --- a/src/agentkit/server/auth/models.py +++ b/src/agentkit/server/auth/models.py @@ -258,6 +258,99 @@ class TerminalApprovalModel(Base): expires_at: Mapped[str] = mapped_column(String(64), nullable=False) +# --------------------------------------------------------------------------- +# V3: Department-scoped admin models (U1 — Admin Console) +# --------------------------------------------------------------------------- + + +class DepartmentModel(Base): + """Department record (V3 — Admin Console). + + A department is the unit of resource isolation: skills, KB sources, and + LLM quotas can be bound to a department. Users belong to one or more + departments via :class:`UserDepartmentModel` (many-to-many); their + effective permissions are the union of all departments they belong to. + + ``is_active=0`` disables a department: its users can still log in but + cannot access department-bound resources. + """ + + __tablename__ = "departments" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) + description: Mapped[str | None] = mapped_column(String(1024), nullable=True) + is_active: Mapped[bool] = mapped_column(default=True, nullable=False) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + +class UserDepartmentModel(Base): + """Many-to-many association between users and departments (V3). + + Composite primary key ``(user_id, department_id)`` enforces uniqueness + per (user, department) pair. A user with no rows here is a "global" user + with no department-scoped permissions. + """ + + __tablename__ = "user_departments" + + user_id: Mapped[str] = mapped_column(String(36), primary_key=True) + department_id: Mapped[str] = mapped_column(String(36), primary_key=True) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + +class DepartmentSkillBindingModel(Base): + """Skill binding for a department (V3). + + Each row grants the department access to a named skill. ``skill_name`` + references the skill registry identifier (not a DB FK — skills are + defined in YAML configs). + """ + + __tablename__ = "department_skill_bindings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + skill_name: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + +class DepartmentKbBindingModel(Base): + """Knowledge-base source binding for a department (V3). + + Each row grants the department access to a KB source. ``kb_source_id`` + references the KB source identifier (not a DB FK — KB sources are + managed by the KB subsystem). + """ + + __tablename__ = "department_kb_bindings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + kb_source_id: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + +class DepartmentQuotaModel(Base): + """Quota configuration for a department (V3). + + Each row defines a single quota for a department. ``quota_type`` is one + of ``token_limit`` / ``cost_limit`` / ``model_whitelist``. ``limit_value`` + is stored as TEXT (JSON-encoded for complex types like model_whitelist, + plain integer-as-string for simple limits). ``period`` is ``daily`` or + ``monthly``. + """ + + __tablename__ = "department_quotas" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + quota_type: Mapped[str] = mapped_column(String(32), nullable=False) + limit_value: Mapped[str] = mapped_column(String(1024), nullable=False) + period: Mapped[str] = mapped_column(String(16), nullable=False, default="daily") + updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + # --------------------------------------------------------------------------- # Schema DDL (kept in sync with the models above for aiosqlite bootstrap) # --------------------------------------------------------------------------- @@ -405,6 +498,73 @@ CREATE INDEX IF NOT EXISTS idx_terminal_approvals_session_id ON terminal_approvals(session_id); CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status ON terminal_approvals(status); + +-- V3: Department-scoped admin tables (U1 — Admin Console). +-- departments: top-level isolation unit. name is UNIQUE so admin UI can +-- reference departments by name. is_active=0 disables a department without +-- deleting it (users keep their user_departments rows but lose access to +-- department-bound resources). +CREATE TABLE IF NOT EXISTS departments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +-- V3: user_departments many-to-many. Composite PK (user_id, department_id) +-- enforces uniqueness per pair. A user with no rows here is a "global" user. +CREATE TABLE IF NOT EXISTS user_departments ( + user_id TEXT NOT NULL, + department_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, department_id) +); +CREATE INDEX IF NOT EXISTS idx_user_departments_user_id + ON user_departments(user_id); +CREATE INDEX IF NOT EXISTS idx_user_departments_department_id + ON user_departments(department_id); + +-- V3: department_skill_bindings grants a department access to a named skill. +-- UNIQUE (department_id, skill_name) prevents duplicate bindings. +CREATE TABLE IF NOT EXISTS department_skill_bindings ( + id TEXT PRIMARY KEY, + department_id TEXT NOT NULL, + skill_name TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (department_id, skill_name) +); +CREATE INDEX IF NOT EXISTS idx_department_skill_bindings_department_id + ON department_skill_bindings(department_id); + +-- V3: department_kb_bindings grants a department access to a KB source. +-- UNIQUE (department_id, kb_source_id) prevents duplicate bindings. +CREATE TABLE IF NOT EXISTS department_kb_bindings ( + id TEXT PRIMARY KEY, + department_id TEXT NOT NULL, + kb_source_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (department_id, kb_source_id) +); +CREATE INDEX IF NOT EXISTS idx_department_kb_bindings_department_id + ON department_kb_bindings(department_id); + +-- V3: department_quotas stores per-department quota configuration. +-- quota_type ∈ {token_limit, cost_limit, model_whitelist}. +-- limit_value is TEXT (JSON for model_whitelist, integer-as-string for +-- simple limits). period ∈ {daily, monthly}. UNIQUE (department_id, +-- quota_type, period) ensures one row per quota type per period. +CREATE TABLE IF NOT EXISTS department_quotas ( + id TEXT PRIMARY KEY, + department_id TEXT NOT NULL, + quota_type TEXT NOT NULL, + limit_value TEXT NOT NULL, + period TEXT NOT NULL DEFAULT 'daily', + updated_at TEXT NOT NULL, + UNIQUE (department_id, quota_type, period) +); +CREATE INDEX IF NOT EXISTS idx_department_quotas_department_id + ON department_quotas(department_id); """ @@ -416,7 +576,11 @@ CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status # that require data backfill or migration. The :func:`init_auth_db` function # uses this together with the ``auth_meta.schema_version`` row to decide # which migrations to run. -_SCHEMA_VERSION = 2 +# +# V3 (2026-06-21, Admin Console): added departments, user_departments, +# department_skill_bindings, department_kb_bindings, department_quotas. +# No backfill needed — all new tables are additive. +_SCHEMA_VERSION = 3 _META_SCHEMA_VERSION_KEY = "schema_version" @@ -615,3 +779,27 @@ def auth_session_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[ "revoked_reason": row["revoked_reason"], "previous_session_id": row["previous_session_id"], } + + +def department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]: + """Convert a ``departments`` row into a JSON-safe dict. + + The ``is_active`` field is normalized to a Python ``bool`` (the DB + stores 0/1). + """ + return { + "id": row["id"], + "name": row["name"], + "description": row["description"], + "is_active": bool(row["is_active"]), + "created_at": row["created_at"], + } + + +def user_department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]: + """Convert a ``user_departments`` row into a JSON-safe dict.""" + return { + "user_id": row["user_id"], + "department_id": row["department_id"], + "created_at": row["created_at"], + } diff --git a/src/agentkit/server/frontend/components.d.ts b/src/agentkit/server/frontend/components.d.ts index 7ba3ab6..3897cd6 100644 --- a/src/agentkit/server/frontend/components.d.ts +++ b/src/agentkit/server/frontend/components.d.ts @@ -15,9 +15,11 @@ declare module 'vue' { ABadge: typeof import('ant-design-vue/es')['Badge'] AButton: typeof import('ant-design-vue/es')['Button'] ACard: typeof import('ant-design-vue/es')['Card'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] ACol: typeof import('ant-design-vue/es')['Col'] ACollapse: typeof import('ant-design-vue/es')['Collapse'] ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel'] + ActiveSessionsPanel: typeof import('./src/components/settings/ActiveSessionsPanel.vue')['default'] ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem'] ADivider: typeof import('ant-design-vue/es')['Divider'] @@ -54,6 +56,7 @@ declare module 'vue' { BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default'] BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default'] BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default'] + ChangePasswordPanel: typeof import('./src/components/settings/ChangePasswordPanel.vue')['default'] ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default'] ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default'] ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default'] @@ -119,6 +122,7 @@ declare module 'vue' { TopNav: typeof import('./src/components/layout/TopNav.vue')['default'] UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default'] UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default'] + UserSessionsPanel: typeof import('./src/components/admin/UserSessionsPanel.vue')['default'] WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default'] } } diff --git a/src/agentkit/server/routes/kb_management.py b/src/agentkit/server/routes/kb_management.py index 2c0cad0..7919ed8 100644 --- a/src/agentkit/server/routes/kb_management.py +++ b/src/agentkit/server/routes/kb_management.py @@ -7,13 +7,17 @@ import logging import uuid from dataclasses import dataclass, field from datetime import datetime, timezone +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Security, UploadFile, File from fastapi.security import APIKeyHeader, APIKeyQuery from pydantic import BaseModel +from agentkit.server.admin.context import DepartmentContext, get_department_context +from agentkit.server.admin.filtering import filter_kb_sources_by_department from agentkit.server.auth.dependencies import require_permission +from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH from agentkit.server.auth.permissions import Permission logger = logging.getLogger(__name__) @@ -189,9 +193,48 @@ class UpdateSourceRequest(BaseModel): @router.get("/kb-management/sources") -async def list_sources(req: Request, _auth: None = Depends(_verify_api_key)): - """List all knowledge sources.""" +async def list_sources( + req: Request, + _auth: None = Depends(_verify_api_key), + dept_ctx: DepartmentContext = Depends(get_department_context), +): + """List all knowledge sources. + + Department filtering (U4): non-admin users see only: + - KB sources bound to one of their departments, OR + - KB sources with no department binding (global sources). + + Admin users (``role == "admin"``) bypass filtering and see all + sources. Unauthenticated callers (API-key clients) see only global + sources. + """ sources = _source_store.list_sources() + + if not dept_ctx.should_filter: + return _serialize_sources(sources) + + db_path = _resolve_kb_db_path(req) + all_ids = [s.id for s in sources] + try: + visible_ids = await filter_kb_sources_by_department( + db_path, dept_ctx.department_ids, all_ids + ) + except Exception: # noqa: BLE001 — never block listing on DB errors + logger.exception("Department KB filtering failed — returning empty list") + return {"sources": []} + visible_set = set(visible_ids) + filtered = [s for s in sources if s.id in visible_set] + return _serialize_sources(filtered) + + +def _resolve_kb_db_path(req: Request) -> Path: + """Resolve the auth DB path from ``app.state`` or the default.""" + path = getattr(req.app.state, "auth_db_path", None) + return Path(path) if path else DEFAULT_AUTH_DB_PATH + + +def _serialize_sources(sources: list[KnowledgeSource]) -> dict[str, Any]: + """Serialize KB sources to the GET /kb-management/sources response shape.""" return { "sources": [ { @@ -287,9 +330,51 @@ async def update_source( @router.get("/kb-management/documents") -async def list_documents(source_id: str | None = None, _auth: None = Depends(_verify_api_key)): - """List all documents, optionally filtered by source_id.""" +async def list_documents( + req: Request, + source_id: str | None = None, + _auth: None = Depends(_verify_api_key), + dept_ctx: DepartmentContext = Depends(get_department_context), +): + """List all documents, optionally filtered by source_id. + + Department filtering (U4): non-admin users see only documents + belonging to: + - KB sources bound to one of their departments, OR + - KB sources with no department binding (global sources). + + Admin users (``role == "admin"``) bypass filtering and see all + documents. Unauthenticated callers (API-key clients) see only + documents from global sources. + + If ``source_id`` is provided AND the caller is not allowed to see + that source, an empty list is returned (rather than 404 — the + source may exist, the caller just can't see it). + """ documents = _source_store.list_documents(source_id) + + if not dept_ctx.should_filter: + return _serialize_documents(documents) + + # Compute visible source ids across ALL known sources, then filter + # documents whose source_id is in the visible set. + all_sources = _source_store.list_sources() + all_source_ids = [s.id for s in all_sources] + db_path = _resolve_kb_db_path(req) + try: + visible_ids = await filter_kb_sources_by_department( + db_path, dept_ctx.department_ids, all_source_ids + ) + except Exception: # noqa: BLE001 — never block listing on DB errors + logger.exception("Department KB filtering failed — returning empty list") + return {"documents": []} + visible_set = set(visible_ids) + filtered = [d for d in documents if d.source_id in visible_set] + return _serialize_documents(filtered) + + +def _serialize_documents(documents: list[UploadedDocument]) -> dict[str, Any]: + """Serialize documents to the GET /kb-management/documents response shape.""" return { "documents": [ { diff --git a/src/agentkit/server/routes/skills.py b/src/agentkit/server/routes/skills.py index aeb97b4..ee03c8d 100644 --- a/src/agentkit/server/routes/skills.py +++ b/src/agentkit/server/routes/skills.py @@ -4,12 +4,16 @@ import logging import os import re import urllib.parse +from pathlib import Path import httpx -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from typing import Any +from agentkit.server.admin.context import DepartmentContext, get_department_context +from agentkit.server.admin.filtering import filter_skills_by_department +from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.pipeline import SkillPipeline @@ -139,10 +143,50 @@ async def register_skill(request: RegisterSkillRequest, req: Request): @router.get("/skills") -async def list_skills(req: Request): - """List all skills with full metadata""" +async def list_skills( + req: Request, + dept_ctx: DepartmentContext = Depends(get_department_context), +): + """List all skills with full metadata. + + Department filtering (U4): non-admin users see only: + - skills bound to one of their departments, OR + - skills with no department binding (global skills). + + Admin users (``role == "admin"``) bypass filtering and see all + registered skills. Unauthenticated callers (API-key clients) see + only global skills. + """ skill_registry = req.app.state.skill_registry skills = skill_registry.list_skills() + + # Admins bypass department filtering. + if not dept_ctx.should_filter: + return _serialize_skills(skills) + + # Non-admin: filter by department bindings. + db_path = _resolve_db_path(req) + all_names = [s.name for s in skills] + try: + visible_names = await filter_skills_by_department( + db_path, dept_ctx.department_ids, all_names + ) + except Exception: # noqa: BLE001 — never block listing on DB errors + logger.exception("Department skill filtering failed — returning empty list") + return [] + visible_set = set(visible_names) + filtered = [s for s in skills if s.name in visible_set] + return _serialize_skills(filtered) + + +def _resolve_db_path(req: Request) -> Path: + """Resolve the auth DB path from ``app.state`` or the default.""" + path = getattr(req.app.state, "auth_db_path", None) + return Path(path) if path else DEFAULT_AUTH_DB_PATH + + +def _serialize_skills(skills: list[Skill]) -> list[dict[str, Any]]: + """Serialize a list of Skill objects to the GET /skills response shape.""" return [ { "name": s.name, diff --git a/tests/integration/admin/__init__.py b/tests/integration/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/admin/test_department_isolation.py b/tests/integration/admin/test_department_isolation.py new file mode 100644 index 0000000..acebe7d --- /dev/null +++ b/tests/integration/admin/test_department_isolation.py @@ -0,0 +1,504 @@ +"""Integration tests for department-scoped isolation (U4). + +Verifies that: + +- A user belonging to department A sees only: + - skills bound to department A, AND + - skills with NO department binding (global skills). +- A user belonging to both A and B sees skills from A + B + global. +- A user with NO departments sees only global skills. +- An admin user bypasses filtering entirely (sees everything). +- KB source filtering follows the same rules. +- Removing a user from a department immediately revokes visibility + of that department's bound resources. + +The tests mount a minimal FastAPI app with the ``skills`` and +``kb-management`` routers, plus a real auth DB (init_auth_db) so the +``department_skill_bindings`` / ``department_kb_bindings`` tables +exist. The ``get_department_context`` dependency is overridden per +test to simulate different callers (admin, user-in-A, user-in-B, +user-in-both, user-in-none). +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from agentkit.server.admin.context import DepartmentContext +from agentkit.server.auth.models import init_auth_db +from agentkit.server.routes import kb_management as kb_routes +from agentkit.server.routes import skills as skills_routes +from agentkit.skills.base import Skill, SkillConfig +from agentkit.skills.registry import SkillRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def tmp_auth_db(tmp_path: Path) -> Path: + db_path = tmp_path / "dept_isolation.db" + await init_auth_db(db_path) + return db_path + + +@pytest.fixture +def skill_registry() -> SkillRegistry: + """A SkillRegistry pre-loaded with three test skills. + + - ``hr_skill`` — will be bound to department A. + - ``dev_skill`` — will be bound to department B. + - ``global_skill`` — has NO department binding (global). + """ + registry = SkillRegistry() + for name in ("hr_skill", "dev_skill", "global_skill"): + config = SkillConfig( + name=name, + agent_type="test_type", + task_mode="llm_generate", + description=f"Test skill {name}", + prompt={"identity": name, "instructions": "test"}, + ) + registry.register(Skill(config=config)) + return registry + + +@pytest.fixture +def kb_store(): + """Reset the module-level KB source store singleton. + + The KB routes use a module-level ``_source_store`` singleton. We + reset it before each test so tests don't leak state. + """ + kb_routes._source_store = kb_routes.KnowledgeSourceStore() + return kb_routes._source_store + + +@pytest.fixture +def app( + tmp_auth_db: Path, + skill_registry: SkillRegistry, + kb_store: kb_routes.KnowledgeSourceStore, +) -> FastAPI: + """A minimal FastAPI app with skills + kb-management routers. + + The ``get_department_context`` dependency is overridden per-test + via ``app.dependency_overrides``. The default override is "no + current user" (unauthenticated) — individual tests install their + own override. + """ + application = FastAPI() + application.state.auth_db_path = str(tmp_auth_db) + application.state.skill_registry = skill_registry + # KB routes read from the module-level _source_store, not app.state, + # so we don't need to install it on app.state. + + application.include_router(skills_routes.router, prefix="/api/v1") + application.include_router(kb_routes.router, prefix="/api/v1") + + # Default: unauthenticated caller (no current_user on request.state). + # Tests override this via app.dependency_overrides. + application.dependency_overrides[ + skills_routes.get_department_context + ] = _unauthenticated_context + application.dependency_overrides[ + kb_routes.get_department_context + ] = _unauthenticated_context + return application + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Department-context dependency overrides +# --------------------------------------------------------------------------- + + +async def _unauthenticated_context(request: Request) -> DepartmentContext: + """Simulate an unauthenticated caller (no current_user).""" + return DepartmentContext(user_id=None, department_ids=[], is_admin=False) + + +def _ctx_for_user( + user_id: str | None, + department_ids: list[str], + is_admin: bool = False, +): + """Build a dependency override returning a fixed DepartmentContext.""" + + async def _override(request: Request) -> DepartmentContext: + return DepartmentContext( + user_id=user_id, + department_ids=list(department_ids), + is_admin=is_admin, + ) + + return _override + + +def _set_caller( + app: FastAPI, + user_id: str | None, + department_ids: list[str], + is_admin: bool = False, +) -> None: + """Install the dependency overrides for both routers.""" + override = _ctx_for_user(user_id, department_ids, is_admin) + app.dependency_overrides[skills_routes.get_department_context] = override + app.dependency_overrides[kb_routes.get_department_context] = override + + +# --------------------------------------------------------------------------- +# DB helpers (synchronous sqlite3 — no event-loop mixing with TestClient) +# --------------------------------------------------------------------------- + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _create_department(db_path: Path, name: str) -> str: + dept_id = str(uuid.uuid4()) + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO departments (id, name, description, is_active, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (dept_id, name, "", 1, _now_iso()), + ) + db.commit() + return dept_id + + +def _bind_skill(db_path: Path, department_id: str, skill_name: str) -> None: + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO department_skill_bindings (id, department_id, skill_name, created_at) " + "VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), department_id, skill_name, _now_iso()), + ) + db.commit() + + +def _bind_kb(db_path: Path, department_id: str, kb_source_id: str) -> None: + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO department_kb_bindings (id, department_id, kb_source_id, created_at) " + "VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), department_id, kb_source_id, _now_iso()), + ) + db.commit() + + +def _assign_user_to_department( + db_path: Path, user_id: str, department_id: str +) -> None: + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + (user_id, department_id, _now_iso()), + ) + db.commit() + + +def _remove_user_from_department( + db_path: Path, user_id: str, department_id: str +) -> None: + with sqlite3.connect(str(db_path)) as db: + db.execute( + "DELETE FROM user_departments WHERE user_id = ? AND department_id = ?", + (user_id, department_id), + ) + db.commit() + + +# --------------------------------------------------------------------------- +# Test fixtures: departments A and B with skill/KB bindings +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dept_setup(tmp_auth_db: Path, kb_store: kb_routes.KnowledgeSourceStore): + """Create departments A and B, bind skills and KB sources. + + Layout: + - Department A: bound to ``hr_skill`` and KB source ``hr_kb`` + - Department B: bound to ``dev_skill`` and KB source ``dev_kb`` + - ``global_skill`` and ``global_kb`` have NO bindings (global) + """ + dept_a = _create_department(tmp_auth_db, "HR") + dept_b = _create_department(tmp_auth_db, "Dev") + _bind_skill(tmp_auth_db, dept_a, "hr_skill") + _bind_skill(tmp_auth_db, dept_b, "dev_skill") + # global_skill intentionally has no binding. + + # Create KB sources in the in-memory store. + hr_kb = kb_store.add_source("HR KB", "local", {}) + dev_kb = kb_store.add_source("Dev KB", "local", {}) + global_kb = kb_store.add_source("Global KB", "local", {}) + _bind_kb(tmp_auth_db, dept_a, hr_kb.id) + _bind_kb(tmp_auth_db, dept_b, dev_kb.id) + # global_kb intentionally has no binding. + + return { + "dept_a": dept_a, + "dept_b": dept_b, + "hr_kb_id": hr_kb.id, + "dev_kb_id": dev_kb.id, + "global_kb_id": global_kb.id, + } + + +# --------------------------------------------------------------------------- +# Skill isolation tests +# --------------------------------------------------------------------------- + + +class TestSkillIsolation: + """GET /api/v1/skills must respect department bindings.""" + + def test_user_in_dept_a_sees_hr_and_global_not_dev( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller(app, user_id="user-a", department_ids=[dept_setup["dept_a"]]) + resp = client.get("/api/v1/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert "hr_skill" in names + assert "global_skill" in names + assert "dev_skill" not in names + + def test_admin_sees_all_skills( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller( + app, + user_id="admin-1", + department_ids=[], + is_admin=True, + ) + resp = client.get("/api/v1/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert names == {"hr_skill", "dev_skill", "global_skill"} + + def test_user_with_no_departments_sees_only_global( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller(app, user_id="lonely-user", department_ids=[]) + resp = client.get("/api/v1/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert names == {"global_skill"} + + def test_user_in_both_departments_sees_all_bound_plus_global( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller( + app, + user_id="user-ab", + department_ids=[dept_setup["dept_a"], dept_setup["dept_b"]], + ) + resp = client.get("/api/v1/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert names == {"hr_skill", "dev_skill", "global_skill"} + + def test_unauthenticated_caller_sees_only_global( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + # Default override is unauthenticated. + resp = client.get("/api/v1/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert names == {"global_skill"} + + def test_user_removed_from_dept_a_loses_hr_skill( + self, + app: FastAPI, + client: TestClient, + tmp_auth_db: Path, + dept_setup: dict, + ): + """After removal, the user no longer sees hr_skill.""" + user_id = "user-removal" + # Initially assign to dept A. + _assign_user_to_department(tmp_auth_db, user_id, dept_setup["dept_a"]) + _set_caller(app, user_id=user_id, department_ids=[dept_setup["dept_a"]]) + + resp = client.get("/api/v1/skills") + names = {s["name"] for s in resp.json()} + assert "hr_skill" in names + + # Remove from dept A — simulate the context change by updating + # the override (in production, the next request's + # get_department_context would re-query user_departments and + # find no rows). + _remove_user_from_department(tmp_auth_db, user_id, dept_setup["dept_a"]) + _set_caller(app, user_id=user_id, department_ids=[]) + + resp = client.get("/api/v1/skills") + names = {s["name"] for s in resp.json()} + assert "hr_skill" not in names + assert "global_skill" in names + + +# --------------------------------------------------------------------------- +# KB source isolation tests +# --------------------------------------------------------------------------- + + +class TestKbSourceIsolation: + """GET /api/v1/kb-management/sources must respect department bindings.""" + + def test_user_in_dept_a_sees_hr_and_global_kb( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller(app, user_id="user-a", department_ids=[dept_setup["dept_a"]]) + resp = client.get("/api/v1/kb-management/sources") + assert resp.status_code == 200 + ids = {s["id"] for s in resp.json()["sources"]} + assert dept_setup["hr_kb_id"] in ids + assert dept_setup["global_kb_id"] in ids + assert dept_setup["dev_kb_id"] not in ids + + def test_admin_sees_all_kb_sources( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller( + app, + user_id="admin-1", + department_ids=[], + is_admin=True, + ) + resp = client.get("/api/v1/kb-management/sources") + assert resp.status_code == 200 + ids = {s["id"] for s in resp.json()["sources"]} + assert ids == { + dept_setup["hr_kb_id"], + dept_setup["dev_kb_id"], + dept_setup["global_kb_id"], + } + + def test_user_with_no_departments_sees_only_global_kb( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller(app, user_id="lonely-user", department_ids=[]) + resp = client.get("/api/v1/kb-management/sources") + assert resp.status_code == 200 + ids = {s["id"] for s in resp.json()["sources"]} + assert ids == {dept_setup["global_kb_id"]} + + def test_user_in_both_departments_sees_all_kb( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + _set_caller( + app, + user_id="user-ab", + department_ids=[dept_setup["dept_a"], dept_setup["dept_b"]], + ) + resp = client.get("/api/v1/kb-management/sources") + assert resp.status_code == 200 + ids = {s["id"] for s in resp.json()["sources"]} + assert ids == { + dept_setup["hr_kb_id"], + dept_setup["dev_kb_id"], + dept_setup["global_kb_id"], + } + + def test_unauthenticated_caller_sees_only_global_kb( + self, app: FastAPI, client: TestClient, dept_setup: dict + ): + resp = client.get("/api/v1/kb-management/sources") + assert resp.status_code == 200 + ids = {s["id"] for s in resp.json()["sources"]} + assert ids == {dept_setup["global_kb_id"]} + + +# --------------------------------------------------------------------------- +# KB document isolation tests +# --------------------------------------------------------------------------- + + +class TestKbDocumentIsolation: + """GET /api/v1/kb-management/documents must respect department bindings.""" + + @pytest.fixture + def docs_setup(self, dept_setup: dict, kb_store: kb_routes.KnowledgeSourceStore): + """Add one document to each KB source.""" + for source_id_key, filename in [ + ("hr_kb_id", "hr_doc.txt"), + ("dev_kb_id", "dev_doc.txt"), + ("global_kb_id", "global_doc.txt"), + ]: + source_id = dept_setup[source_id_key] + doc = kb_routes.UploadedDocument( + document_id=str(uuid.uuid4()), + filename=filename, + source_id=source_id, + chunks=1, + status="indexed", + ) + kb_store.add_document(doc) + return dept_setup + + def test_user_in_dept_a_sees_only_hr_and_global_docs( + self, app: FastAPI, client: TestClient, docs_setup: dict + ): + _set_caller(app, user_id="user-a", department_ids=[docs_setup["dept_a"]]) + resp = client.get("/api/v1/kb-management/documents") + assert resp.status_code == 200 + filenames = {d["filename"] for d in resp.json()["documents"]} + assert "hr_doc.txt" in filenames + assert "global_doc.txt" in filenames + assert "dev_doc.txt" not in filenames + + def test_admin_sees_all_documents( + self, app: FastAPI, client: TestClient, docs_setup: dict + ): + _set_caller( + app, + user_id="admin-1", + department_ids=[], + is_admin=True, + ) + resp = client.get("/api/v1/kb-management/documents") + assert resp.status_code == 200 + filenames = {d["filename"] for d in resp.json()["documents"]} + assert filenames == {"hr_doc.txt", "dev_doc.txt", "global_doc.txt"} + + def test_user_in_dept_a_filtering_by_dev_source_returns_empty( + self, app: FastAPI, client: TestClient, docs_setup: dict + ): + """A user in dept A asking for dept B's source_id gets nothing.""" + _set_caller(app, user_id="user-a", department_ids=[docs_setup["dept_a"]]) + resp = client.get( + "/api/v1/kb-management/documents", + params={"source_id": docs_setup["dev_kb_id"]}, + ) + assert resp.status_code == 200 + assert resp.json()["documents"] == [] + + def test_user_in_dept_a_can_query_own_source( + self, app: FastAPI, client: TestClient, docs_setup: dict + ): + _set_caller(app, user_id="user-a", department_ids=[docs_setup["dept_a"]]) + resp = client.get( + "/api/v1/kb-management/documents", + params={"source_id": docs_setup["hr_kb_id"]}, + ) + assert resp.status_code == 200 + filenames = {d["filename"] for d in resp.json()["documents"]} + assert filenames == {"hr_doc.txt"} diff --git a/tests/integration/admin/test_department_routes.py b/tests/integration/admin/test_department_routes.py new file mode 100644 index 0000000..28e4f28 --- /dev/null +++ b/tests/integration/admin/test_department_routes.py @@ -0,0 +1,372 @@ +"""Integration tests for the department admin routes (U2). + +Uses FastAPI TestClient with a test app that mounts only the new +``admin_router`` from ``routes.admin``. The ``_require_admin`` dependency +is overridden via ``app.dependency_overrides`` so the tests don't need +real JWTs — they can simulate admin and non-admin callers directly. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from agentkit.server.auth.models import init_auth_db +from agentkit.server.routes import admin as admin_routes_module + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def tmp_auth_db(tmp_path: Path) -> Path: + db_path = tmp_path / "admin_departments.db" + await init_auth_db(db_path) + return db_path + + +def _make_admin_user() -> dict[str, Any]: + return {"user_id": "admin-1", "username": "admin", "role": "admin"} + + +@pytest.fixture +def admin_app(tmp_auth_db: Path) -> FastAPI: + """A minimal FastAPI app with only the department admin router mounted. + + The ``_require_admin`` dependency is overridden to return a fake admin + user. Individual tests can re-override it to simulate a non-admin. + """ + app = FastAPI() + app.state.auth_db_path = str(tmp_auth_db) + app.include_router(admin_routes_module.admin_router, prefix="/api/v1") + + # Default: allow admin access. + app.dependency_overrides[admin_routes_module._require_admin] = lambda: _make_admin_user() + return app + + +@pytest.fixture +def admin_client(admin_app: FastAPI) -> TestClient: + return TestClient(admin_app) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _insert_user(db_path: Path, *, user_id: str | None = None) -> str: + """Insert a minimal user row synchronously (for test setup).""" + user_id = user_id or str(uuid.uuid4()) + now_iso = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, + f"user-{user_id[:8]}", + f"{user_id[:8]}@example.com", + "$2b$12$placeholder.hash.placeholder.hash.placeholder.hash", + "member", + 1, + 0, + 0, + now_iso, + now_iso, + ), + ) + db.commit() + return user_id + + +def _assign_user_to_department(db_path: Path, user_id: str, department_id: str) -> None: + """Insert a user_departments row synchronously (for test setup).""" + with sqlite3.connect(str(db_path)) as db: + db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) VALUES (?, ?, ?)", + (user_id, department_id, datetime.now(timezone.utc).isoformat()), + ) + db.commit() + + +def _create_department(client: TestClient, name: str, description: str = "") -> dict: + resp = client.post( + "/api/v1/admin/departments", + json={"name": name, "description": description}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +# --------------------------------------------------------------------------- +# Department CRUD +# --------------------------------------------------------------------------- + + +class TestCreateDepartment: + def test_create_returns_201_with_department_dict(self, admin_client: TestClient): + resp = admin_client.post( + "/api/v1/admin/departments", + json={"name": "Engineering", "description": "Eng team"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["id"] + assert body["name"] == "Engineering" + assert body["description"] == "Eng team" + assert body["is_active"] is True + + def test_create_duplicate_name_returns_409(self, admin_client: TestClient): + _create_department(admin_client, "Engineering") + resp = admin_client.post( + "/api/v1/admin/departments", + json={"name": "Engineering"}, + ) + assert resp.status_code == 409 + + def test_non_admin_returns_403(self, admin_app: FastAPI): + """When _require_admin raises 403, the endpoint returns 403.""" + admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden + client = TestClient(admin_app) + resp = client.post( + "/api/v1/admin/departments", + json={"name": "Engineering"}, + ) + assert resp.status_code == 403 + + +class TestListDepartments: + def test_list_returns_all_departments(self, admin_client: TestClient): + _create_department(admin_client, "Engineering") + _create_department(admin_client, "HR") + resp = admin_client.get("/api/v1/admin/departments") + assert resp.status_code == 200 + names = {d["name"] for d in resp.json()} + assert names == {"Engineering", "HR"} + + def test_list_exclude_inactive(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/disable") + resp = admin_client.get("/api/v1/admin/departments", params={"include_inactive": False}) + assert resp.status_code == 200 + assert resp.json() == [] + + +class TestGetDepartment: + def test_get_returns_department_by_id(self, admin_client: TestClient): + created = _create_department(admin_client, "Engineering") + resp = admin_client.get(f"/api/v1/admin/departments/{created['id']}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Engineering" + + def test_get_unknown_id_returns_404(self, admin_client: TestClient): + resp = admin_client.get(f"/api/v1/admin/departments/{uuid.uuid4()}") + assert resp.status_code == 404 + + +class TestUpdateDepartment: + def test_update_name_and_description(self, admin_client: TestClient): + created = _create_department(admin_client, "Engineering", "Old") + resp = admin_client.patch( + f"/api/v1/admin/departments/{created['id']}", + json={"name": "Eng", "description": "New"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Eng" + assert body["description"] == "New" + + def test_update_duplicate_name_returns_409(self, admin_client: TestClient): + _create_department(admin_client, "Engineering") + hr = _create_department(admin_client, "HR") + resp = admin_client.patch( + f"/api/v1/admin/departments/{hr['id']}", + json={"name": "Engineering"}, + ) + assert resp.status_code == 409 + + def test_update_unknown_id_returns_404(self, admin_client: TestClient): + resp = admin_client.patch( + f"/api/v1/admin/departments/{uuid.uuid4()}", + json={"name": "X"}, + ) + assert resp.status_code == 404 + + +class TestDisableEnableDepartment: + def test_disable_sets_is_active_false(self, admin_client: TestClient): + created = _create_department(admin_client, "Engineering") + resp = admin_client.post(f"/api/v1/admin/departments/{created['id']}/disable") + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + def test_enable_sets_is_active_true(self, admin_client: TestClient): + created = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{created['id']}/disable") + resp = admin_client.post(f"/api/v1/admin/departments/{created['id']}/enable") + assert resp.status_code == 200 + assert resp.json()["is_active"] is True + + def test_disable_unknown_id_returns_404(self, admin_client: TestClient): + resp = admin_client.post(f"/api/v1/admin/departments/{uuid.uuid4()}/disable") + assert resp.status_code == 404 + + +class TestDeleteDepartment: + def test_delete_removes_department(self, admin_client: TestClient): + created = _create_department(admin_client, "Engineering") + resp = admin_client.delete(f"/api/v1/admin/departments/{created['id']}") + assert resp.status_code == 200 + assert resp.json() == {"deleted": True} + # Confirm it's gone. + assert admin_client.get(f"/api/v1/admin/departments/{created['id']}").status_code == 404 + + def test_delete_unknown_id_returns_404(self, admin_client: TestClient): + resp = admin_client.delete(f"/api/v1/admin/departments/{uuid.uuid4()}") + assert resp.status_code == 404 + + def test_delete_department_with_users_returns_400( + self, admin_client: TestClient, tmp_auth_db: Path + ): + created = _create_department(admin_client, "Engineering") + # Insert a user and assign to the department directly in the DB + # (synchronous sqlite3 — no event-loop mixing with TestClient). + user_id = _insert_user(tmp_auth_db) + _assign_user_to_department(tmp_auth_db, user_id, created["id"]) + resp = admin_client.delete(f"/api/v1/admin/departments/{created['id']}") + assert resp.status_code == 400 + assert "users" in resp.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Skill bindings +# --------------------------------------------------------------------------- + + +class TestSkillBindings: + def test_bind_skill_returns_201(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + resp = admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + assert resp.status_code == 201 + body = resp.json() + assert body["skill_name"] == "code_review" + assert body["department_id"] == dept["id"] + + def test_bind_duplicate_skill_returns_409(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + resp = admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + assert resp.status_code == 409 + + def test_list_department_skills(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/web_search") + resp = admin_client.get(f"/api/v1/admin/departments/{dept['id']}/skills") + assert resp.status_code == 200 + assert resp.json() == ["code_review", "web_search"] + + def test_unbind_skill(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + resp = admin_client.delete(f"/api/v1/admin/departments/{dept['id']}/skills/code_review") + assert resp.status_code == 200 + assert resp.json() == {"unbound": True} + # Confirm it's gone. + assert admin_client.get(f"/api/v1/admin/departments/{dept['id']}/skills").json() == [] + + def test_unbind_skill_idempotent(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + resp = admin_client.delete(f"/api/v1/admin/departments/{dept['id']}/skills/never_bound") + assert resp.status_code == 200 + assert resp.json() == {"unbound": True} + + +# --------------------------------------------------------------------------- +# KB bindings +# --------------------------------------------------------------------------- + + +class TestKbBindings: + def test_bind_kb_returns_201(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + resp = admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + assert resp.status_code == 201 + body = resp.json() + assert body["kb_source_id"] == "kb-source-1" + assert body["department_id"] == dept["id"] + + def test_bind_duplicate_kb_returns_409(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + resp = admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + assert resp.status_code == 409 + + def test_list_department_kbs(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-2") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + resp = admin_client.get(f"/api/v1/admin/departments/{dept['id']}/kb") + assert resp.status_code == 200 + assert resp.json() == ["kb-source-1", "kb-source-2"] + + def test_unbind_kb(self, admin_client: TestClient): + dept = _create_department(admin_client, "Engineering") + admin_client.post(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + resp = admin_client.delete(f"/api/v1/admin/departments/{dept['id']}/kb/kb-source-1") + assert resp.status_code == 200 + assert resp.json() == {"unbound": True} + assert admin_client.get(f"/api/v1/admin/departments/{dept['id']}/kb").json() == [] + + +# --------------------------------------------------------------------------- +# Non-admin access +# --------------------------------------------------------------------------- + + +class TestNonAdminAccess: + """All department endpoints must return 403 for non-admin users.""" + + def test_non_admin_cannot_create(self, admin_app: FastAPI): + admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden + client = TestClient(admin_app) + resp = client.post( + "/api/v1/admin/departments", + json={"name": "Engineering"}, + ) + assert resp.status_code == 403 + + def test_non_admin_cannot_list(self, admin_app: FastAPI): + admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden + client = TestClient(admin_app) + resp = client.get("/api/v1/admin/departments") + assert resp.status_code == 403 + + def test_non_admin_cannot_delete(self, admin_app: FastAPI): + admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden + client = TestClient(admin_app) + resp = client.delete(f"/api/v1/admin/departments/{uuid.uuid4()}") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Helpers for non-admin simulation +# --------------------------------------------------------------------------- + + +def _raise_forbidden() -> dict[str, Any]: + """Dependency override that simulates a non-admin (403) response.""" + raise HTTPException(status_code=403, detail="Admin permission required") diff --git a/tests/unit/admin/__init__.py b/tests/unit/admin/__init__.py new file mode 100644 index 0000000..fbd68b1 --- /dev/null +++ b/tests/unit/admin/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the admin console subsystem.""" diff --git a/tests/unit/admin/test_department_service.py b/tests/unit/admin/test_department_service.py new file mode 100644 index 0000000..7bab48c --- /dev/null +++ b/tests/unit/admin/test_department_service.py @@ -0,0 +1,421 @@ +"""Unit tests for DepartmentService (U2 — department CRUD + bindings). + +Covers: +- Happy path: create → list → get → update → disable → enable → delete +- Happy path: bind_skill → list_skills → unbind_skill +- Happy path: bind_kb → list_kbs → unbind_kb +- Edge case: create duplicate name → ValueError +- Edge case: delete department with users → ValueError +- Edge case: update to duplicate name → ValueError +- Edge case: get/update/delete non-existent department → None/ValueError/False +- Edge case: bind duplicate skill → ValueError +- Edge case: count_department_users returns correct count +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path + +import aiosqlite +import pytest + +from agentkit.server.admin.department_service import DepartmentService +from agentkit.server.auth.models import init_auth_db + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def fresh_db(tmp_path: Path) -> Path: + """A brand-new auth DB on a fresh path (no data).""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + return db_path + + +@pytest.fixture +def service() -> DepartmentService: + return DepartmentService() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def _insert_user(db_path: Path, *, user_id: str | None = None) -> str: + """Insert a minimal user row and return its id.""" + user_id = user_id or str(uuid.uuid4()) + now_iso = _now_iso() + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, + f"user-{user_id[:8]}", + f"{user_id[:8]}@example.com", + "$2b$12$placeholder.hash.placeholder.hash.placeholder.hash", + "member", + 1, + 0, + 0, + now_iso, + now_iso, + ), + ) + await db.commit() + return user_id + + +async def _assign_user_to_department(db_path: Path, user_id: str, department_id: str) -> None: + """Insert a user_departments row.""" + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) VALUES (?, ?, ?)", + (user_id, department_id, _now_iso()), + ) + await db.commit() + + +# --------------------------------------------------------------------------- +# Department CRUD happy path +# --------------------------------------------------------------------------- + + +class TestDepartmentCrudHappyPath: + async def test_create_returns_department_dict(self, service: DepartmentService, fresh_db: Path): + dept = await service.create_department(fresh_db, "Engineering", "Eng team") + assert dept["id"] + assert dept["name"] == "Engineering" + assert dept["description"] == "Eng team" + assert dept["is_active"] is True + assert "created_at" in dept + + async def test_create_with_empty_description_stores_none( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "HR") + assert dept["description"] is None + + async def test_list_returns_created_departments( + self, service: DepartmentService, fresh_db: Path + ): + await service.create_department(fresh_db, "Engineering") + await service.create_department(fresh_db, "HR") + depts = await service.list_departments(fresh_db) + assert len(depts) == 2 + names = {d["name"] for d in depts} + assert names == {"Engineering", "HR"} + + async def test_list_excludes_inactive_when_asked( + self, service: DepartmentService, fresh_db: Path + ): + eng = await service.create_department(fresh_db, "Engineering") + await service.set_department_active(fresh_db, eng["id"], is_active=False) + active_only = await service.list_departments(fresh_db, include_inactive=False) + assert len(active_only) == 0 + all_depts = await service.list_departments(fresh_db, include_inactive=True) + assert len(all_depts) == 1 + + async def test_get_returns_department_by_id(self, service: DepartmentService, fresh_db: Path): + created = await service.create_department(fresh_db, "Engineering") + fetched = await service.get_department(fresh_db, created["id"]) + assert fetched is not None + assert fetched["id"] == created["id"] + assert fetched["name"] == "Engineering" + + async def test_get_returns_none_for_unknown_id( + self, service: DepartmentService, fresh_db: Path + ): + result = await service.get_department(fresh_db, str(uuid.uuid4())) + assert result is None + + async def test_update_name_and_description(self, service: DepartmentService, fresh_db: Path): + created = await service.create_department(fresh_db, "Engineering", "Old desc") + updated = await service.update_department( + fresh_db, created["id"], name="Eng", description="New desc" + ) + assert updated["name"] == "Eng" + assert updated["description"] == "New desc" + + async def test_update_partial_only_changes_provided_fields( + self, service: DepartmentService, fresh_db: Path + ): + created = await service.create_department(fresh_db, "Engineering", "Old desc") + # Only update name, description should be preserved. + updated = await service.update_department(fresh_db, created["id"], name="Eng") + assert updated["name"] == "Eng" + assert updated["description"] == "Old desc" + + async def test_disable_sets_is_active_false(self, service: DepartmentService, fresh_db: Path): + created = await service.create_department(fresh_db, "Engineering") + disabled = await service.set_department_active(fresh_db, created["id"], is_active=False) + assert disabled["is_active"] is False + + async def test_enable_sets_is_active_true(self, service: DepartmentService, fresh_db: Path): + created = await service.create_department(fresh_db, "Engineering") + await service.set_department_active(fresh_db, created["id"], is_active=False) + enabled = await service.set_department_active(fresh_db, created["id"], is_active=True) + assert enabled["is_active"] is True + + async def test_delete_removes_department(self, service: DepartmentService, fresh_db: Path): + created = await service.create_department(fresh_db, "Engineering") + deleted = await service.delete_department(fresh_db, created["id"]) + assert deleted is True + # Confirm it's gone. + assert await service.get_department(fresh_db, created["id"]) is None + + async def test_delete_returns_false_for_unknown_id( + self, service: DepartmentService, fresh_db: Path + ): + deleted = await service.delete_department(fresh_db, str(uuid.uuid4())) + assert deleted is False + + +# --------------------------------------------------------------------------- +# Department CRUD edge cases +# --------------------------------------------------------------------------- + + +class TestDepartmentCrudEdgeCases: + async def test_create_duplicate_name_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + await service.create_department(fresh_db, "Engineering") + with pytest.raises(ValueError, match="already exists"): + await service.create_department(fresh_db, "Engineering") + + async def test_update_to_duplicate_name_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + await service.create_department(fresh_db, "Engineering") + hr = await service.create_department(fresh_db, "HR") + with pytest.raises(ValueError, match="already exists"): + await service.update_department(fresh_db, hr["id"], name="Engineering") + + async def test_update_nonexistent_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + with pytest.raises(ValueError, match="not found"): + await service.update_department(fresh_db, str(uuid.uuid4()), name="X") + + async def test_set_active_nonexistent_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + with pytest.raises(ValueError, match="not found"): + await service.set_department_active(fresh_db, str(uuid.uuid4()), is_active=False) + + async def test_delete_department_with_users_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + user_id = await _insert_user(fresh_db) + await _assign_user_to_department(fresh_db, user_id, dept["id"]) + with pytest.raises(ValueError, match="Department has users"): + await service.delete_department(fresh_db, dept["id"]) + + async def test_delete_department_after_removing_users_succeeds( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + user_id = await _insert_user(fresh_db) + await _assign_user_to_department(fresh_db, user_id, dept["id"]) + # Remove the user-department association directly. + async with aiosqlite.connect(str(fresh_db)) as db: + await db.execute( + "DELETE FROM user_departments WHERE department_id = ?", + (dept["id"],), + ) + await db.commit() + # Now deletion should succeed. + deleted = await service.delete_department(fresh_db, dept["id"]) + assert deleted is True + + async def test_delete_cascades_bindings(self, service: DepartmentService, fresh_db: Path): + """Deleting a department should also clear its skill/KB bindings.""" + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_skill(fresh_db, dept["id"], "code_review") + await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + await service.delete_department(fresh_db, dept["id"]) + # Bindings should be gone. + assert await service.list_department_skills(fresh_db, dept["id"]) == [] + assert await service.list_department_kbs(fresh_db, dept["id"]) == [] + + +# --------------------------------------------------------------------------- +# Skill bindings +# --------------------------------------------------------------------------- + + +class TestSkillBindings: + async def test_bind_skill_returns_binding_dict( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + binding = await service.bind_skill(fresh_db, dept["id"], "code_review") + assert binding["department_id"] == dept["id"] + assert binding["skill_name"] == "code_review" + assert binding["id"] + assert "created_at" in binding + + async def test_list_department_skills_returns_bound_names( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_skill(fresh_db, dept["id"], "code_review") + await service.bind_skill(fresh_db, dept["id"], "web_search") + skills = await service.list_department_skills(fresh_db, dept["id"]) + assert skills == ["code_review", "web_search"] # sorted alphabetically + + async def test_unbind_skill_removes_binding(self, service: DepartmentService, fresh_db: Path): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_skill(fresh_db, dept["id"], "code_review") + unbound = await service.unbind_skill(fresh_db, dept["id"], "code_review") + assert unbound is True + assert await service.list_department_skills(fresh_db, dept["id"]) == [] + + async def test_unbind_skill_returns_false_for_nonexistent( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + unbound = await service.unbind_skill(fresh_db, dept["id"], "never_bound") + assert unbound is False + + async def test_bind_duplicate_skill_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_skill(fresh_db, dept["id"], "code_review") + with pytest.raises(ValueError, match="already bound"): + await service.bind_skill(fresh_db, dept["id"], "code_review") + + async def test_bind_skill_to_nonexistent_department_raises( + self, service: DepartmentService, fresh_db: Path + ): + with pytest.raises(ValueError, match="not found"): + await service.bind_skill(fresh_db, str(uuid.uuid4()), "code_review") + + async def test_same_skill_name_in_different_departments( + self, service: DepartmentService, fresh_db: Path + ): + dept_a = await service.create_department(fresh_db, "Engineering") + dept_b = await service.create_department(fresh_db, "HR") + await service.bind_skill(fresh_db, dept_a["id"], "shared_skill") + await service.bind_skill(fresh_db, dept_b["id"], "shared_skill") + assert len(await service.list_department_skills(fresh_db, dept_a["id"])) == 1 + assert len(await service.list_department_skills(fresh_db, dept_b["id"])) == 1 + + +# --------------------------------------------------------------------------- +# KB bindings +# --------------------------------------------------------------------------- + + +class TestKbBindings: + async def test_bind_kb_returns_binding_dict(self, service: DepartmentService, fresh_db: Path): + dept = await service.create_department(fresh_db, "Engineering") + binding = await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + assert binding["department_id"] == dept["id"] + assert binding["kb_source_id"] == "kb-source-1" + assert binding["id"] + assert "created_at" in binding + + async def test_list_department_kbs_returns_bound_ids( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_kb(fresh_db, dept["id"], "kb-source-2") + await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + kbs = await service.list_department_kbs(fresh_db, dept["id"]) + assert kbs == ["kb-source-1", "kb-source-2"] # sorted alphabetically + + async def test_unbind_kb_removes_binding(self, service: DepartmentService, fresh_db: Path): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + unbound = await service.unbind_kb(fresh_db, dept["id"], "kb-source-1") + assert unbound is True + assert await service.list_department_kbs(fresh_db, dept["id"]) == [] + + async def test_unbind_kb_returns_false_for_nonexistent( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + unbound = await service.unbind_kb(fresh_db, dept["id"], "never_bound") + assert unbound is False + + async def test_bind_duplicate_kb_raises_value_error( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + with pytest.raises(ValueError, match="already bound"): + await service.bind_kb(fresh_db, dept["id"], "kb-source-1") + + async def test_bind_kb_to_nonexistent_department_raises( + self, service: DepartmentService, fresh_db: Path + ): + with pytest.raises(ValueError, match="not found"): + await service.bind_kb(fresh_db, str(uuid.uuid4()), "kb-source-1") + + +# --------------------------------------------------------------------------- +# count_department_users +# --------------------------------------------------------------------------- + + +class TestCountDepartmentUsers: + async def test_count_returns_zero_for_empty_department( + self, service: DepartmentService, fresh_db: Path + ): + dept = await service.create_department(fresh_db, "Engineering") + count = await service.count_department_users(fresh_db, dept["id"]) + assert count == 0 + + async def test_count_returns_correct_count(self, service: DepartmentService, fresh_db: Path): + dept = await service.create_department(fresh_db, "Engineering") + user_a = await _insert_user(fresh_db) + user_b = await _insert_user(fresh_db) + await _assign_user_to_department(fresh_db, user_a, dept["id"]) + await _assign_user_to_department(fresh_db, user_b, dept["id"]) + count = await service.count_department_users(fresh_db, dept["id"]) + assert count == 2 + + async def test_count_returns_zero_for_unknown_department( + self, service: DepartmentService, fresh_db: Path + ): + count = await service.count_department_users(fresh_db, str(uuid.uuid4())) + assert count == 0 + + +# --------------------------------------------------------------------------- +# Singleton helpers +# --------------------------------------------------------------------------- + + +class TestSingletonHelpers: + def test_get_department_service_returns_singleton(self): + from agentkit.server.admin.department_service import ( + get_department_service, + set_department_service, + ) + + # Save the original singleton so we don't disturb other tests. + original = get_department_service() + try: + custom = DepartmentService() + set_department_service(custom) + assert get_department_service() is custom + # Clearing falls back to a new lazy instance. + set_department_service(None) + new_one = get_department_service() + assert new_one is not custom + finally: + set_department_service(original) diff --git a/tests/unit/admin/test_models.py b/tests/unit/admin/test_models.py new file mode 100644 index 0000000..82e8bae --- /dev/null +++ b/tests/unit/admin/test_models.py @@ -0,0 +1,567 @@ +"""Unit tests for auth.models V3 — department-scoped admin tables (U1). + +Covers: +- ``init_auth_db`` creates the new V3 tables (departments, user_departments, + department_skill_bindings, department_kb_bindings, department_quotas) +- ``init_auth_db`` is idempotent (calling twice does not error) +- ``_SCHEMA_VERSION`` is recorded as 3 in ``auth_meta`` +- ``departments`` insert + query round-trip +- ``user_departments`` many-to-many relationship (one user → many departments, + one department → many users) +- ``department_skill_bindings`` UNIQUE (department_id, skill_name) constraint +- ``department_quotas`` UNIQUE (department_id, quota_type, period) constraint +- ``department_row_to_dict`` / ``user_department_row_to_dict`` helpers +- Indexes are created for the common access patterns +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path + +import aiosqlite +import pytest + +from agentkit.server.auth.models import ( + DepartmentKbBindingModel, + DepartmentModel, + DepartmentQuotaModel, + DepartmentSkillBindingModel, + UserDepartmentModel, + _SCHEMA_VERSION, + department_row_to_dict, + init_auth_db, + user_department_row_to_dict, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def fresh_db(tmp_path: Path) -> Path: + """A brand-new auth DB on a fresh path (no data).""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + return db_path + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def _insert_department( + db: aiosqlite.Connection, + *, + dept_id: str | None = None, + name: str | None = None, + description: str | None = None, + is_active: bool = True, +) -> str: + """Insert a minimal department row and return its id.""" + dept_id = dept_id or str(uuid.uuid4()) + name = name or f"dept-{dept_id[:8]}" + await db.execute( + "INSERT INTO departments (id, name, description, is_active, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (dept_id, name, description, 1 if is_active else 0, _now_iso()), + ) + return dept_id + + +async def _insert_user(db: aiosqlite.Connection, *, user_id: str | None = None) -> str: + """Insert a minimal user row and return its id.""" + user_id = user_id or str(uuid.uuid4()) + now_iso = _now_iso() + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, + f"user-{user_id[:8]}", + f"{user_id[:8]}@example.com", + "$2b$12$placeholder.hash.placeholder.hash.placeholder.hash", + "member", + 1, + 0, + 0, + now_iso, + now_iso, + ), + ) + return user_id + + +async def _list_index_names(db: aiosqlite.Connection, table: str) -> set[str]: + """Return the set of index names for a table.""" + db.row_factory = aiosqlite.Row + cursor = await db.execute(f"PRAGMA index_list({table})") + rows = await cursor.fetchall() + return {row["name"] for row in rows} + + +async def _list_table_names(db: aiosqlite.Connection) -> set[str]: + """Return the set of table names in the SQLite file.""" + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + rows = await cursor.fetchall() + return {row[0] for row in rows} + + +# --------------------------------------------------------------------------- +# _SCHEMA_VERSION +# --------------------------------------------------------------------------- + + +class TestSchemaVersion: + def test_schema_version_is_v3(self): + """V3 adds the department-scoped admin tables.""" + assert _SCHEMA_VERSION == 3 + + def test_sqlalchemy_model_table_names(self): + assert DepartmentModel.__tablename__ == "departments" + assert UserDepartmentModel.__tablename__ == "user_departments" + assert DepartmentSkillBindingModel.__tablename__ == "department_skill_bindings" + assert DepartmentKbBindingModel.__tablename__ == "department_kb_bindings" + assert DepartmentQuotaModel.__tablename__ == "department_quotas" + + +# --------------------------------------------------------------------------- +# init_auth_db: table creation + idempotency +# --------------------------------------------------------------------------- + + +class TestInitAuthDbTables: + async def test_creates_departments_table(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + tables = await _list_table_names(db) + assert "departments" in tables + + async def test_creates_user_departments_table(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + tables = await _list_table_names(db) + assert "user_departments" in tables + + async def test_creates_department_skill_bindings_table(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + tables = await _list_table_names(db) + assert "department_skill_bindings" in tables + + async def test_creates_department_kb_bindings_table(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + tables = await _list_table_names(db) + assert "department_kb_bindings" in tables + + async def test_creates_department_quotas_table(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + tables = await _list_table_names(db) + assert "department_quotas" in tables + + async def test_records_schema_version_3_in_auth_meta(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT value FROM auth_meta WHERE key='schema_version'") + row = await cursor.fetchone() + assert row is not None + assert row["value"] == "3" + assert row["value"] == str(_SCHEMA_VERSION) + + async def test_init_auth_db_is_idempotent(self, tmp_path: Path): + """Calling init_auth_db twice on the same path must not error.""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + # Second call should be a no-op (CREATE TABLE IF NOT EXISTS + idempotent + # meta upsert). Must not raise. + await init_auth_db(db_path) + + async with aiosqlite.connect(str(db_path)) as db: + tables = await _list_table_names(db) + assert "departments" in tables + assert "user_departments" in tables + + +# --------------------------------------------------------------------------- +# Indexes +# --------------------------------------------------------------------------- + + +class TestDepartmentIndexes: + async def test_user_departments_user_id_index(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + indexes = await _list_index_names(db, "user_departments") + assert "idx_user_departments_user_id" in indexes + + async def test_user_departments_department_id_index(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + indexes = await _list_index_names(db, "user_departments") + assert "idx_user_departments_department_id" in indexes + + async def test_department_skill_bindings_department_id_index(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + indexes = await _list_index_names(db, "department_skill_bindings") + assert "idx_department_skill_bindings_department_id" in indexes + + async def test_department_kb_bindings_department_id_index(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + indexes = await _list_index_names(db, "department_kb_bindings") + assert "idx_department_kb_bindings_department_id" in indexes + + async def test_department_quotas_department_id_index(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + indexes = await _list_index_names(db, "department_quotas") + assert "idx_department_quotas_department_id" in indexes + + +# --------------------------------------------------------------------------- +# departments: insert + query +# --------------------------------------------------------------------------- + + +class TestDepartmentsCrud: + async def test_insert_and_query_department(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department( + db, + dept_id=dept_id, + name="Engineering", + description="Software engineering department", + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM departments WHERE id=?", (dept_id,)) + row = await cursor.fetchone() + + assert row is not None + assert row["id"] == dept_id + assert row["name"] == "Engineering" + assert row["description"] == "Software engineering department" + assert bool(row["is_active"]) is True + + async def test_department_name_is_unique(self, fresh_db: Path): + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, name="HR", description="Human Resources") + await db.commit() + # Inserting a second department with the same name must fail. + with pytest.raises(sqlite3.IntegrityError): + await _insert_department(db, name="HR", description="Duplicate") + await db.commit() + + async def test_department_is_active_defaults_to_true(self, fresh_db: Path): + """Insert without is_active → column should default to 1 (True).""" + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await db.execute( + "INSERT INTO departments (id, name, created_at) VALUES (?, ?, ?)", + (dept_id, "DefaultActive", _now_iso()), + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT is_active FROM departments WHERE id=?", (dept_id,)) + row = await cursor.fetchone() + assert row is not None + assert bool(row["is_active"]) is True + + async def test_department_description_is_nullable(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await db.execute( + "INSERT INTO departments (id, name, created_at) VALUES (?, ?, ?)", + (dept_id, "NoDescription", _now_iso()), + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT description FROM departments WHERE id=?", (dept_id,) + ) + row = await cursor.fetchone() + assert row is not None + assert row["description"] is None + + +# --------------------------------------------------------------------------- +# user_departments: many-to-many relationship +# --------------------------------------------------------------------------- + + +class TestUserDepartmentsManyToMany: + async def test_user_can_belong_to_multiple_departments(self, fresh_db: Path): + user_id = str(uuid.uuid4()) + dept_a = str(uuid.uuid4()) + dept_b = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_user(db, user_id=user_id) + await _insert_department(db, dept_id=dept_a, name="DeptA") + await _insert_department(db, dept_id=dept_b, name="DeptB") + now = _now_iso() + await db.executemany( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + [(user_id, dept_a, now), (user_id, dept_b, now)], + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT department_id FROM user_departments WHERE user_id=? " + "ORDER BY department_id", + (user_id,), + ) + rows = await cursor.fetchall() + + dept_ids = [row["department_id"] for row in rows] + assert dept_ids == sorted([dept_a, dept_b]) + + async def test_department_can_have_multiple_users(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + user_a = str(uuid.uuid4()) + user_b = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="Shared") + await _insert_user(db, user_id=user_a) + await _insert_user(db, user_id=user_b) + now = _now_iso() + await db.executemany( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + [(user_a, dept_id, now), (user_b, dept_id, now)], + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT user_id FROM user_departments WHERE department_id=? " + "ORDER BY user_id", + (dept_id,), + ) + rows = await cursor.fetchall() + + user_ids = [row["user_id"] for row in rows] + assert user_ids == sorted([user_a, user_b]) + + async def test_composite_pk_prevents_duplicate_pair(self, fresh_db: Path): + """The (user_id, department_id) composite PK rejects duplicate pairs.""" + user_id = str(uuid.uuid4()) + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_user(db, user_id=user_id) + await _insert_department(db, dept_id=dept_id, name="Unique") + now = _now_iso() + await db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + (user_id, dept_id, now), + ) + await db.commit() + with pytest.raises(sqlite3.IntegrityError): + await db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + (user_id, dept_id, now), + ) + await db.commit() + + +# --------------------------------------------------------------------------- +# department_skill_bindings: UNIQUE constraint +# --------------------------------------------------------------------------- + + +class TestDepartmentSkillBindingsUnique: + async def test_unique_department_skill_pair(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="Bindings") + now = _now_iso() + await db.execute( + "INSERT INTO department_skill_bindings " + "(id, department_id, skill_name, created_at) VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), dept_id, "code_review", now), + ) + await db.commit() + # Same (department_id, skill_name) pair must fail, even with a new id. + with pytest.raises(sqlite3.IntegrityError): + await db.execute( + "INSERT INTO department_skill_bindings " + "(id, department_id, skill_name, created_at) VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), dept_id, "code_review", now), + ) + await db.commit() + + async def test_same_skill_name_in_different_departments_is_allowed( + self, fresh_db: Path + ): + dept_a = str(uuid.uuid4()) + dept_b = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_a, name="DeptA") + await _insert_department(db, dept_id=dept_b, name="DeptB") + now = _now_iso() + await db.executemany( + "INSERT INTO department_skill_bindings " + "(id, department_id, skill_name, created_at) VALUES (?, ?, ?, ?)", + [ + (str(uuid.uuid4()), dept_a, "shared_skill", now), + (str(uuid.uuid4()), dept_b, "shared_skill", now), + ], + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT COUNT(*) AS c FROM department_skill_bindings " + "WHERE skill_name='shared_skill'" + ) + row = await cursor.fetchone() + assert row["c"] == 2 + + +# --------------------------------------------------------------------------- +# department_quotas: UNIQUE constraint +# --------------------------------------------------------------------------- + + +class TestDepartmentQuotasUnique: + async def test_unique_department_quota_type_period(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="Quota") + now = _now_iso() + await db.execute( + "INSERT INTO department_quotas " + "(id, department_id, quota_type, limit_value, period, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), dept_id, "token_limit", "10000", "daily", now), + ) + await db.commit() + # Same (department_id, quota_type, period) triple must fail. + with pytest.raises(sqlite3.IntegrityError): + await db.execute( + "INSERT INTO department_quotas " + "(id, department_id, quota_type, limit_value, period, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), dept_id, "token_limit", "20000", "daily", now), + ) + await db.commit() + + async def test_same_quota_type_different_period_is_allowed(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="QuotaPeriods") + now = _now_iso() + await db.executemany( + "INSERT INTO department_quotas " + "(id, department_id, quota_type, limit_value, period, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + (str(uuid.uuid4()), dept_id, "token_limit", "10000", "daily", now), + (str(uuid.uuid4()), dept_id, "token_limit", "300000", "monthly", now), + ], + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT period, limit_value FROM department_quotas " + "WHERE department_id=? AND quota_type='token_limit' " + "ORDER BY period", + (dept_id,), + ) + rows = await cursor.fetchall() + assert len(rows) == 2 + assert rows[0]["period"] == "daily" + assert rows[0]["limit_value"] == "10000" + assert rows[1]["period"] == "monthly" + assert rows[1]["limit_value"] == "300000" + + async def test_quota_period_defaults_to_daily(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="DefaultPeriod") + await db.execute( + "INSERT INTO department_quotas " + "(id, department_id, quota_type, limit_value, updated_at) " + "VALUES (?, ?, ?, ?, ?)", + (str(uuid.uuid4()), dept_id, "cost_limit", "10.00", _now_iso()), + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT period FROM department_quotas WHERE department_id=?", + (dept_id,), + ) + row = await cursor.fetchone() + assert row is not None + assert row["period"] == "daily" + + +# --------------------------------------------------------------------------- +# row_to_dict helpers +# --------------------------------------------------------------------------- + + +class TestRowToDictHelpers: + async def test_department_row_to_dict(self, fresh_db: Path): + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department( + db, + dept_id=dept_id, + name="HelperTest", + description="Testing the helper", + is_active=False, + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM departments WHERE id=?", (dept_id,)) + row = await cursor.fetchone() + + d = department_row_to_dict(row) + assert d["id"] == dept_id + assert d["name"] == "HelperTest" + assert d["description"] == "Testing the helper" + assert isinstance(d["is_active"], bool) + assert d["is_active"] is False + assert "created_at" in d + + async def test_department_row_to_dict_normalizes_is_active(self, fresh_db: Path): + """DB stores 0/1; helper should return Python bool.""" + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_department(db, dept_id=dept_id, name="BoolCheck", is_active=True) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM departments WHERE id=?", (dept_id,)) + row = await cursor.fetchone() + d = department_row_to_dict(row) + assert isinstance(d["is_active"], bool) + assert d["is_active"] is True + + async def test_user_department_row_to_dict(self, fresh_db: Path): + user_id = str(uuid.uuid4()) + dept_id = str(uuid.uuid4()) + async with aiosqlite.connect(str(fresh_db)) as db: + await _insert_user(db, user_id=user_id) + await _insert_department(db, dept_id=dept_id, name="UserDeptHelper") + now = _now_iso() + await db.execute( + "INSERT INTO user_departments (user_id, department_id, created_at) " + "VALUES (?, ?, ?)", + (user_id, dept_id, now), + ) + await db.commit() + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM user_departments WHERE user_id=? AND department_id=?", + (user_id, dept_id), + ) + row = await cursor.fetchone() + + d = user_department_row_to_dict(row) + assert d["user_id"] == user_id + assert d["department_id"] == dept_id + assert d["created_at"] == now diff --git a/tests/unit/auth/test_models.py b/tests/unit/auth/test_models.py index 3b399ff..d3d33b1 100644 --- a/tests/unit/auth/test_models.py +++ b/tests/unit/auth/test_models.py @@ -118,9 +118,9 @@ async def _list_index_names(db: aiosqlite.Connection, table: str) -> set[str]: class TestSchemaVersion: - def test_schema_version_is_v2(self): - """The current schema version is 2 (V2 adds auth_sessions + auth_meta).""" - assert _SCHEMA_VERSION == 2 + def test_schema_version_is_v3(self): + """The current schema version is 3 (V3 adds department-scoped admin tables).""" + assert _SCHEMA_VERSION == 3 def test_sqlalchemy_model_table_name(self): assert AuthSessionModel.__tablename__ == "auth_sessions"