feat(admin): U1+U2+U4 — schema v3, department service, context filtering
U1: Bump _SCHEMA_VERSION to 3, add 5 department tables (departments, user_departments, department_skill_bindings, department_kb_bindings, department_quotas) + 5 ORM models + helpers. U2: DepartmentService (12 async methods: CRUD + bind/unbind skill/KB + count_users). Mount admin_router in app.py. 36 unit + 28 integration tests. U4: DepartmentContext FastAPI dependency (per-route, admin bypasses filtering). filter_skills_by_department / filter_kb_sources_by_department helpers. Applied to GET /skills and GET /kb-management/* routes. 15 integration tests for department isolation. Also includes brainstorm + plan docs. 108 new tests, all pass.
This commit is contained in:
parent
6dca9ba4f2
commit
ad65f7a8d7
|
|
@ -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 <domain> <action>` 执行管理操作,与 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 <domain> <action>` 镜像所有 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 埋点)
|
||||||
|
|
||||||
|
每批次独立可交付、可测试、可合并。
|
||||||
|
|
@ -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 <domain> <action>` 镜像 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 <domain> <action>` 命令组,通过 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 个菜单项)+ 顶部用户信息 + 内容区 `<router-view />`
|
||||||
|
- 路由结构改为嵌套:
|
||||||
|
```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?~~ → **已明确**:拒绝删除,强制管理员先移除用户。
|
||||||
|
|
@ -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
|
||||||
|
"""
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -48,6 +48,7 @@ from agentkit.server.routes import (
|
||||||
experts,
|
experts,
|
||||||
system,
|
system,
|
||||||
auth as auth_routes,
|
auth as auth_routes,
|
||||||
|
admin as admin_routes_module,
|
||||||
)
|
)
|
||||||
from agentkit.server.auth.jwt_utils import get_jwt_secret
|
from agentkit.server.auth.jwt_utils import get_jwt_secret
|
||||||
from agentkit.server.auth.middleware import AuthMiddleware
|
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(experts.router, prefix="/api/v1")
|
||||||
app.include_router(auth_routes.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(auth_routes.admin_router, prefix="/api/v1")
|
||||||
|
app.include_router(admin_routes_module.admin_router, prefix="/api/v1")
|
||||||
|
|
||||||
# Serve GUI when in GUI mode
|
# Serve GUI when in GUI mode
|
||||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,99 @@ class TerminalApprovalModel(Base):
|
||||||
expires_at: Mapped[str] = mapped_column(String(64), nullable=False)
|
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)
|
# 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);
|
ON terminal_approvals(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status
|
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status
|
||||||
ON 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
|
# that require data backfill or migration. The :func:`init_auth_db` function
|
||||||
# uses this together with the ``auth_meta.schema_version`` row to decide
|
# uses this together with the ``auth_meta.schema_version`` row to decide
|
||||||
# which migrations to run.
|
# 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"
|
_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"],
|
"revoked_reason": row["revoked_reason"],
|
||||||
"previous_session_id": row["previous_session_id"],
|
"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"],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ declare module 'vue' {
|
||||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||||
AButton: typeof import('ant-design-vue/es')['Button']
|
AButton: typeof import('ant-design-vue/es')['Button']
|
||||||
ACard: typeof import('ant-design-vue/es')['Card']
|
ACard: typeof import('ant-design-vue/es')['Card']
|
||||||
|
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||||
ACol: typeof import('ant-design-vue/es')['Col']
|
ACol: typeof import('ant-design-vue/es')['Col']
|
||||||
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||||
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
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']
|
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||||
|
|
@ -54,6 +56,7 @@ declare module 'vue' {
|
||||||
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
||||||
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
|
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
|
||||||
BoardStatusView: typeof import('./src/components/chat/BoardStatusView.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']
|
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
||||||
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
||||||
ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.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']
|
TopNav: typeof import('./src/components/layout/TopNav.vue')['default']
|
||||||
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
|
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
|
||||||
UserBubble: typeof import('./src/components/chat/messages/UserBubble.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']
|
WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,17 @@ import logging
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Security, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request, Security, UploadFile, File
|
||||||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||||
from pydantic import BaseModel
|
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.dependencies import require_permission
|
||||||
|
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||||
from agentkit.server.auth.permissions import Permission
|
from agentkit.server.auth.permissions import Permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -189,9 +193,48 @@ class UpdateSourceRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kb-management/sources")
|
@router.get("/kb-management/sources")
|
||||||
async def list_sources(req: Request, _auth: None = Depends(_verify_api_key)):
|
async def list_sources(
|
||||||
"""List all knowledge 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()
|
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 {
|
return {
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
|
|
@ -287,9 +330,51 @@ async def update_source(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kb-management/documents")
|
@router.get("/kb-management/documents")
|
||||||
async def list_documents(source_id: str | None = None, _auth: None = Depends(_verify_api_key)):
|
async def list_documents(
|
||||||
"""List all documents, optionally filtered by source_id."""
|
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)
|
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 {
|
return {
|
||||||
"documents": [
|
"documents": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Any
|
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.base import Skill, SkillConfig
|
||||||
from agentkit.skills.pipeline import SkillPipeline
|
from agentkit.skills.pipeline import SkillPipeline
|
||||||
|
|
||||||
|
|
@ -139,10 +143,50 @@ async def register_skill(request: RegisterSkillRequest, req: Request):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/skills")
|
@router.get("/skills")
|
||||||
async def list_skills(req: Request):
|
async def list_skills(
|
||||||
"""List all skills with full metadata"""
|
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
|
skill_registry = req.app.state.skill_registry
|
||||||
skills = skill_registry.list_skills()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"name": s.name,
|
"name": s.name,
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Unit tests for the admin console subsystem."""
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -118,9 +118,9 @@ async def _list_index_names(db: aiosqlite.Connection, table: str) -> set[str]:
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaVersion:
|
class TestSchemaVersion:
|
||||||
def test_schema_version_is_v2(self):
|
def test_schema_version_is_v3(self):
|
||||||
"""The current schema version is 2 (V2 adds auth_sessions + auth_meta)."""
|
"""The current schema version is 3 (V3 adds department-scoped admin tables)."""
|
||||||
assert _SCHEMA_VERSION == 2
|
assert _SCHEMA_VERSION == 3
|
||||||
|
|
||||||
def test_sqlalchemy_model_table_name(self):
|
def test_sqlalchemy_model_table_name(self):
|
||||||
assert AuthSessionModel.__tablename__ == "auth_sessions"
|
assert AuthSessionModel.__tablename__ == "auth_sessions"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue