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:
chiguyong 2026-06-21 15:03:27 +08:00
parent 6dca9ba4f2
commit ad65f7a8d7
18 changed files with 3859 additions and 11 deletions

View File

@ -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 COLUMNNULL 表示未分配部门的全局用户)
- **假设**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 埋点)
每批次独立可交付、可测试、可合并。

View File

@ -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 CRUDLLM 配置只在 `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 级 RBACmember/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 → LlmConfigViewLLM 配置)
/admin/skills → SkillsViewSkill 管理)
/admin/kb → KbManagementViewKB 管理)
/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_idAuthMiddleware 已注入)
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/costdaily/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~~**已明确**:拒绝删除,强制管理员先移除用户。

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ from agentkit.server.routes import (
experts,
system,
auth as auth_routes,
admin as admin_routes_module,
)
from agentkit.server.auth.jwt_utils import get_jwt_secret
from agentkit.server.auth.middleware import AuthMiddleware
@ -927,6 +928,7 @@ def create_app(
app.include_router(experts.router, prefix="/api/v1")
app.include_router(auth_routes.router, prefix="/api/v1")
app.include_router(auth_routes.admin_router, prefix="/api/v1")
app.include_router(admin_routes_module.admin_router, prefix="/api/v1")
# Serve GUI when in GUI mode
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")

View File

@ -258,6 +258,99 @@ class TerminalApprovalModel(Base):
expires_at: Mapped[str] = mapped_column(String(64), nullable=False)
# ---------------------------------------------------------------------------
# V3: Department-scoped admin models (U1 — Admin Console)
# ---------------------------------------------------------------------------
class DepartmentModel(Base):
"""Department record (V3 — Admin Console).
A department is the unit of resource isolation: skills, KB sources, and
LLM quotas can be bound to a department. Users belong to one or more
departments via :class:`UserDepartmentModel` (many-to-many); their
effective permissions are the union of all departments they belong to.
``is_active=0`` disables a department: its users can still log in but
cannot access department-bound resources.
"""
__tablename__ = "departments"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String(1024), nullable=True)
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
class UserDepartmentModel(Base):
"""Many-to-many association between users and departments (V3).
Composite primary key ``(user_id, department_id)`` enforces uniqueness
per (user, department) pair. A user with no rows here is a "global" user
with no department-scoped permissions.
"""
__tablename__ = "user_departments"
user_id: Mapped[str] = mapped_column(String(36), primary_key=True)
department_id: Mapped[str] = mapped_column(String(36), primary_key=True)
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
class DepartmentSkillBindingModel(Base):
"""Skill binding for a department (V3).
Each row grants the department access to a named skill. ``skill_name``
references the skill registry identifier (not a DB FK skills are
defined in YAML configs).
"""
__tablename__ = "department_skill_bindings"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
skill_name: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
class DepartmentKbBindingModel(Base):
"""Knowledge-base source binding for a department (V3).
Each row grants the department access to a KB source. ``kb_source_id``
references the KB source identifier (not a DB FK KB sources are
managed by the KB subsystem).
"""
__tablename__ = "department_kb_bindings"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
kb_source_id: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
class DepartmentQuotaModel(Base):
"""Quota configuration for a department (V3).
Each row defines a single quota for a department. ``quota_type`` is one
of ``token_limit`` / ``cost_limit`` / ``model_whitelist``. ``limit_value``
is stored as TEXT (JSON-encoded for complex types like model_whitelist,
plain integer-as-string for simple limits). ``period`` is ``daily`` or
``monthly``.
"""
__tablename__ = "department_quotas"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
quota_type: Mapped[str] = mapped_column(String(32), nullable=False)
limit_value: Mapped[str] = mapped_column(String(1024), nullable=False)
period: Mapped[str] = mapped_column(String(16), nullable=False, default="daily")
updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso)
# ---------------------------------------------------------------------------
# Schema DDL (kept in sync with the models above for aiosqlite bootstrap)
# ---------------------------------------------------------------------------
@ -405,6 +498,73 @@ CREATE INDEX IF NOT EXISTS idx_terminal_approvals_session_id
ON terminal_approvals(session_id);
CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status
ON terminal_approvals(status);
-- V3: Department-scoped admin tables (U1 Admin Console).
-- departments: top-level isolation unit. name is UNIQUE so admin UI can
-- reference departments by name. is_active=0 disables a department without
-- deleting it (users keep their user_departments rows but lose access to
-- department-bound resources).
CREATE TABLE IF NOT EXISTS departments (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
-- V3: user_departments many-to-many. Composite PK (user_id, department_id)
-- enforces uniqueness per pair. A user with no rows here is a "global" user.
CREATE TABLE IF NOT EXISTS user_departments (
user_id TEXT NOT NULL,
department_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, department_id)
);
CREATE INDEX IF NOT EXISTS idx_user_departments_user_id
ON user_departments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_departments_department_id
ON user_departments(department_id);
-- V3: department_skill_bindings grants a department access to a named skill.
-- UNIQUE (department_id, skill_name) prevents duplicate bindings.
CREATE TABLE IF NOT EXISTS department_skill_bindings (
id TEXT PRIMARY KEY,
department_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (department_id, skill_name)
);
CREATE INDEX IF NOT EXISTS idx_department_skill_bindings_department_id
ON department_skill_bindings(department_id);
-- V3: department_kb_bindings grants a department access to a KB source.
-- UNIQUE (department_id, kb_source_id) prevents duplicate bindings.
CREATE TABLE IF NOT EXISTS department_kb_bindings (
id TEXT PRIMARY KEY,
department_id TEXT NOT NULL,
kb_source_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (department_id, kb_source_id)
);
CREATE INDEX IF NOT EXISTS idx_department_kb_bindings_department_id
ON department_kb_bindings(department_id);
-- V3: department_quotas stores per-department quota configuration.
-- quota_type {token_limit, cost_limit, model_whitelist}.
-- limit_value is TEXT (JSON for model_whitelist, integer-as-string for
-- simple limits). period {daily, monthly}. UNIQUE (department_id,
-- quota_type, period) ensures one row per quota type per period.
CREATE TABLE IF NOT EXISTS department_quotas (
id TEXT PRIMARY KEY,
department_id TEXT NOT NULL,
quota_type TEXT NOT NULL,
limit_value TEXT NOT NULL,
period TEXT NOT NULL DEFAULT 'daily',
updated_at TEXT NOT NULL,
UNIQUE (department_id, quota_type, period)
);
CREATE INDEX IF NOT EXISTS idx_department_quotas_department_id
ON department_quotas(department_id);
"""
@ -416,7 +576,11 @@ CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status
# that require data backfill or migration. The :func:`init_auth_db` function
# uses this together with the ``auth_meta.schema_version`` row to decide
# which migrations to run.
_SCHEMA_VERSION = 2
#
# V3 (2026-06-21, Admin Console): added departments, user_departments,
# department_skill_bindings, department_kb_bindings, department_quotas.
# No backfill needed — all new tables are additive.
_SCHEMA_VERSION = 3
_META_SCHEMA_VERSION_KEY = "schema_version"
@ -615,3 +779,27 @@ def auth_session_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[
"revoked_reason": row["revoked_reason"],
"previous_session_id": row["previous_session_id"],
}
def department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]:
"""Convert a ``departments`` row into a JSON-safe dict.
The ``is_active`` field is normalized to a Python ``bool`` (the DB
stores 0/1).
"""
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"is_active": bool(row["is_active"]),
"created_at": row["created_at"],
}
def user_department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]:
"""Convert a ``user_departments`` row into a JSON-safe dict."""
return {
"user_id": row["user_id"],
"department_id": row["department_id"],
"created_at": row["created_at"],
}

View File

@ -15,9 +15,11 @@ declare module 'vue' {
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ActiveSessionsPanel: typeof import('./src/components/settings/ActiveSessionsPanel.vue')['default']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
@ -54,6 +56,7 @@ declare module 'vue' {
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default']
ChangePasswordPanel: typeof import('./src/components/settings/ChangePasswordPanel.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default']
@ -119,6 +122,7 @@ declare module 'vue' {
TopNav: typeof import('./src/components/layout/TopNav.vue')['default']
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default']
UserSessionsPanel: typeof import('./src/components/admin/UserSessionsPanel.vue')['default']
WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default']
}
}

View File

@ -7,13 +7,17 @@ import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Security, UploadFile, File
from fastapi.security import APIKeyHeader, APIKeyQuery
from pydantic import BaseModel
from agentkit.server.admin.context import DepartmentContext, get_department_context
from agentkit.server.admin.filtering import filter_kb_sources_by_department
from agentkit.server.auth.dependencies import require_permission
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
from agentkit.server.auth.permissions import Permission
logger = logging.getLogger(__name__)
@ -189,9 +193,48 @@ class UpdateSourceRequest(BaseModel):
@router.get("/kb-management/sources")
async def list_sources(req: Request, _auth: None = Depends(_verify_api_key)):
"""List all knowledge sources."""
async def list_sources(
req: Request,
_auth: None = Depends(_verify_api_key),
dept_ctx: DepartmentContext = Depends(get_department_context),
):
"""List all knowledge sources.
Department filtering (U4): non-admin users see only:
- KB sources bound to one of their departments, OR
- KB sources with no department binding (global sources).
Admin users (``role == "admin"``) bypass filtering and see all
sources. Unauthenticated callers (API-key clients) see only global
sources.
"""
sources = _source_store.list_sources()
if not dept_ctx.should_filter:
return _serialize_sources(sources)
db_path = _resolve_kb_db_path(req)
all_ids = [s.id for s in sources]
try:
visible_ids = await filter_kb_sources_by_department(
db_path, dept_ctx.department_ids, all_ids
)
except Exception: # noqa: BLE001 — never block listing on DB errors
logger.exception("Department KB filtering failed — returning empty list")
return {"sources": []}
visible_set = set(visible_ids)
filtered = [s for s in sources if s.id in visible_set]
return _serialize_sources(filtered)
def _resolve_kb_db_path(req: Request) -> Path:
"""Resolve the auth DB path from ``app.state`` or the default."""
path = getattr(req.app.state, "auth_db_path", None)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
def _serialize_sources(sources: list[KnowledgeSource]) -> dict[str, Any]:
"""Serialize KB sources to the GET /kb-management/sources response shape."""
return {
"sources": [
{
@ -287,9 +330,51 @@ async def update_source(
@router.get("/kb-management/documents")
async def list_documents(source_id: str | None = None, _auth: None = Depends(_verify_api_key)):
"""List all documents, optionally filtered by source_id."""
async def list_documents(
req: Request,
source_id: str | None = None,
_auth: None = Depends(_verify_api_key),
dept_ctx: DepartmentContext = Depends(get_department_context),
):
"""List all documents, optionally filtered by source_id.
Department filtering (U4): non-admin users see only documents
belonging to:
- KB sources bound to one of their departments, OR
- KB sources with no department binding (global sources).
Admin users (``role == "admin"``) bypass filtering and see all
documents. Unauthenticated callers (API-key clients) see only
documents from global sources.
If ``source_id`` is provided AND the caller is not allowed to see
that source, an empty list is returned (rather than 404 the
source may exist, the caller just can't see it).
"""
documents = _source_store.list_documents(source_id)
if not dept_ctx.should_filter:
return _serialize_documents(documents)
# Compute visible source ids across ALL known sources, then filter
# documents whose source_id is in the visible set.
all_sources = _source_store.list_sources()
all_source_ids = [s.id for s in all_sources]
db_path = _resolve_kb_db_path(req)
try:
visible_ids = await filter_kb_sources_by_department(
db_path, dept_ctx.department_ids, all_source_ids
)
except Exception: # noqa: BLE001 — never block listing on DB errors
logger.exception("Department KB filtering failed — returning empty list")
return {"documents": []}
visible_set = set(visible_ids)
filtered = [d for d in documents if d.source_id in visible_set]
return _serialize_documents(filtered)
def _serialize_documents(documents: list[UploadedDocument]) -> dict[str, Any]:
"""Serialize documents to the GET /kb-management/documents response shape."""
return {
"documents": [
{

View File

@ -4,12 +4,16 @@ import logging
import os
import re
import urllib.parse
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Any
from agentkit.server.admin.context import DepartmentContext, get_department_context
from agentkit.server.admin.filtering import filter_skills_by_department
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
from agentkit.skills.base import Skill, SkillConfig
from agentkit.skills.pipeline import SkillPipeline
@ -139,10 +143,50 @@ async def register_skill(request: RegisterSkillRequest, req: Request):
@router.get("/skills")
async def list_skills(req: Request):
"""List all skills with full metadata"""
async def list_skills(
req: Request,
dept_ctx: DepartmentContext = Depends(get_department_context),
):
"""List all skills with full metadata.
Department filtering (U4): non-admin users see only:
- skills bound to one of their departments, OR
- skills with no department binding (global skills).
Admin users (``role == "admin"``) bypass filtering and see all
registered skills. Unauthenticated callers (API-key clients) see
only global skills.
"""
skill_registry = req.app.state.skill_registry
skills = skill_registry.list_skills()
# Admins bypass department filtering.
if not dept_ctx.should_filter:
return _serialize_skills(skills)
# Non-admin: filter by department bindings.
db_path = _resolve_db_path(req)
all_names = [s.name for s in skills]
try:
visible_names = await filter_skills_by_department(
db_path, dept_ctx.department_ids, all_names
)
except Exception: # noqa: BLE001 — never block listing on DB errors
logger.exception("Department skill filtering failed — returning empty list")
return []
visible_set = set(visible_names)
filtered = [s for s in skills if s.name in visible_set]
return _serialize_skills(filtered)
def _resolve_db_path(req: Request) -> Path:
"""Resolve the auth DB path from ``app.state`` or the default."""
path = getattr(req.app.state, "auth_db_path", None)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
def _serialize_skills(skills: list[Skill]) -> list[dict[str, Any]]:
"""Serialize a list of Skill objects to the GET /skills response shape."""
return [
{
"name": s.name,

View File

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Unit tests for the admin console subsystem."""

View File

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

View File

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

View File

@ -118,9 +118,9 @@ async def _list_index_names(db: aiosqlite.Connection, table: str) -> set[str]:
class TestSchemaVersion:
def test_schema_version_is_v2(self):
"""The current schema version is 2 (V2 adds auth_sessions + auth_meta)."""
assert _SCHEMA_VERSION == 2
def test_schema_version_is_v3(self):
"""The current schema version is 3 (V3 adds department-scoped admin tables)."""
assert _SCHEMA_VERSION == 3
def test_sqlalchemy_model_table_name(self):
assert AuthSessionModel.__tablename__ == "auth_sessions"