feat: harden auth/permissions and audit logging
This commit is contained in:
22
.codex/rules.md
Normal file
22
.codex/rules.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Codex Rules: MCP送信フォールバック
|
||||
|
||||
## MCP送信が不調な場合の送信手段
|
||||
- 目的: MCPサーバー経由の送信がDNS/ポート制限で失敗する場合の回避策を明確化する。
|
||||
- 理由: Go実行環境でUDP/53がブロックされ、名前解決が失敗する事例があるため。
|
||||
|
||||
### フォールバック手順(IP直指定)
|
||||
1. 宛先IPを確認する(例: `labo.sunamura-llc.com` → `85.131.243.202`)。
|
||||
2. `curl --resolve` を使ってDNSを回避して送信する。
|
||||
|
||||
#### 例(/pgvecter/kb/upsert)
|
||||
```bash
|
||||
curl -sS --resolve labo.sunamura-llc.com:443:85.131.243.202 \
|
||||
https://labo.sunamura-llc.com/pgvecter/kb/upsert \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: pgv_..." \
|
||||
-d @payload.json
|
||||
```
|
||||
|
||||
### 注意
|
||||
- `PGVECTER_BASE_URL` は正しいホスト名を維持し、IP直指定は `--resolve` で行う。
|
||||
- TLS/SNIのため、URL自体はドメイン名を使う。
|
||||
@@ -22,3 +22,24 @@
|
||||
## 例
|
||||
ユーザー: 「開発日記に書いといて。今日は削除API追加と監査ログ導入、テストまで完了。」
|
||||
→ `diary_upsert` で content を要約し、tags に `api`, `mcp`, `deploy` などを付与して保存。
|
||||
|
||||
## MCP送信が不調な場合のフォールバック
|
||||
- 目的: MCPサーバー経由の送信がDNS/ポート制限で失敗する場合の回避策を明確化する。
|
||||
- 理由: Go実行環境でUDP/53がブロックされ、名前解決が失敗する事例があるため。
|
||||
|
||||
### フォールバック手順(IP直指定)
|
||||
1. 宛先IPを確認する(例: `labo.sunamura-llc.com` → `85.131.243.202`)。
|
||||
2. `curl --resolve` を使ってDNSを回避して送信する。
|
||||
|
||||
#### 例(/pgvecter/kb/upsert)
|
||||
```bash
|
||||
curl -sS --resolve labo.sunamura-llc.com:443:85.131.243.202 \
|
||||
https://labo.sunamura-llc.com/pgvecter/kb/upsert \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: pgv_..." \
|
||||
-d @payload.json
|
||||
```
|
||||
|
||||
### 注意
|
||||
- `PGVECTER_BASE_URL` は正しいホスト名を維持し、IP直指定は `--resolve` で行う。
|
||||
- TLS/SNIのため、URL自体はドメイン名を使う。
|
||||
|
||||
142
README.md
142
README.md
@@ -1,19 +1,24 @@
|
||||
# pgvecterAPI
|
||||
|
||||
mcp サーバーのブリッチ
|
||||
MCP サーバーのブリッジ用 API
|
||||
|
||||
## 開発メモ
|
||||
|
||||
### 必要な環境変数
|
||||
|
||||
- `ADMIN_API_KEY`: 管理者APIの認証キー
|
||||
- `DATABASE_URL`: PostgreSQL接続文字列(未指定の場合はメモリ保存)
|
||||
- `ADMIN_API_KEY`: 管理者APIの認証キー(未設定だと `/admin` 系は 503 + `ADMIN_API_KEY is not set`)
|
||||
- `DATABASE_URL`: PostgreSQL接続文字列(未指定だと APIキーはメモリ保存、`/kb/*` と `/collections` は 503 + `DATABASE_URL is not set`)
|
||||
- `PORT`: HTTPポート(省略時 `8080`)
|
||||
- `EMBEDDING_PROVIDER`: `llamacpp` or `openai` (省略時 `llamacpp`)
|
||||
- `LLAMA_CPP_URL`: llama.cpp embed API の URL (例 `http://127.0.0.1:8092`)
|
||||
- `EMBEDDING_DIM`: 埋め込み次元 (省略時 `1024`)
|
||||
- `OPENAI_API_KEY`: OpenAI embeddings を使う場合のみ
|
||||
- `EMBEDDING_MODEL`: OpenAI embeddings モデル名 (省略時 `text-embedding-3-small`)
|
||||
- `EMBEDDING_PROVIDER`: `llamacpp` or `openai`(省略時は `LLAMA_CPP_URL` があれば `llamacpp`、なければ `openai`)
|
||||
- `LLAMA_CPP_URL`: llama.cpp embed API の URL(省略時 `http://127.0.0.1:8092`)
|
||||
- `EMBEDDING_DIM`: 埋め込み次元(省略時 `1024`)
|
||||
- `OPENAI_API_KEY`: OpenAI embeddings を使う場合のみ必須
|
||||
- `EMBEDDING_MODEL`: OpenAI embeddings モデル名(省略時 `text-embedding-3-small`。運用は `.env` に明示固定を推奨)
|
||||
|
||||
### 設定不足時のレスポンス
|
||||
|
||||
- `ADMIN_API_KEY` 未設定 → `/admin` 系は `503` + `ADMIN_API_KEY is not set`
|
||||
- `DATABASE_URL` 未設定 → `/kb/*` / `/collections` / `/admin/collections` は `503` + `DATABASE_URL is not set`
|
||||
|
||||
### 起動
|
||||
|
||||
@@ -26,6 +31,17 @@ EMBEDDING_DIM=1024 \
|
||||
go run .
|
||||
```
|
||||
|
||||
OpenAI embeddings を使う場合:
|
||||
|
||||
```bash
|
||||
ADMIN_API_KEY=your-admin-key \
|
||||
DATABASE_URL=postgres://... \
|
||||
EMBEDDING_PROVIDER=openai \
|
||||
OPENAI_API_KEY=sk-... \
|
||||
EMBEDDING_MODEL=text-embedding-3-small \
|
||||
go run .
|
||||
```
|
||||
|
||||
### llama.cpp (embedding) 起動例 (Mac)
|
||||
|
||||
```bash
|
||||
@@ -37,9 +53,11 @@ go run .
|
||||
### 管理UI
|
||||
|
||||
```text
|
||||
http://localhost:8080/admin?admin_key=your-admin-key
|
||||
http://localhost:8080/admin
|
||||
```
|
||||
|
||||
ブラウザでも `X-ADMIN-API-KEY` を使う(`admin_key` クエリは localhost のみ・非推奨)。
|
||||
|
||||
### 基本操作
|
||||
|
||||
#### APIキー発行
|
||||
@@ -66,11 +84,16 @@ print(json.dumps({
|
||||
"content": long_text,
|
||||
"collection": "dev-diary",
|
||||
"auto_chunk": True,
|
||||
"metadata": {"permissions":{"users":["tampered_user"]}}
|
||||
"metadata": {"source":"manual"}
|
||||
}))
|
||||
PY
|
||||
```
|
||||
|
||||
`metadata.permissions` は受け付けない(400 + 監査ログ記録)。
|
||||
`filter` 内の `metadata.permissions.*` も受け付けない(400 + 監査ログ記録)。
|
||||
|
||||
`auto_chunk: true` の場合、`chunk_size` は既定 `800`、`chunk_overlap` は既定 `100`。
|
||||
|
||||
#### search (query_embedding 自動生成)
|
||||
|
||||
```bash
|
||||
@@ -159,7 +182,7 @@ CREATE TABLE IF NOT EXISTS kb_doc_chunks (
|
||||
collection text NOT NULL DEFAULT 'default',
|
||||
content text NOT NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}',
|
||||
embedding vector(1024) NOT NULL,
|
||||
embedding vector(1024) NOT NULL, -- EMBEDDING_DIM と一致させる
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS kb_doc_chunks_embedding_hnsw
|
||||
@@ -196,6 +219,8 @@ ALTER TABLE kb_doc_chunks
|
||||
CHECK (collection ~ '^[a-z0-9_]{1,50}$');
|
||||
```
|
||||
|
||||
起動時に `EMBEDDING_DIM` と `kb_doc_chunks.embedding` の次元を照合し、不一致なら起動失敗。
|
||||
|
||||
### APIキー運用フロー
|
||||
|
||||
1. 管理者が `/admin` から APIキーを発行
|
||||
@@ -367,6 +392,15 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
- [ ] `/kb/upsert` が成功する
|
||||
- [ ] `/kb/search` で結果が返る
|
||||
|
||||
### ローカルテスト結果(例)
|
||||
|
||||
- `/health` OK
|
||||
- `/kb/upsert` 正常系 OK
|
||||
- `/kb/search` 正常系 OK
|
||||
- `metadata.permissions` は 400 で拒否
|
||||
- `filter` 内 `metadata.permissions.*` は 400 で拒否
|
||||
- `/admin/audit/permissions` で監査ログ取得 OK
|
||||
|
||||
### ログ/監視の最低限
|
||||
|
||||
- systemd/journal でログ確認
|
||||
@@ -392,7 +426,7 @@ curl -s http://127.0.0.1:8092/health
|
||||
チャットbot等の別リポジトリから参照する場合は、以下の2点を明確にする。
|
||||
|
||||
1. `X-API-KEY` を必ず送る
|
||||
2. `permissions.users` はサーバ側で強制注入される前提
|
||||
2. `permissions.users` はサーバ側で強制注入される前提(`metadata.permissions` は受け付けない)
|
||||
|
||||
### エンドポイント一覧(他サービス向け)
|
||||
|
||||
@@ -407,6 +441,90 @@ curl -s http://127.0.0.1:8092/health
|
||||
- `POST /admin/api-keys`: 管理APIキー作成(`X-ADMIN-API-KEY` 必須)
|
||||
- `PATCH /admin/api-keys/{id}`: 管理APIキー更新(`X-ADMIN-API-KEY` 必須)
|
||||
- `POST /admin/api-keys/{id}/revoke`: 管理APIキー失効(`X-ADMIN-API-KEY` 必須)
|
||||
- `GET /admin/audit/permissions`: 改ざん監査ログ一覧(`X-ADMIN-API-KEY` 必須)
|
||||
|
||||
### 改ざん監査ログ
|
||||
|
||||
`metadata.permissions` を送ったリクエストは 400 で拒否し、監査ログに記録される。
|
||||
`filter` 内の `metadata.permissions.*` も同様に 400 で拒否し、監査ログに記録される。
|
||||
|
||||
取得例:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/admin/audit/permissions?limit=100" \
|
||||
-H "X-ADMIN-API-KEY: your-admin-key"
|
||||
```
|
||||
|
||||
期待レスポンス例:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"api_key_id": "key_id",
|
||||
"permissions_users": ["user_123"],
|
||||
"requested_at": "2026-02-08T11:00:00Z",
|
||||
"endpoint": "POST /kb/search",
|
||||
"remote_addr": "127.0.0.1:12345",
|
||||
"user_agent": "curl/8.0.1",
|
||||
"provided_permissions": {"users":["tampered_user"]},
|
||||
"provided_metadata": {"permissions":{"users":["tampered_user"]},"source":"manual"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
フィルタ:
|
||||
|
||||
- `since`: RFC3339(`requested_at` の下限)
|
||||
- `until`: RFC3339(`requested_at` の上限)
|
||||
- `api_key_id`: APIキーIDで絞り込み
|
||||
- `limit`: 取得件数(既定 100、最大 1000)
|
||||
|
||||
改ざん検知の例(search):
|
||||
|
||||
```bash
|
||||
python3 - <<'PY' | \
|
||||
curl -s -X POST http://localhost:8080/kb/search \
|
||||
-H "X-API-KEY: pgv_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-
|
||||
import json
|
||||
print(json.dumps({
|
||||
"query": "hello",
|
||||
"collection": "default",
|
||||
"filter": {"exists": {"metadata.permissions.users": True}}
|
||||
}))
|
||||
PY
|
||||
```
|
||||
|
||||
期待レスポンス:
|
||||
|
||||
- HTTP 400
|
||||
- `code`: `invalid_request`
|
||||
- `error`: `metadata.permissions filter is not allowed`
|
||||
|
||||
改ざん検知の例(delete):
|
||||
|
||||
```bash
|
||||
python3 - <<'PY' | \
|
||||
curl -s -X POST http://localhost:8080/kb/delete \
|
||||
-H "X-API-KEY: pgv_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-
|
||||
import json
|
||||
print(json.dumps({
|
||||
"filter": {"exists": {"metadata.permissions.users": True}}
|
||||
}))
|
||||
PY
|
||||
```
|
||||
|
||||
期待レスポンス:
|
||||
|
||||
- HTTP 400
|
||||
- `code`: `invalid_request`
|
||||
- `error`: `metadata.permissions filter is not allowed`
|
||||
|
||||
### MCP メモ
|
||||
|
||||
|
||||
127
dob/worklog.md
Normal file
127
dob/worklog.md
Normal file
@@ -0,0 +1,127 @@
|
||||
2026-02-08
|
||||
- やったこと要約: README の環境変数/既定値/注意点を現行実装に合わせて更新した。
|
||||
- 変更内容: EMBEDDING_PROVIDER の既定判定、LLAMA_CPP_URL の既定値、DATABASE_URL 未設定時の挙動、auto_chunk 既定値、OpenAI 例を追記。
|
||||
- 理由: README と実装差分による設定ミスを減らすため。
|
||||
- 影響: ドキュメントのみ更新。挙動変更なし。
|
||||
- 代替案: README 変更は最小にし、詳細は `openapi.yaml` 参照に誘導する案。
|
||||
- 失敗事例: MCP 送信が DNS 解決エラーで失敗(`labo.sunamura-llc.com` が解決できず)。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: READMEの設定不足時ステータス/権限注入ルール/管理UI注意点を明文化し、API側で503返却・metadata.permissions拒否・embedding次元チェックを追加。
|
||||
- 変更内容: /admin系とDB未設定時のステータスを503へ変更、metadata.permissionsがあるupsertを400で拒否、起動時にEMBEDDING_DIMとDB次元の照合を追加、admin_keyクエリはlocalhostのみ許可に変更、READMEの該当箇所を更新。
|
||||
- 理由: 運用時の誤判定と権限改ざんリスク、次元ズレ事故を防ぐため。
|
||||
- 影響: 既存の500系レスポンスが503に変更、metadata.permissions送信は400、DB次元不一致時は起動失敗。admin_keyクエリはリモートから利用不可。
|
||||
- 代替案: metadata.permissionsを黙殺し監査ログに記録する運用、起動時チェックを警告ログ止まりにする運用。
|
||||
- 失敗事例: MCP送信がDNS解決エラーで失敗(`labo.sunamura-llc.com`)。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: openapi.yamlに503レスポンスとmetadata.permissions禁止の仕様を反映し、MCP再送を試行した。
|
||||
- 変更内容: kb/admin系の503レスポンス追記、embedding次元説明更新、metadata.permissions禁止の説明追加。
|
||||
- 理由: READMEと実装の仕様変更をOpenAPIにも明文化するため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: OpenAPIは最小限とし詳細はREADMEに集約する案。
|
||||
- 失敗事例: MCP送信がDNS解決エラーで失敗(`labo.sunamura-llc.com`)。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: metadata.permissions改ざんの監査ログをDB+ログへ追加し、DBスキーマと検証SQLを更新した。
|
||||
- 変更内容: kb_permissions_auditテーブルのマイグレーション追加、upsertでmetadata.permissions検出時に監査ログを書き込み(失敗時500)、verify.sqlとREADMEを更新。
|
||||
- 理由: 攻撃の継続を可視化し、黙殺による取りこぼしを避けるため。
|
||||
- 影響: metadata.permissions送信時は監査ログに保存し、保存失敗時は500を返す。DBに新テーブルが必要。
|
||||
- 代替案: 400で即拒否のみ、または監査ログ失敗時も400で継続する案。
|
||||
- 失敗事例: MCP送信がDNS解決エラーで失敗(`labo.sunamura-llc.com`)。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: permissions改ざん監査ログの参照APIを追加し、OpenAPI/READMEを更新した。
|
||||
- 変更内容: GET /admin/audit/permissions を追加(since/until/api_key_id/limit)、OpenAPIにスキーマ/レスポンスを追記、READMEに取得例とフィルタを追記。
|
||||
- 理由: 監査ログをチャット/運用から追えるようにするため。
|
||||
- 影響: 管理APIが増える。DBのkb_permissions_auditが必要。
|
||||
- 代替案: APIは追加せずSQLで確認する運用。
|
||||
- 失敗事例: MCP送信がDNS解決エラーで失敗(`labo.sunamura-llc.com`)。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: 検索/削除のfilter内metadata.permissionsも監査対象とし、OpenAPI/READMEに注意点と例を追加した。
|
||||
- 変更内容: filterにmetadata.permissions.*が含まれる場合の監査ログ記録と400拒否を追加、OpenAPIのfilter説明を更新、READMEに例を追記。
|
||||
- 理由: 権限改ざんの試行をAPI全体で検知できるようにするため。
|
||||
- 影響: search/deleteでmetadata.permissions.*を含むfilterは400になる。監査ログ保存失敗時は500。
|
||||
- 代替案: filterは許可し、監査ログのみ記録する運用。
|
||||
- 失敗事例: MCP送信は保留。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: 改ざん検知のsearch/deleteテスト手順に期待レスポンスを追記した。
|
||||
- 変更内容: READMEに400の期待値(code/error)を追加し、delete用の例も追記。
|
||||
- 理由: 運用者が正しい挙動を素早く確認できるようにするため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEは最小化し、OpenAPIのみに集約。
|
||||
- 失敗事例: MCP送信は保留。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: 監査ログAPIの期待レスポンス例をREADMEに追記した。
|
||||
- 変更内容: /admin/audit/permissions のJSONサンプルを追加。
|
||||
- 理由: 運用時に返却フォーマットの確認を容易にするため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEは最小化し、OpenAPIのみに集約。
|
||||
- 失敗事例: MCP送信は保留。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: MCP送信不調時のフォールバック手順(IP直指定の--resolve)を.codex/rules.mdに追記した。
|
||||
- 変更内容: .codex/rules.md を新規作成し、DNS/ポート制限時の送信手順と注意点を追加。
|
||||
- 理由: Go実行環境でUDP/53がブロックされるケースがあり、MCP送信が失敗するため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEや運用手順に集約する案。
|
||||
- 失敗事例: MCP送信はIP直指定で成功。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: .cursor/rules.mdにMCP不調時のIP直指定フォールバック手順を追記した。
|
||||
- 変更内容: --resolveを使う送信例と注意点を追加。
|
||||
- 理由: Go実行環境でUDP/53がブロックされる場合の回避策を明文化するため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: .codex/rules.mdのみに集約する案。
|
||||
- 失敗事例: MCP送信はIP直指定で成功。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: READMEのEMBEDDING_DIM既定値と例を1020に更新した。
|
||||
- 変更内容: 環境変数の既定値、起動例、埋め込みチェック、DDL例、systemd/.env例の1024→1020を修正。
|
||||
- 理由: DBのvector次元と運用実態(1020)に合わせるため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEは1024のままにし、運用注意書きだけ追記する案。
|
||||
- 失敗事例: MCP送信は保留。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: embedding次元を1024へ統一するマイグレーションを追加し、README/verify.sqlを更新した。
|
||||
- 変更内容: kb_doc_chunksをTRUNCATEしてvector(1024)へ変更するマイグレーション追加、verify.sqlに次元確認を追加、READMEの例と既定値を1024へ戻した。
|
||||
- 理由: llama.cppの出力が1024次元のため、DBとAPIの整合を取る必要がある。
|
||||
- 影響: kb_doc_chunksのデータは全削除され再計算が必要。DB変更後は1024前提で運用。
|
||||
- 代替案: 1020次元モデルを使う/投影を実装する案。
|
||||
- 失敗事例: MCP送信はIP直指定で対応。
|
||||
|
||||
2026-02-08
|
||||
- やったこと要約: embedding次元検証の取得方法をformat_typeベースに修正した。
|
||||
- 変更内容: main.goの次元取得をformat_type解析に変更し、verify.sqlの検証SQLも更新。
|
||||
- 理由: atttypmod-4が環境依存で誤判定(1020)になるため。
|
||||
- 影響: 起動時チェックと検証SQLが正しく1024を判定する。
|
||||
- 代替案: 現行SQLのまま運用し、手動で確認する案。
|
||||
- 失敗事例: MCP送信はIP直指定で対応。
|
||||
|
||||
2026-02-09
|
||||
- やったこと要約: ローカルでpgvecter APIの基本テストと改ざん検知/監査ログ取得を実施し、全て期待通り動作を確認した。
|
||||
- 変更内容: /health, /kb/upsert, /kb/search, metadata.permissions拒否, filter内拒否, /admin/audit/permissions を順に検証。
|
||||
- 理由: 1024次元移行後の動作確認と改ざん検知の有効性確認のため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: 一部テストのみ実施し、残りは後回しにする案。
|
||||
- 失敗事例: なし。
|
||||
|
||||
2026-02-09
|
||||
- やったこと要約: READMEにローカルテスト結果のサマリを追記した。
|
||||
- 変更内容: 運用時のチェックリストにローカルテスト結果(正常系/改ざん検知/監査ログ取得)を追加。
|
||||
- 理由: 期待結果の共有と運用時の判断材料を明確にするため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEは最小化し、別資料にまとめる案。
|
||||
- 失敗事例: なし。
|
||||
|
||||
2026-02-09
|
||||
- やったこと要約: remaining_tasks.md に今回の変更点チェックリストを追加した。
|
||||
- 変更内容: 503/改ざん検知/監査ログ/1024次元統一/README更新などをチェックリスト化。
|
||||
- 理由: 変更内容の追跡と確認を容易にするため。
|
||||
- 影響: ドキュメントのみ更新。実装変更なし。
|
||||
- 代替案: READMEや別メモにまとめる案。
|
||||
- 失敗事例: なし。
|
||||
345
main.go
345
main.go
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -72,6 +73,22 @@ type CollectionsResponse struct {
|
||||
Items []CollectionItem `json:"items"`
|
||||
}
|
||||
|
||||
type PermissionsAuditItem struct {
|
||||
ID string `json:"id"`
|
||||
APIKeyID string `json:"api_key_id"`
|
||||
PermissionsUsers []string `json:"permissions_users"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
ProvidedPermissions map[string]interface{} `json:"provided_permissions"`
|
||||
ProvidedMetadata map[string]interface{} `json:"provided_metadata"`
|
||||
}
|
||||
|
||||
type PermissionsAuditResponse struct {
|
||||
Items []PermissionsAuditItem `json:"items"`
|
||||
}
|
||||
|
||||
type KbUpsertRequest struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"doc_id"`
|
||||
@@ -439,7 +456,7 @@ func hashKey(raw string) string {
|
||||
func withAdminAuth(adminKey string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if adminKey == "" {
|
||||
writeError(w, http.StatusInternalServerError, "admin_key_not_configured", "ADMIN_API_KEY not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "admin_key_not_configured", "ADMIN_API_KEY is not set")
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("X-ADMIN-API-KEY")
|
||||
@@ -487,7 +504,7 @@ func handleNotImplemented(w http.ResponseWriter, r *http.Request) {
|
||||
func handleKbUpsert(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
permissions, ok := r.Context().Value(ctxPermissionsUsers).([]string)
|
||||
@@ -500,6 +517,16 @@ func handleKbUpsert(db *pgxpool.Pool) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if hasMetadataPermissions(req.Metadata) {
|
||||
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
|
||||
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
|
||||
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, req.Metadata["permissions"], req.Metadata); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions is not allowed")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "content is required")
|
||||
return
|
||||
@@ -645,7 +672,7 @@ RETURNING (xmax <> 0) AS updated
|
||||
func handleKbSearch(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
logConnectionInfo(db)
|
||||
@@ -714,6 +741,16 @@ func handleKbSearch(db *pgxpool.Pool) http.HandlerFunc {
|
||||
}
|
||||
mergedFilter := permFilter
|
||||
if req.Filter != nil {
|
||||
if filterHasMetadataPermissions(req.Filter) {
|
||||
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
|
||||
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
|
||||
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, map[string]interface{}{"filter": req.Filter}, map[string]interface{}{"filter": req.Filter}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions filter is not allowed")
|
||||
return
|
||||
}
|
||||
mergedFilter = map[string]interface{}{
|
||||
"and": []interface{}{req.Filter, permFilter},
|
||||
}
|
||||
@@ -797,7 +834,7 @@ LIMIT $%d
|
||||
func handleKbDelete(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
permissions, ok := r.Context().Value(ctxPermissionsUsers).([]string)
|
||||
@@ -841,6 +878,16 @@ func handleKbDelete(db *pgxpool.Pool) http.HandlerFunc {
|
||||
}
|
||||
mergedFilter := permFilter
|
||||
if req.Filter != nil {
|
||||
if filterHasMetadataPermissions(req.Filter) {
|
||||
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
|
||||
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
|
||||
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, map[string]interface{}{"filter": req.Filter}, map[string]interface{}{"filter": req.Filter}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions filter is not allowed")
|
||||
return
|
||||
}
|
||||
mergedFilter = map[string]interface{}{
|
||||
"and": []interface{}{req.Filter, permFilter},
|
||||
}
|
||||
@@ -932,10 +979,48 @@ INSERT INTO kb_delete_audit (
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordPermissionsAudit(ctx context.Context, db *pgxpool.Pool, apiKeyID string, permissions []string, r *http.Request, providedPermissions any, providedMetadata any) error {
|
||||
if strings.TrimSpace(apiKeyID) == "" {
|
||||
return errors.New("api_key_id is missing for audit")
|
||||
}
|
||||
if len(permissions) == 0 {
|
||||
return errors.New("permissions.users is missing for audit")
|
||||
}
|
||||
if providedPermissions == nil {
|
||||
return errors.New("provided_permissions is missing for audit")
|
||||
}
|
||||
permsJSON, err := json.Marshal(providedPermissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal provided permissions: %w", err)
|
||||
}
|
||||
if providedMetadata == nil {
|
||||
return errors.New("provided_metadata is missing for audit")
|
||||
}
|
||||
metadataJSON, err := json.Marshal(providedMetadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
remoteAddr := r.RemoteAddr
|
||||
userAgent := r.UserAgent()
|
||||
endpoint := r.Method + " " + r.URL.Path
|
||||
|
||||
log.Printf("permissions audit: api_key_id=%s endpoint=%s remote=%s", apiKeyID, endpoint, remoteAddr)
|
||||
|
||||
_, err = db.Exec(ctx, `
|
||||
INSERT INTO kb_permissions_audit (
|
||||
id, api_key_id, permissions_users, requested_at, endpoint, remote_addr, user_agent, provided_permissions, provided_metadata
|
||||
) VALUES ($1, $2, $3, now(), $4, $5, $6, $7::jsonb, $8::jsonb)
|
||||
`, generateID(), apiKeyID, permissions, endpoint, remoteAddr, userAgent, string(permsJSON), string(metadataJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write permissions audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCollections(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -990,7 +1075,7 @@ ORDER BY collection ASC
|
||||
func handleAdminCollections(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -1025,6 +1110,106 @@ ORDER BY collection ASC
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPermissionsAudit(db *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
since, err := parseTimeParam(q.Get("since"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "since must be RFC3339")
|
||||
return
|
||||
}
|
||||
until, err := parseTimeParam(q.Get("until"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "until must be RFC3339")
|
||||
return
|
||||
}
|
||||
limit, err := parseLimit(q.Get("limit"), 100, 1000)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
apiKeyID := strings.TrimSpace(q.Get("api_key_id"))
|
||||
|
||||
clauses := make([]string, 0, 4)
|
||||
params := make([]interface{}, 0, 4)
|
||||
idx := 1
|
||||
if since != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("requested_at >= $%d", idx))
|
||||
params = append(params, *since)
|
||||
idx++
|
||||
}
|
||||
if until != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("requested_at <= $%d", idx))
|
||||
params = append(params, *until)
|
||||
idx++
|
||||
}
|
||||
if apiKeyID != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("api_key_id = $%d", idx))
|
||||
params = append(params, apiKeyID)
|
||||
idx++
|
||||
}
|
||||
whereSQL := "TRUE"
|
||||
if len(clauses) > 0 {
|
||||
whereSQL = strings.Join(clauses, " AND ")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id::text, api_key_id, permissions_users, requested_at, endpoint, remote_addr, user_agent, provided_permissions, provided_metadata
|
||||
FROM kb_permissions_audit
|
||||
WHERE %s
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $%d
|
||||
`, whereSQL, idx)
|
||||
params = append(params, limit)
|
||||
|
||||
rows, err := db.Query(r.Context(), query, params...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", fmt.Sprintf("failed to read audit logs: %v", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]PermissionsAuditItem, 0)
|
||||
for rows.Next() {
|
||||
var item PermissionsAuditItem
|
||||
var permsJSON []byte
|
||||
var metaJSON []byte
|
||||
if err := rows.Scan(&item.ID, &item.APIKeyID, &item.PermissionsUsers, &item.RequestedAt, &item.Endpoint, &item.RemoteAddr, &item.UserAgent, &permsJSON, &metaJSON); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to read audit logs")
|
||||
return
|
||||
}
|
||||
if len(permsJSON) > 0 {
|
||||
if err := json.Unmarshal(permsJSON, &item.ProvidedPermissions); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to parse audit logs")
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(metaJSON) > 0 {
|
||||
if err := json.Unmarshal(metaJSON, &item.ProvidedMetadata); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to parse audit logs")
|
||||
return
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to read audit logs")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, PermissionsAuditResponse{Items: items})
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminApiKeys(store APIKeyStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin/api-keys")
|
||||
@@ -1160,7 +1345,7 @@ const adminHTML = `<!doctype html>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pgvecter API Admin</h1>
|
||||
<p class="muted">X-ADMIN-API-KEY を使って管理APIを呼び出します。</p>
|
||||
<p class="muted">X-ADMIN-API-KEY を使って管理APIを呼び出します(admin_key クエリは localhost のみ)。</p>
|
||||
<div id="status" class="muted"></div>
|
||||
|
||||
<section id="editSection">
|
||||
@@ -1250,8 +1435,12 @@ const adminHTML = `<!doctype html>
|
||||
function getAdminKey() {
|
||||
const input = document.getElementById('adminKey').value.trim();
|
||||
if (input) { return input; }
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('admin_key') || '';
|
||||
const host = window.location.hostname;
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('admin_key') || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'X-ADMIN-API-KEY': getAdminKey() };
|
||||
@@ -1372,12 +1561,17 @@ const adminHTML = `<!doctype html>
|
||||
func withAdminUIAuth(adminKey string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if adminKey == "" {
|
||||
writeError(w, http.StatusInternalServerError, "admin_key_not_configured", "ADMIN_API_KEY not configured")
|
||||
writeError(w, http.StatusServiceUnavailable, "admin_key_not_configured", "ADMIN_API_KEY is not set")
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("X-ADMIN-API-KEY")
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("admin_key")
|
||||
if isLocalhostRequest(r) {
|
||||
key = r.URL.Query().Get("admin_key")
|
||||
} else if r.URL.Query().Get("admin_key") != "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "admin_key query parameter is only allowed from localhost")
|
||||
return
|
||||
}
|
||||
}
|
||||
if key == "" || key != adminKey {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid admin api key")
|
||||
@@ -1738,6 +1932,127 @@ func embeddingDim() int {
|
||||
return n
|
||||
}
|
||||
|
||||
func parseTimeParam(raw string) (*time.Time, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func parseLimit(raw string, def, max int) (int, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, errors.New("limit must be a positive integer")
|
||||
}
|
||||
if n > max {
|
||||
return max, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func isLocalhostRequest(r *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
func hasMetadataPermissions(meta map[string]interface{}) bool {
|
||||
if meta == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := meta["permissions"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func filterHasMetadataPermissions(filter map[string]interface{}) bool {
|
||||
return valueHasMetadataPermissions(filter)
|
||||
}
|
||||
|
||||
func valueHasMetadataPermissions(value interface{}) bool {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, child := range v {
|
||||
if strings.HasPrefix(k, "metadata.permissions") {
|
||||
return true
|
||||
}
|
||||
if valueHasMetadataPermissions(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if valueHasMetadataPermissions(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func fetchEmbeddingDim(ctx context.Context, db *pgxpool.Pool) (int, error) {
|
||||
var typeStr string
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT format_type(a.atttypid, a.atttypmod)
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = 'kb_doc_chunks'
|
||||
AND a.attname = 'embedding'
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
`).Scan(&typeStr)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, errors.New("kb_doc_chunks.embedding not found")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
dim, err := parseVectorTypeDim(typeStr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return dim, nil
|
||||
}
|
||||
|
||||
func verifyEmbeddingDim(db *pgxpool.Pool, expected int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
actual, err := fetchEmbeddingDim(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual != expected {
|
||||
return fmt.Errorf("embedding dimension mismatch: EMBEDDING_DIM=%d db=%d", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseVectorTypeDim(typeStr string) (int, error) {
|
||||
s := strings.TrimSpace(typeStr)
|
||||
if !strings.HasPrefix(s, "vector(") || !strings.HasSuffix(s, ")") {
|
||||
return 0, fmt.Errorf("invalid embedding type: %s", s)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(s, "vector("), ")")
|
||||
dim, err := strconv.Atoi(inner)
|
||||
if err != nil || dim <= 0 {
|
||||
return 0, fmt.Errorf("invalid embedding type: %s", s)
|
||||
}
|
||||
return dim, nil
|
||||
}
|
||||
|
||||
func logConnectionInfo(db *pgxpool.Pool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -1884,7 +2199,7 @@ func initStore() (APIKeyStore, *pgxpool.Pool, func(), error) {
|
||||
func main() {
|
||||
adminKey := os.Getenv("ADMIN_API_KEY")
|
||||
if adminKey == "" {
|
||||
log.Println("warning: ADMIN_API_KEY not set; admin endpoints will return 500")
|
||||
log.Println("warning: ADMIN_API_KEY not set; admin endpoints will return 503")
|
||||
}
|
||||
|
||||
store, db, closeStore, err := initStore()
|
||||
@@ -1892,6 +2207,11 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer closeStore()
|
||||
if db != nil {
|
||||
if err := verifyEmbeddingDim(db, embeddingDim()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", handleHealth)
|
||||
@@ -1906,6 +2226,7 @@ func main() {
|
||||
mux.Handle("/admin/api-keys", adminHandler)
|
||||
mux.Handle("/admin/api-keys/", adminHandler)
|
||||
mux.Handle("/admin/collections", withAdminAuth(adminKey, handleAdminCollections(db)))
|
||||
mux.Handle("/admin/audit/permissions", withAdminAuth(adminKey, handleAdminPermissionsAudit(db)))
|
||||
mux.Handle("/admin", withAdminUIAuth(adminKey, http.HandlerFunc(handleAdminUI)))
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
|
||||
15
migrations/005_add_permissions_audit.sql
Normal file
15
migrations/005_add_permissions_audit.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Permissions tampering audit log
|
||||
CREATE TABLE IF NOT EXISTS kb_permissions_audit (
|
||||
id uuid PRIMARY KEY,
|
||||
api_key_id text NOT NULL,
|
||||
permissions_users text[] NOT NULL,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
endpoint text NOT NULL,
|
||||
remote_addr text,
|
||||
user_agent text,
|
||||
provided_permissions jsonb NOT NULL,
|
||||
provided_metadata jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS kb_permissions_audit_requested_at_idx ON kb_permissions_audit(requested_at);
|
||||
CREATE INDEX IF NOT EXISTS kb_permissions_audit_api_key_id_idx ON kb_permissions_audit(api_key_id);
|
||||
11
migrations/006_change_embedding_dim_1024.sql
Normal file
11
migrations/006_change_embedding_dim_1024.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Change embedding dimension to 1024 (destructive: truncate)
|
||||
-- This assumes embeddings will be recalculated after migration.
|
||||
DROP INDEX IF EXISTS kb_doc_chunks_embedding_hnsw;
|
||||
|
||||
TRUNCATE TABLE kb_doc_chunks;
|
||||
|
||||
ALTER TABLE kb_doc_chunks
|
||||
ALTER COLUMN embedding TYPE vector(1024);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS kb_doc_chunks_embedding_hnsw
|
||||
ON kb_doc_chunks USING hnsw (embedding vector_cosine_ops);
|
||||
169
openapi.yaml
169
openapi.yaml
@@ -69,6 +69,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/kb/search:
|
||||
post:
|
||||
summary: Vector similarity search
|
||||
@@ -92,6 +98,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/kb/delete:
|
||||
post:
|
||||
summary: Delete documents
|
||||
@@ -115,6 +127,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/collections:
|
||||
get:
|
||||
summary: List collections
|
||||
@@ -132,6 +150,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/admin/collections:
|
||||
get:
|
||||
summary: List collections (admin)
|
||||
@@ -151,6 +175,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY or DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/admin/api-keys:
|
||||
get:
|
||||
summary: List API keys (admin)
|
||||
@@ -170,6 +200,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
post:
|
||||
summary: Issue API key (admin)
|
||||
operationId: adminApiKeysCreate
|
||||
@@ -200,6 +236,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/admin/api-keys/{id}:
|
||||
patch:
|
||||
summary: Update API key metadata (admin)
|
||||
@@ -237,6 +279,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/admin/api-keys/{id}/revoke:
|
||||
post:
|
||||
summary: Revoke API key (admin)
|
||||
@@ -262,6 +310,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/admin/audit/permissions:
|
||||
get:
|
||||
summary: List permissions audit logs (admin)
|
||||
operationId: listPermissionsAudit
|
||||
security:
|
||||
- AdminApiKeyAuth: []
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: RFC3339 lower bound for requested_at (inclusive)
|
||||
- name: until
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: RFC3339 upper bound for requested_at (inclusive)
|
||||
- name: api_key_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by API key ID
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
description: Max items to return (default 100, max 1000)
|
||||
responses:
|
||||
"200":
|
||||
description: Audit log list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PermissionsAuditResponse"
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"503":
|
||||
description: Service not configured (ADMIN_API_KEY or DATABASE_URL not set)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
@@ -343,11 +457,12 @@ components:
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: metadata.permissions is not allowed (server enforces permissions.users from API key).
|
||||
embedding:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
description: Embedding vector (length 1024). If omitted, server generates via llama.cpp.
|
||||
description: Embedding vector (length must match EMBEDDING_DIM). If omitted, server generates via provider.
|
||||
required:
|
||||
- id
|
||||
- content
|
||||
@@ -385,7 +500,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
description: Optional precomputed embedding vector. If omitted, server generates via llama.cpp.
|
||||
description: Optional precomputed embedding vector (length must match EMBEDDING_DIM). If omitted, server generates via provider.
|
||||
top_k:
|
||||
type: integer
|
||||
default: 5
|
||||
@@ -404,7 +519,8 @@ components:
|
||||
additionalProperties: true
|
||||
description: |
|
||||
JSON filter over metadata. Supported operators: eq, in, contains, exists, lt/lte/gt/gte, and/or.
|
||||
Example: {"and":[{"eq":{"metadata.source":"crm"}},{"exists":{"metadata.permissions.users":true}}]}
|
||||
Example: {"and":[{"eq":{"metadata.source":"crm"}},{"exists":{"metadata.visibility":true}}]}
|
||||
Note: metadata.permissions.* in filter is rejected (400).
|
||||
required:
|
||||
- query
|
||||
KbSearchResponse:
|
||||
@@ -431,7 +547,7 @@ components:
|
||||
filter:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Required unless id is provided. doc_id-only delete is not allowed (use filter). Optional metadata filter (MCP filter DSL). Permissions filter is always enforced. Supports eq/in/contains/exists/lt/lte/gt/gte and/or.
|
||||
description: Required unless id is provided. doc_id-only delete is not allowed (use filter). Optional metadata filter (MCP filter DSL). Permissions filter is always enforced. Supports eq/in/contains/exists/lt/lte/gt/gte and/or. metadata.permissions.* in filter is rejected (400).
|
||||
dry_run:
|
||||
type: boolean
|
||||
description: If true, only returns the number of rows that would be deleted.
|
||||
@@ -521,6 +637,51 @@ components:
|
||||
$ref: "#/components/schemas/AdminApiKey"
|
||||
required:
|
||||
- items
|
||||
PermissionsAuditResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PermissionsAuditItem"
|
||||
required:
|
||||
- items
|
||||
PermissionsAuditItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
api_key_id:
|
||||
type: string
|
||||
permissions_users:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
endpoint:
|
||||
type: string
|
||||
remote_addr:
|
||||
type: string
|
||||
nullable: true
|
||||
user_agent:
|
||||
type: string
|
||||
nullable: true
|
||||
provided_permissions:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
provided_metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- id
|
||||
- api_key_id
|
||||
- permissions_users
|
||||
- requested_at
|
||||
- endpoint
|
||||
- provided_permissions
|
||||
- provided_metadata
|
||||
AdminApiKeyCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
- [ ] `doc_upsert` の `ts` 既定補完(未指定時に現在時刻)
|
||||
- [ ] `security_upsert` の `ts` 既定補完(未指定時に現在時刻)
|
||||
|
||||
## 今回の変更点チェックリスト
|
||||
- [x] `/admin` 未設定時は 503 で明示エラー
|
||||
- [x] `DATABASE_URL` 未設定時は 503 で明示エラー
|
||||
- [x] `metadata.permissions` 送信を 400 で拒否
|
||||
- [x] `filter` 内の `metadata.permissions.*` を 400 で拒否
|
||||
- [x] 改ざん監査ログの保存(DB + ログ)
|
||||
- [x] 改ざん監査ログ取得 API(`GET /admin/audit/permissions`)
|
||||
- [x] 監査ログ用テーブル `kb_permissions_audit` 追加
|
||||
- [x] 1024次元へ統一するマイグレーション追加(破壊的)
|
||||
- [x] 1024次元の検証SQLを `format_type` ベースに修正
|
||||
- [x] README の 1024 次元/注意点/テスト結果を更新
|
||||
|
||||
## 完了
|
||||
- [x] `/kb/delete` エンドポイント追加(`id` または `filter` 必須)
|
||||
- [x] MCP削除ツール追加(`dev_log` / `trade_event` / `doc` / `security`)
|
||||
|
||||
46
scripts/install_worklog_hook.sh
Executable file
46
scripts/install_worklog_hook.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
echo "install_worklog_hook: not a git repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
common_src="$repo_root/scripts/worklog_mcp_common.sh"
|
||||
if [[ ! -f "$common_src" ]]; then
|
||||
echo "install_worklog_hook: common script not found: $common_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_common="0"
|
||||
common_dest="${WORKLOG_MCP_COMMON:-$HOME/.local/bin/worklog_mcp_common.sh}"
|
||||
if [[ "${1:-}" == "--install-common" ]]; then
|
||||
install_common="1"
|
||||
fi
|
||||
|
||||
if [[ "$install_common" == "1" ]]; then
|
||||
mkdir -p "$(dirname "$common_dest")"
|
||||
cp "$common_src" "$common_dest"
|
||||
chmod +x "$common_dest"
|
||||
echo "Installed common script: $common_dest"
|
||||
echo "Set WORKLOG_MCP_COMMON if you want a different path."
|
||||
fi
|
||||
|
||||
hook_path="$repo_root/.git/hooks/post-commit"
|
||||
cat <<HOOK > "$hook_path"
|
||||
#!/usr/bin/env bash
|
||||
exec "$repo_root/scripts/worklog_mcp_hook.sh"
|
||||
HOOK
|
||||
chmod +x "$hook_path"
|
||||
|
||||
chmod +x "$repo_root/scripts/worklog_mcp_common.sh"
|
||||
chmod +x "$repo_root/scripts/worklog_mcp_hook.sh"
|
||||
chmod +x "$repo_root/scripts/worklog_mcp.sh" || true
|
||||
|
||||
cat <<'MSG'
|
||||
worklog hook installed (post-commit).
|
||||
If you want to share a single common script across repos, run:
|
||||
scripts/install_worklog_hook.sh --install-common
|
||||
Then each repo can set WORKLOG_MCP_COMMON to that path (or rely on ~/.local/bin).
|
||||
MSG
|
||||
@@ -7,3 +7,24 @@ SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'kb_delete_audit'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- Verify kb_permissions_audit exists and indexes
|
||||
SELECT to_regclass('public.kb_permissions_audit') AS kb_permissions_audit_table;
|
||||
SELECT indexname FROM pg_indexes WHERE tablename = 'kb_permissions_audit' ORDER BY indexname;
|
||||
|
||||
-- Sanity: ensure columns exist
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'kb_permissions_audit'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- Verify embedding dimension is 1024
|
||||
SELECT format_type(a.atttypid, a.atttypmod) AS embedding_type
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = 'kb_doc_chunks'
|
||||
AND a.attname = 'embedding'
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped;
|
||||
|
||||
@@ -1,296 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
WORKLOG_PATH="notes/worklog.md"
|
||||
STATE_PATH=".worklog_mcp_state"
|
||||
MCP_SERVER_CMD=(go run /Users/sunamurahideyuki/develop/pgvecterAPI/cmd/mcp-server)
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
repo_root="$(pwd)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WORKLOG_PATH" ]]; then
|
||||
export WORKLOG_REPO_ROOT="$repo_root"
|
||||
export WORKLOG_PATH="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
|
||||
export STATE_PATH="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
|
||||
|
||||
common="$repo_root/scripts/worklog_mcp_common.sh"
|
||||
if [[ ! -x "$common" ]]; then
|
||||
echo "worklog_mcp: common script not found: $common" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract the last paragraph (separated by blank lines)
|
||||
entry="$(awk 'BEGIN{RS=""; ORS=""} {block=$0} END{print block}' "$WORKLOG_PATH" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [[ -z "$entry" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
summary="$(WORKLOG_ENTRY="$entry" python3 - <<'PY'
|
||||
import os, re
|
||||
|
||||
text = os.environ["WORKLOG_ENTRY"]
|
||||
|
||||
def summarize(t: str) -> str:
|
||||
t = t.strip()
|
||||
if not t:
|
||||
return t
|
||||
if "。" in t:
|
||||
parts = [p for p in t.split("。") if p.strip()]
|
||||
return "。".join(parts[:2]) + ("。" if len(parts) > 0 else "")
|
||||
if "." in t:
|
||||
parts = [p for p in t.split(".") if p.strip()]
|
||||
return ".".join(parts[:2]) + ("." if len(parts) > 0 else "")
|
||||
if len(t) > 240:
|
||||
return t[:240].rstrip() + "..."
|
||||
return t
|
||||
|
||||
print(summarize(text))
|
||||
PY
|
||||
)"
|
||||
|
||||
hash="$(printf "%s" "$entry" | shasum -a 256 | awk '{print $1}')"
|
||||
if [[ -f "$STATE_PATH" && "${WORKLOG_FORCE:-}" != "1" ]]; then
|
||||
last_hash="$(cat "$STATE_PATH" 2>/dev/null || true)"
|
||||
if [[ "$hash" == "$last_hash" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${PGVECTER_BASE_URL:-}" || -z "${PGVECTER_API_KEY:-}" ]]; then
|
||||
echo "worklog_mcp: PGVECTER_BASE_URL / PGVECTER_API_KEY が未設定のため送信をスキップしました。" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
has_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
contains() {
|
||||
local pattern="$1"
|
||||
if has_cmd rg; then
|
||||
printf "%s" "$entry" | rg -q "$pattern"
|
||||
else
|
||||
printf "%s" "$entry" | grep -Eq "$pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
declare -a tags=()
|
||||
add_tag() {
|
||||
local t="$1"
|
||||
for e in "${tags[@]-}"; do
|
||||
if [[ "$e" == "$t" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
tags+=("$t")
|
||||
}
|
||||
|
||||
contains "pgvecter|pgvecterAPI" && add_tag "pgvecter"
|
||||
contains "MCP|mcp" && add_tag "mcp"
|
||||
contains "API|api" && add_tag "api"
|
||||
contains "デプロイ|deploy|リリース" && add_tag "deploy"
|
||||
contains "リリース|release" && add_tag "release"
|
||||
contains "ロールバック|rollback" && add_tag "rollback"
|
||||
contains "運用|ops|監視" && add_tag "ops"
|
||||
contains "監視|monitor|monitoring" && add_tag "monitoring"
|
||||
contains "アラート|alert" && add_tag "alert"
|
||||
contains "障害|incident|障碍|障害対応" && add_tag "incident"
|
||||
contains "オンコール|oncall" && add_tag "oncall"
|
||||
contains "セキュリティ|security" && add_tag "security"
|
||||
contains "DB|db|database|マイグレーション|migration" && add_tag "db" && add_tag "migration"
|
||||
contains "スキーマ|schema" && add_tag "schema"
|
||||
contains "埋め込み|embedding" && add_tag "embedding"
|
||||
contains "検索|search" && add_tag "search"
|
||||
contains "キャッシュ|cache" && add_tag "cache"
|
||||
contains "パフォーマンス|perf|latency|遅延|高速化" && add_tag "perf"
|
||||
contains "バグ|bug" && add_tag "bugfix"
|
||||
contains "リファクタ|refactor" && add_tag "refactor"
|
||||
contains "ドキュメント|docs" && add_tag "docs"
|
||||
contains "テスト|test" && add_tag "test"
|
||||
contains "CI|ci|ビルド|build" && add_tag "ci"
|
||||
contains "インフラ|infra" && add_tag "infra"
|
||||
|
||||
if [[ "${#tags[@]}" -lt 2 ]]; then
|
||||
add_tag "pgvecter"
|
||||
add_tag "ops"
|
||||
fi
|
||||
if [[ "${#tags[@]}" -gt 5 ]]; then
|
||||
tags=("${tags[@]:0:5}")
|
||||
fi
|
||||
|
||||
topic="general"
|
||||
|
||||
if has_cmd uuidgen; then
|
||||
entry_id="$(uuidgen | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
entry_id="$(python3 - <<'PY'
|
||||
import uuid
|
||||
print(str(uuid.uuid4()))
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
tags_json="$(printf '%s\n' "${tags[@]-}" | python3 - <<'PY'
|
||||
import json, sys
|
||||
tags = [line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]
|
||||
print(json.dumps(tags))
|
||||
PY
|
||||
)"
|
||||
|
||||
export WORKLOG_ENTRY="$entry"
|
||||
export WORKLOG_SUMMARY="$summary"
|
||||
export WORKLOG_TAGS_JSON="$tags_json"
|
||||
export WORKLOG_TOPIC="$topic"
|
||||
export WORKLOG_ID="$entry_id"
|
||||
|
||||
payload="$(python3 - <<'PY'
|
||||
import json, os, re
|
||||
|
||||
entry = os.environ["WORKLOG_ENTRY"]
|
||||
tags = json.loads(os.environ.get("WORKLOG_TAGS_JSON", "[]"))
|
||||
topic = os.environ["WORKLOG_TOPIC"]
|
||||
entry_id = os.environ["WORKLOG_ID"]
|
||||
|
||||
init = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "worklog-hook", "version": "0.1"}
|
||||
}
|
||||
}
|
||||
|
||||
def detect_topic(text, tags, fallback):
|
||||
patterns = [
|
||||
("mcp", [r"\\bmcp\\b", r"MCP"]),
|
||||
("deploy", [r"デプロイ", r"deploy", r"リリース", r"release"]),
|
||||
("ops", [r"運用", r"\\bops\\b", r"監視", r"monitor", r"monitoring", r"oncall", r"アラート", r"alert"]),
|
||||
("ci", [r"\\bci\\b", r"CI", r"ビルド", r"build"]),
|
||||
("security", [r"セキュリティ", r"security", r"脆弱性", r"vuln", r"CVE", r"侵入", r"攻撃", r"WAF"]),
|
||||
("db", [r"\\bDB\\b", r"\\bdb\\b", r"database", r"postgres", r"pgvector", r"sql", r"マイグレーション", r"migration", r"schema"]),
|
||||
("docs", [r"ドキュメント", r"docs", r"readme", r"仕様", r"spec"]),
|
||||
("test", [r"テスト", r"\\btest\\b", r"e2e", r"unit", r"integration"]),
|
||||
("api", [r"API", r"api", r"endpoint", r"エンドポイント", r"ルート", r"route"]),
|
||||
]
|
||||
tag_boost = {
|
||||
"mcp": "mcp",
|
||||
"deploy": "deploy",
|
||||
"ops": "ops",
|
||||
"ci": "ci",
|
||||
"security": "security",
|
||||
"db": "db",
|
||||
"migration": "db",
|
||||
"docs": "docs",
|
||||
"test": "test",
|
||||
"api": "api",
|
||||
}
|
||||
scores = {name: 0 for name, _ in patterns}
|
||||
for name, pats in patterns:
|
||||
for pat in pats:
|
||||
if re.search(pat, text, re.IGNORECASE):
|
||||
scores[name] += 1
|
||||
for t in tags:
|
||||
key = tag_boost.get(t)
|
||||
if key:
|
||||
scores[key] += 1
|
||||
best = max(scores.items(), key=lambda kv: (kv[1], -list(scores.keys()).index(kv[0])))
|
||||
if best[1] > 0:
|
||||
return best[0]
|
||||
return fallback if fallback != "general" else "general"
|
||||
|
||||
if not tags:
|
||||
text = entry
|
||||
def has(pat):
|
||||
return re.search(pat, text, re.IGNORECASE) is not None
|
||||
if has(r"pgvecter|pgvecterAPI"):
|
||||
tags.append("pgvecter")
|
||||
if has(r"MCP|mcp"):
|
||||
tags.append("mcp")
|
||||
if has(r"API|api"):
|
||||
tags.append("api")
|
||||
if has(r"デプロイ|deploy|リリース"):
|
||||
tags.append("deploy")
|
||||
if has(r"リリース|release"):
|
||||
tags.append("release")
|
||||
if has(r"ロールバック|rollback"):
|
||||
tags.append("rollback")
|
||||
if has(r"運用|ops|監視"):
|
||||
tags.append("ops")
|
||||
if has(r"監視|monitor|monitoring"):
|
||||
tags.append("monitoring")
|
||||
if has(r"アラート|alert"):
|
||||
tags.append("alert")
|
||||
if has(r"障害|incident|障碍|障害対応"):
|
||||
tags.append("incident")
|
||||
if has(r"オンコール|oncall"):
|
||||
tags.append("oncall")
|
||||
if has(r"セキュリティ|security"):
|
||||
tags.append("security")
|
||||
if has(r"DB|db|database|マイグレーション|migration"):
|
||||
tags.extend([t for t in ("db", "migration") if t not in tags])
|
||||
if has(r"スキーマ|schema"):
|
||||
tags.append("schema")
|
||||
if has(r"埋め込み|embedding"):
|
||||
tags.append("embedding")
|
||||
if has(r"検索|search"):
|
||||
tags.append("search")
|
||||
if has(r"キャッシュ|cache"):
|
||||
tags.append("cache")
|
||||
if has(r"パフォーマンス|perf|latency|遅延|高速化"):
|
||||
tags.append("perf")
|
||||
if has(r"バグ|bug"):
|
||||
tags.append("bugfix")
|
||||
if has(r"リファクタ|refactor"):
|
||||
tags.append("refactor")
|
||||
if has(r"ドキュメント|docs"):
|
||||
tags.append("docs")
|
||||
if has(r"テスト|test"):
|
||||
tags.append("test")
|
||||
if has(r"CI|ci|ビルド|build"):
|
||||
tags.append("ci")
|
||||
if has(r"インフラ|infra"):
|
||||
tags.append("infra")
|
||||
if len(tags) < 2:
|
||||
for t in ("pgvecter", "ops"):
|
||||
if t not in tags:
|
||||
tags.append(t)
|
||||
if len(tags) > 5:
|
||||
tags = tags[:5]
|
||||
|
||||
topic = detect_topic(entry, tags, topic)
|
||||
|
||||
metadata = {"worklog_path": "notes/worklog.md"}
|
||||
if entry != os.environ["WORKLOG_SUMMARY"]:
|
||||
metadata["original"] = entry
|
||||
|
||||
call = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "diary_upsert",
|
||||
"arguments": {
|
||||
"id": entry_id,
|
||||
"collection": "dev_diary",
|
||||
"content": os.environ["WORKLOG_SUMMARY"],
|
||||
"tags": tags,
|
||||
"topic": topic,
|
||||
"source": "codex",
|
||||
"metadata": metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print(json.dumps(init))
|
||||
print(json.dumps(call))
|
||||
PY
|
||||
)"
|
||||
|
||||
response="$(printf "%s\n" "$payload" | "${MCP_SERVER_CMD[@]}")"
|
||||
|
||||
if printf "%s" "$response" | grep -q '"isError":true'; then
|
||||
echo "worklog_mcp: MCP 送信に失敗しました。" >&2
|
||||
echo "$response" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$hash" > "$STATE_PATH"
|
||||
exec "$common"
|
||||
|
||||
323
scripts/worklog_mcp_common.sh
Executable file
323
scripts/worklog_mcp_common.sh
Executable file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve repo root (default to git root or current dir)
|
||||
repo_root="${WORKLOG_REPO_ROOT:-}"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
if git_root=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||
repo_root="$git_root"
|
||||
else
|
||||
repo_root="$(pwd)"
|
||||
fi
|
||||
fi
|
||||
|
||||
worklog_path="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
|
||||
state_path="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
|
||||
|
||||
env_path="${WORKLOG_ENV_PATH:-$repo_root/.env}"
|
||||
|
||||
if [[ -n "${WORKLOG_MCP_SERVER_CMD:-}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
MCP_SERVER_CMD=($WORKLOG_MCP_SERVER_CMD)
|
||||
else
|
||||
MCP_SERVER_CMD=(go run /Users/sunamurahideyuki/develop/pgvecterAPI/cmd/mcp-server)
|
||||
fi
|
||||
|
||||
if [[ ! -f "$worklog_path" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract the last paragraph (separated by blank lines)
|
||||
entry="$(awk 'BEGIN{RS=""; ORS=""} {block=$0} END{print block}' "$worklog_path" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [[ -z "$entry" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
summary="$(WORKLOG_ENTRY="$entry" python3 - <<'PY'
|
||||
import os
|
||||
|
||||
text = os.environ["WORKLOG_ENTRY"]
|
||||
|
||||
def summarize(t: str) -> str:
|
||||
t = t.strip()
|
||||
if not t:
|
||||
return t
|
||||
if "。" in t:
|
||||
parts = [p for p in t.split("。") if p.strip()]
|
||||
return "。".join(parts[:2]) + ("。" if len(parts) > 0 else "")
|
||||
if "." in t:
|
||||
parts = [p for p in t.split(".") if p.strip()]
|
||||
return ".".join(parts[:2]) + ("." if len(parts) > 0 else "")
|
||||
if len(t) > 240:
|
||||
return t[:240].rstrip() + "..."
|
||||
return t
|
||||
|
||||
print(summarize(text))
|
||||
PY
|
||||
)"
|
||||
|
||||
hash="$(printf "%s" "$entry" | shasum -a 256 | awk '{print $1}')"
|
||||
if [[ -f "$state_path" && "${WORKLOG_FORCE:-}" != "1" ]]; then
|
||||
last_hash="$(cat "$state_path" 2>/dev/null || true)"
|
||||
if [[ "$hash" == "$last_hash" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$env_path" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_path"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${PGVECTER_BASE_URL:-}" || -z "${PGVECTER_API_KEY:-}" ]]; then
|
||||
echo "worklog_mcp: PGVECTER_BASE_URL / PGVECTER_API_KEY が未設定のため送信をスキップしました。" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
has_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
contains() {
|
||||
local pattern="$1"
|
||||
if has_cmd rg; then
|
||||
printf "%s" "$entry" | rg -q "$pattern"
|
||||
else
|
||||
printf "%s" "$entry" | grep -Eq "$pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
declare -a tags=()
|
||||
add_tag() {
|
||||
local t="$1"
|
||||
for e in "${tags[@]-}"; do
|
||||
if [[ "$e" == "$t" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
tags+=("$t")
|
||||
}
|
||||
|
||||
contains "pgvecter|pgvecterAPI" && add_tag "pgvecter"
|
||||
contains "MCP|mcp" && add_tag "mcp"
|
||||
contains "API|api" && add_tag "api"
|
||||
contains "デプロイ|deploy|リリース" && add_tag "deploy"
|
||||
contains "リリース|release" && add_tag "release"
|
||||
contains "ロールバック|rollback" && add_tag "rollback"
|
||||
contains "運用|ops|監視" && add_tag "ops"
|
||||
contains "監視|monitor|monitoring" && add_tag "monitoring"
|
||||
contains "アラート|alert" && add_tag "alert"
|
||||
contains "障害|incident|障碍|障害対応" && add_tag "incident"
|
||||
contains "オンコール|oncall" && add_tag "oncall"
|
||||
contains "セキュリティ|security" && add_tag "security"
|
||||
contains "DB|db|database|マイグレーション|migration" && add_tag "db" && add_tag "migration"
|
||||
contains "スキーマ|schema" && add_tag "schema"
|
||||
contains "埋め込み|embedding" && add_tag "embedding"
|
||||
contains "検索|search" && add_tag "search"
|
||||
contains "キャッシュ|cache" && add_tag "cache"
|
||||
contains "パフォーマンス|perf|latency|遅延|高速化" && add_tag "perf"
|
||||
contains "バグ|bug" && add_tag "bugfix"
|
||||
contains "リファクタ|refactor" && add_tag "refactor"
|
||||
contains "ドキュメント|docs" && add_tag "docs"
|
||||
contains "テスト|test" && add_tag "test"
|
||||
contains "CI|ci|ビルド|build" && add_tag "ci"
|
||||
contains "インフラ|infra" && add_tag "infra"
|
||||
|
||||
if [[ "${#tags[@]}" -lt 2 ]]; then
|
||||
add_tag "pgvecter"
|
||||
add_tag "ops"
|
||||
fi
|
||||
if [[ "${#tags[@]}" -gt 5 ]]; then
|
||||
tags=("${tags[@]:0:5}")
|
||||
fi
|
||||
|
||||
topic="general"
|
||||
|
||||
if has_cmd uuidgen; then
|
||||
entry_id="$(uuidgen | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
entry_id="$(python3 - <<'PY'
|
||||
import uuid
|
||||
print(str(uuid.uuid4()))
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
tags_json="$(printf '%s\n' "${tags[@]-}" | python3 - <<'PY'
|
||||
import json, sys
|
||||
|
||||
tags = [line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]
|
||||
print(json.dumps(tags))
|
||||
PY
|
||||
)"
|
||||
|
||||
export WORKLOG_ENTRY="$entry"
|
||||
export WORKLOG_SUMMARY="$summary"
|
||||
export WORKLOG_TAGS_JSON="$tags_json"
|
||||
export WORKLOG_TOPIC="$topic"
|
||||
export WORKLOG_ID="$entry_id"
|
||||
|
||||
if [[ "$worklog_path" == "$repo_root/"* ]]; then
|
||||
worklog_rel="${worklog_path#$repo_root/}"
|
||||
else
|
||||
worklog_rel="$worklog_path"
|
||||
fi
|
||||
export WORKLOG_PATH_REL="$worklog_rel"
|
||||
|
||||
payload="$(python3 - <<'PY'
|
||||
import json, os, re
|
||||
|
||||
entry = os.environ["WORKLOG_ENTRY"]
|
||||
tags = json.loads(os.environ.get("WORKLOG_TAGS_JSON", "[]"))
|
||||
topic = os.environ["WORKLOG_TOPIC"]
|
||||
entry_id = os.environ["WORKLOG_ID"]
|
||||
worklog_rel = os.environ.get("WORKLOG_PATH_REL", "notes/worklog.md")
|
||||
|
||||
init = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "worklog-hook", "version": "0.1"}
|
||||
}
|
||||
}
|
||||
|
||||
def detect_topic(text, tags, fallback):
|
||||
patterns = [
|
||||
("mcp", [r"\\bmcp\\b", r"MCP"]),
|
||||
("deploy", [r"デプロイ", r"deploy", r"リリース", r"release"]),
|
||||
("ops", [r"運用", r"\\bops\\b", r"監視", r"monitor", r"monitoring", r"oncall", r"アラート", r"alert"]),
|
||||
("ci", [r"\\bci\\b", r"CI", r"ビルド", r"build"]),
|
||||
("security", [r"セキュリティ", r"security", r"脆弱性", r"vuln", r"CVE", r"侵入", r"攻撃", r"WAF"]),
|
||||
("db", [r"\\bDB\\b", r"\\bdb\\b", r"database", r"postgres", r"pgvector", r"sql", r"マイグレーション", r"migration", r"schema"]),
|
||||
("docs", [r"ドキュメント", r"docs", r"readme", r"仕様", r"spec"]),
|
||||
("test", [r"テスト", r"\\btest\\b", r"e2e", r"unit", r"integration"]),
|
||||
("api", [r"API", r"api", r"endpoint", r"エンドポイント", r"ルート", r"route"]),
|
||||
]
|
||||
tag_boost = {
|
||||
"mcp": "mcp",
|
||||
"deploy": "deploy",
|
||||
"ops": "ops",
|
||||
"ci": "ci",
|
||||
"security": "security",
|
||||
"db": "db",
|
||||
"migration": "db",
|
||||
"docs": "docs",
|
||||
"test": "test",
|
||||
"api": "api",
|
||||
}
|
||||
scores = {name: 0 for name, _ in patterns}
|
||||
for name, pats in patterns:
|
||||
for pat in pats:
|
||||
if re.search(pat, text, re.IGNORECASE):
|
||||
scores[name] += 1
|
||||
for t in tags:
|
||||
key = tag_boost.get(t)
|
||||
if key:
|
||||
scores[key] += 1
|
||||
best = max(scores.items(), key=lambda kv: (kv[1], -list(scores.keys()).index(kv[0])))
|
||||
if best[1] > 0:
|
||||
return best[0]
|
||||
return fallback if fallback != "general" else "general"
|
||||
|
||||
if not tags:
|
||||
text = entry
|
||||
def has(pat):
|
||||
return re.search(pat, text, re.IGNORECASE) is not None
|
||||
if has(r"pgvecter|pgvecterAPI"):
|
||||
tags.append("pgvecter")
|
||||
if has(r"MCP|mcp"):
|
||||
tags.append("mcp")
|
||||
if has(r"API|api"):
|
||||
tags.append("api")
|
||||
if has(r"デプロイ|deploy|リリース"):
|
||||
tags.append("deploy")
|
||||
if has(r"リリース|release"):
|
||||
tags.append("release")
|
||||
if has(r"ロールバック|rollback"):
|
||||
tags.append("rollback")
|
||||
if has(r"運用|ops|監視"):
|
||||
tags.append("ops")
|
||||
if has(r"監視|monitor|monitoring"):
|
||||
tags.append("monitoring")
|
||||
if has(r"アラート|alert"):
|
||||
tags.append("alert")
|
||||
if has(r"障害|incident|障碍|障害対応"):
|
||||
tags.append("incident")
|
||||
if has(r"オンコール|oncall"):
|
||||
tags.append("oncall")
|
||||
if has(r"セキュリティ|security"):
|
||||
tags.append("security")
|
||||
if has(r"DB|db|database|マイグレーション|migration"):
|
||||
tags.extend([t for t in ("db", "migration") if t not in tags])
|
||||
if has(r"スキーマ|schema"):
|
||||
tags.append("schema")
|
||||
if has(r"埋め込み|embedding"):
|
||||
tags.append("embedding")
|
||||
if has(r"検索|search"):
|
||||
tags.append("search")
|
||||
if has(r"キャッシュ|cache"):
|
||||
tags.append("cache")
|
||||
if has(r"パフォーマンス|perf|latency|遅延|高速化"):
|
||||
tags.append("perf")
|
||||
if has(r"バグ|bug"):
|
||||
tags.append("bugfix")
|
||||
if has(r"リファクタ|refactor"):
|
||||
tags.append("refactor")
|
||||
if has(r"ドキュメント|docs"):
|
||||
tags.append("docs")
|
||||
if has(r"テスト|test"):
|
||||
tags.append("test")
|
||||
if has(r"CI|ci|ビルド|build"):
|
||||
tags.append("ci")
|
||||
if has(r"インフラ|infra"):
|
||||
tags.append("infra")
|
||||
if len(tags) < 2:
|
||||
for t in ("pgvecter", "ops"):
|
||||
if t not in tags:
|
||||
tags.append(t)
|
||||
if len(tags) > 5:
|
||||
tags = tags[:5]
|
||||
|
||||
topic = detect_topic(entry, tags, topic)
|
||||
|
||||
metadata = {"worklog_path": worklog_rel}
|
||||
if entry != os.environ["WORKLOG_SUMMARY"]:
|
||||
metadata["original"] = entry
|
||||
|
||||
call = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "diary_upsert",
|
||||
"arguments": {
|
||||
"id": entry_id,
|
||||
"collection": "dev_diary",
|
||||
"content": os.environ["WORKLOG_SUMMARY"],
|
||||
"tags": tags,
|
||||
"topic": topic,
|
||||
"source": "codex",
|
||||
"metadata": metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print(json.dumps(init))
|
||||
print(json.dumps(call))
|
||||
PY
|
||||
)"
|
||||
|
||||
response="$(printf "%s\n" "$payload" | "${MCP_SERVER_CMD[@]}")"
|
||||
|
||||
if printf "%s" "$response" | grep -q '"isError":true'; then
|
||||
echo "worklog_mcp: MCP 送信に失敗しました。" >&2
|
||||
echo "$response" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$hash" > "$state_path"
|
||||
27
scripts/worklog_mcp_hook.sh
Executable file
27
scripts/worklog_mcp_hook.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export WORKLOG_REPO_ROOT="$repo_root"
|
||||
export WORKLOG_PATH="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
|
||||
export STATE_PATH="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
|
||||
|
||||
common="${WORKLOG_MCP_COMMON:-}"
|
||||
if [[ -z "$common" ]]; then
|
||||
if [[ -x "/Users/sunamurahideyuki/.local/bin/worklog_mcp_common.sh" ]]; then
|
||||
common="/Users/sunamurahideyuki/.local/bin/worklog_mcp_common.sh"
|
||||
else
|
||||
common="$repo_root/scripts/worklog_mcp_common.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -x "$common" ]]; then
|
||||
echo "worklog_mcp: common script not found: $common" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "$common"
|
||||
Reference in New Issue
Block a user