From ce9f6147f129cee61e3523e35cf5ba048af26ba5 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Tue, 11 Aug 2026 23:38:50 +0800 Subject: [PATCH] docs: document roles, invites, audit log and self-signup - configuration.md (en/zh): ALLOW_SELF_SIGNUP, AUDIT_RETENTION_DAYS, member schema, role table, invite/self-signup sections, invite KV key, audit_logs D1 table, new admin endpoints - deployment.md (en/zh): migration list through 0005 - README (en/zh) and AGENTS.md: access model, invites, audit log - config.example.yaml: members example; .env.example: new variables - .gitignore: ignore local saas-roadmap.md --- .env.example | 2 + .gitignore | 1 + AGENTS.md | 24 ++++++--- README.md | 21 +++++--- README.zh.md | 21 +++++--- config.example.yaml | 5 +- docs/guide/configuration.md | 91 +++++++++++++++++++++++----------- docs/guide/deployment.md | 2 + docs/zh/guide/configuration.md | 91 +++++++++++++++++++++++----------- docs/zh/guide/deployment.md | 2 + 10 files changed, 184 insertions(+), 76 deletions(-) diff --git a/.env.example b/.env.example index 097abbf..6269474 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ TELEGRAM_WEBHOOK_SECRET=your-webhook-secret # Admin ADMIN_USER_IDS=your-github-id,your-github-login +# ALLOW_SELF_SIGNUP=1 # non-admin GitHub users get a personal group on first login (default off) +# AUDIT_RETENTION_DAYS=90 # audit log retention for the scheduled cleanup (default 90) # Server / public URL (OAuth callbacks + Telegram webhook sync) BASE_URL=http://localhost:8787 diff --git a/.gitignore b/.gitignore index e76283b..70f9c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ data/ config.yaml .direnv/ result +saas-roadmap.md diff --git a/AGENTS.md b/AGENTS.md index c5442dd..32ecfae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord - Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; GitLab etc. can be added later - GitHub OAuth: octokit (token is stored hashed for reverse lookup) - Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist +- Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only +- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry) +- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90) - Local dev: wrangler + Miniflare ## Architecture @@ -66,21 +69,24 @@ src/ │ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions │ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts) ├── web/ # HTTP UI/API routes -│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link / telegram-link), DELETE /token/:userId -│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup) -│ ├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes|groups|me|logs (session + scope auth, validation) +│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / invite accept / self-signup / discord-link / telegram-link), DELETE /token/:userId +│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via shared middleware) +│ ├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes|groups|me|logs|invites|audit (auth middleware + role guards, validation) +│ ├── auth.ts # Shared auth middleware + guards: sessionMiddleware, requireAnyAccess, requireGroup(Role), bearerAuthMiddleware, clientIp +│ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer) │ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers -│ ├── groups.ts # Group CRUD (config:groups), resolveScope, hasAnyAccess, groupAcceptsOwners +│ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup) │ ├── home-routes.ts # landing page (zh/en) │ ├── legal-routes.ts # /terms + /privacy pages (zh/en) │ └── richheader-routes.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card └── lib/ # shared infra ├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation ├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById (D1 send_logs) + ├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes) ├── log.ts # JSON console logger (info/warn/error/fatal) └── locales/ # en.ts, zh.ts translation dictionaries -src/__tests__/ # bun test unit tests (webhook, formatter, discord, telegram, admin, send-log, token-store) +src/__tests__/ # bun test unit tests (webhook, formatter, discord, telegram, admin, groups, invites, audit, send-log, token-store) ``` ## Responsibilities @@ -92,6 +98,9 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) - Filter events by: event type, repo name, actor, action, branch, keyword (regex supported) - Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` +- Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members +- Issue single-use 7-day group invite links (`invite:{token}`); accepting joins as admin/viewer (never owner); `ALLOW_SELF_SIGNUP=1` creates a deterministic personal group (`u-{userId}`) on first login +- Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS` - Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field) - Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML) - Route messages to Discord channels/threads and Telegram chats/topics via REST @@ -143,7 +152,8 @@ Rule: no functional change ships without its documentation; docs and code must n - **Production**: `wrangler secret put ` for each secret - **Routes**: KV key `config:routes` (JSON array, empty until configured) - **KV namespace**: Required binding for token/state/config/session storage -- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` + `telegram_links` tables +- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `audit_logs` + `discord_links` + `telegram_links` tables +- **Access control**: `ADMIN_USER_IDS` (super admins), `ALLOW_SELF_SIGNUP` (optional personal group on first login), `AUDIT_RETENTION_DAYS` (default 90) — all plain env vars, not secrets - **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions - **Telegram**: `TELEGRAM_TOKEN` (Bot API token from BotFather) required for Telegram routes; `TELEGRAM_WEBHOOK_SECRET` (optional secret token for `POST /telegram/webhook` verification); avatars are sent as a link-preview card via the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`) - **Webhook providers**: `GITEA_WEBHOOK_SECRET` (required to receive Gitea webhooks; Gitea signs `X-Gitea-Signature` with the hex HMAC-SHA256 of the body) @@ -158,7 +168,7 @@ npx wrangler kv namespace create KV # Update wrangler.jsonc with KV ID npx wrangler d1 create webhooker # Update wrangler.jsonc d1_databases with the database ID -npm run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0003) +npm run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0005) npx wrangler deploy ``` diff --git a/README.md b/README.md index 6748e72..892b9bc 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ npx wrangler dev # Start local dev server | `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards | | `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` | +| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) | +| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup (default 90) | | `DOCS_URL` | Optional docs site URL used by the landing page | | `GITHUB_REPO_URL` | Optional GitHub repo URL used by the landing page | | `LEGAL_CONTACT` | Optional contact shown on `/terms` and `/privacy` | @@ -110,14 +112,16 @@ Routes belong to **groups** (KV `config:groups`) that scope admin access and can ### Web UI (`/admin`) -The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), and inspect send logs — no KV access needed: +The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), inspect send logs, manage group members and invite links, and read the audit log — no KV access needed: 1. Set `ADMIN_USER_IDS` to the GitHub user IDs (or logins) allowed to manage the console, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. -2. Visit `/admin` and sign in with GitHub. Only users in the whitelist get access. +2. Visit `/admin` and sign in with GitHub. Users with no access get `403` — unless `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or they follow a group invite link. 3. Changes are written to KV immediately and picked up by the webhook pipeline. Sign out at `/admin/logout`. +**Access model.** Every group has `members` with a role: `owner` (manage group, members, invites; edit routes), `admin` (edit routes, view logs), or `viewer` (read-only). Super admins (`ADMIN_USER_IDS`) bypass everything. Legacy `adminIds` are read as owners. Owners generate single-use, 7-day invite links from the group page; group admins and viewers can browse, admins edit, owners administer. All admin operations (logins, group/route/member/invite changes) are recorded in the D1 `audit_logs` table, pruned after `AUDIT_RETENTION_DAYS` (default 90). + See `config.example.yaml` for full syntax examples. ### Filter Types @@ -157,13 +161,18 @@ Set `exclude: true` to invert any filter. - `GET /admin` — Config console UI - `GET /admin/login` — Start admin sign-in (GitHub OAuth) - `GET /admin/logout` — Sign out +- `GET /admin/invite?token=…` — Accept a group invite (browser page) - `GET /admin/api/routes` — List routes -- `PUT /admin/api/routes` — Replace routes -- `GET /admin/api/groups` — List groups (scoped) -- `PUT /admin/api/groups` — Replace groups (super admin only) +- `PUT /admin/api/routes` — Replace routes (owner/admin per group) +- `GET /admin/api/groups` — List groups + your role in each +- `PUT /admin/api/groups` — Replace groups (super: all; owner: own groups only) - `GET /admin/api/groups/:groupId/routes` — List a group's routes - `PUT /admin/api/groups/:groupId/routes` — Replace a group's routes -- `GET /admin/api/me` — Current session / scope +- `POST /admin/api/groups/:groupId/invites` — Create invite link (owner) +- `GET /admin/api/groups/:groupId/invites` — List pending invites (owner) +- `DELETE /admin/api/invites/:token` — Revoke an invite (owner) +- `GET /admin/api/audit` — Audit log (scoped) +- `GET /admin/api/me` — Current session / scope / roles - `GET /admin/api/logs` — Send logs (scoped) - `GET /admin/api/logs/:id` — Single send-log entry diff --git a/README.zh.md b/README.zh.md index 9d555d8..958a914 100644 --- a/README.zh.md +++ b/README.zh.md @@ -65,6 +65,8 @@ npx wrangler dev # 启动本地开发服务器 | `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 | | `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) | | `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID(或登录名),逗号分隔 | +| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) | +| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数(默认 90) | | `DOCS_URL` | 可选;落地页使用的文档站点 URL | | `GITHUB_REPO_URL` | 可选;落地页使用的 GitHub 仓库 URL | | `LEGAL_CONTACT` | 可选;`/terms` 与 `/privacy` 页面展示的联系方式 | @@ -110,14 +112,16 @@ npx wrangler dev # 启动本地开发服务器 ### Web 控制台(`/admin`) -内置的配置控制台让你在浏览器中管理路由与分组(新增 / 编辑 / 删除 / 开关 / 排序),并查看发送日志——无需操作 KV: +内置的配置控制台让你在浏览器中管理路由与分组(新增 / 编辑 / 删除 / 开关 / 排序)、查看发送日志、管理组成员与邀请链接、阅读审计日志——无需操作 KV: 1. 设置 `ADMIN_USER_IDS` 为允许管理控制台的 GitHub 用户 ID(或登录名),例如 `ADMIN_USER_IDS=12345,RhenCloud`。 -2. 访问 `/admin` 并用 GitHub 登录,仅白名单内用户可进入。 +2. 访问 `/admin` 并用 GitHub 登录。无任何权限的用户收到 `403`——除非开启 `ALLOW_SELF_SIGNUP=1`(自动获得个人分组)或通过分组邀请链接加入。 3. 修改会立即写入 KV,webhook 管线随即生效。 在 `/admin/logout` 退出登录。 +**权限模型。** 每个分组都有带角色的 `members`:`owner`(管理分组、成员、邀请;可编辑路由)、`admin`(编辑路由、查看日志)、`viewer`(只读)。超级管理员(`ADMIN_USER_IDS`)绕过所有角色限制。旧的 `adminIds` 字段按 owner 读取。owner 可从分组页面生成一次性、7 天有效的邀请链接;所有管理操作(登录、分组/路由/成员/邀请变更)都会写入 D1 `audit_logs` 表,并按 `AUDIT_RETENTION_DAYS`(默认 90 天)自动清理。 + 完整语法示例见 `config.example.yaml`。 ### 过滤器类型 @@ -157,13 +161,18 @@ npx wrangler dev # 启动本地开发服务器 - `GET /admin` — 配置控制台页面 - `GET /admin/login` — 开始管理员登录(GitHub OAuth) - `GET /admin/logout` — 退出登录 +- `GET /admin/invite?token=…` — 接受分组邀请(浏览器页面) - `GET /admin/api/routes` — 列出路由 -- `PUT /admin/api/routes` — 替换路由 -- `GET /admin/api/groups` — 列出分组(按权限过滤) -- `PUT /admin/api/groups` — 替换分组(仅超级管理员) +- `PUT /admin/api/routes` — 替换路由(按分组 owner/admin 权限) +- `GET /admin/api/groups` — 列出分组 + 你的角色 +- `PUT /admin/api/groups` — 替换分组(超管全量;owner 仅自己的组) - `GET /admin/api/groups/:groupId/routes` — 列出某分组的路由 - `PUT /admin/api/groups/:groupId/routes` — 替换某分组的路由 -- `GET /admin/api/me` — 当前会话 / 权限范围 +- `POST /admin/api/groups/:groupId/invites` — 创建邀请链接(owner) +- `GET /admin/api/groups/:groupId/invites` — 列出待接受邀请(owner) +- `DELETE /admin/api/invites/:token` — 撤销邀请(owner) +- `GET /admin/api/audit` — 审计日志(按权限过滤) +- `GET /admin/api/me` — 当前会话 / 权限范围 / 角色 - `GET /admin/api/logs` — 发送日志(按权限过滤) - `GET /admin/api/logs/:id` — 单条发送日志 diff --git a/config.example.yaml b/config.example.yaml index 33e5f44..a9b91a3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,7 +6,10 @@ groups: - id: default name: "Default" - adminIds: ["your-github-id"] + # Members with roles (owner / admin / viewer); legacy adminIds still work and are treated as owners. + members: + - login: "your-github-id" + role: owner # owners: ["myorg"] # restrict events to this org/user (optional) # providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all) # emoji: true # include emoji in messages (default true) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 18c76dd..c689fe1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -30,6 +30,8 @@ WebHooker requires several secrets to function. For local development, store the | `TELEGRAM_RICH_HEADER_HOST` | Base URL of an external rich-header service; when unset, the built-in `GET /api/richheader` serves the Telegram avatar card | Built-in `/api/richheader` | | `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled | +| `ALLOW_SELF_SIGNUP` | When enabled (`1`/`true`), GitHub users without any group access get a personal group on first login instead of `403` | Disabled | +| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup | `90` | ## Webhook Providers @@ -48,26 +50,31 @@ WebHooker ships with a built-in config console at `/admin` for managing routes i ### Setup -1. Configure `ADMIN_USER_IDS` with the GitHub user IDs allowed to manage routes. Logins are also accepted, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. If unset, the console is disabled. +1. Configure `ADMIN_USER_IDS` with the GitHub user IDs allowed to manage everything. Logins are also accepted, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. If unset, the console is disabled (unless `ALLOW_SELF_SIGNUP` is enabled). 2. Open `/admin` and sign in with GitHub. -3. Only users in the whitelist receive a session cookie; everyone else gets `403`. +3. Users without any access get `403`, except when `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or when they follow a group [invite link](#invites). ### Endpoints -| Endpoint | Description | -| ---------------------------------- | --------------------------------------- | -| `GET /admin` | Config console UI | -| `GET /admin/login` | Start GitHub OAuth sign-in | -| `GET /admin/logout` | Destroy session | -| `GET /admin/api/me` | Current session, scope, and groups | -| `GET /admin/api/routes` | List routes (admin only) | -| `PUT /admin/api/routes` | Replace routes (admin only) | -| `GET /admin/api/groups` | List groups (scoped to access) | -| `PUT /admin/api/groups` | Replace groups (super admin only) | -| `GET /admin/api/groups/:id/routes` | List a group's routes | -| `PUT /admin/api/groups/:id/routes` | Replace a group's routes | -| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | -| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | +| Endpoint | Description | +| ----------------------------------------- | -------------------------------------------- | +| `GET /admin` | Config console UI | +| `GET /admin/login` | Start GitHub OAuth sign-in | +| `GET /admin/logout` | Destroy session | +| `GET /admin/invite?token=…` | Accept a group invite (browser page) | +| `GET /admin/api/me` | Current session, scope, groups, and roles | +| `GET /admin/api/routes` | List routes (scoped to access) | +| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | +| `GET /admin/api/groups` | List groups + the signed-in user's role each | +| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | +| `GET /admin/api/groups/:id/routes` | List a group's routes | +| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | +| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | +| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | +| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | +| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | +| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | +| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run. @@ -167,29 +174,53 @@ Routes belong to groups. Groups scope admin access and can restrict which events { "id": "backend-team", "name": "Backend Team", - "adminIds": ["rhencloud"], + "members": [ + { "login": "rhencloud", "role": "owner" }, + { "login": "octobot", "role": "admin" }, + { "login": "reader", "role": "viewer" } + ], "owners": ["myorg"], "providers": ["github", "gitea"] } ``` -| Field | Type | Required | Description | -| ----------- | -------- | -------- | ------------------------------------------------------------------------- | -| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | -| `name` | string | Yes | Human-readable group name | -| `adminIds` | string[] | Yes | GitHub user IDs or logins who may manage this group's routes | -| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | -| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all | -| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | +| Field | Type | Required | Description | +| ----------- | -------- | -------- | ---------------------------------------------------------------------------- | +| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | +| `name` | string | Yes | Human-readable group name | +| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` | +| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present | +| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | +| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all | +| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | + +### Roles + +Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) always bypass them. + +| Role | View routes/logs | Edit routes | Manage members & invites | Edit group settings | +| -------- | ---------------- | ----------- | ------------------------ | ------------------- | +| `owner` | ✓ | ✓ | ✓ | ✓ (except `owners`) | +| `admin` | ✓ | ✓ | ✗ | ✗ | +| `viewer` | ✓ (read-only) | ✗ | ✗ | ✗ | ### Access Model -- **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes. -- **Group admins** (`adminIds`) only see and edit the groups they manage; submitting a route outside their groups returns `403`. +- **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes; only they can edit a group's `owners` list. +- **Owners** manage their group's routes, members, invites, name, `emoji`, and `providers`. They cannot remove the last owner or demote themselves when no other owner remains. +- **Admins** edit routes inside their groups and view logs; **viewers** get a read-only console. - Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter. - The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all. - The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide. +### Invites + +Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`. + +### Self Sign-up + +With `ALLOW_SELF_SIGNUP=1`, a GitHub user who has no group access gets a personal group (`u-{userId}`, owned by them) on first login instead of a `403`. This is the entry point for a fully self-service SaaS install; disable it to keep the console invite-only. + ## Filter Types See the [Filter Tutorial](./filters) for a hands-on guide with worked examples. @@ -230,6 +261,7 @@ Filters accept either a single string or an array of strings: | `token:{userId}` | `{ userId, accessToken, expiresAt, refreshToken? }` | 0.9 × token expiry | | `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds | +| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days | | `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds | | `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run`) | 7 days | | `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent | @@ -239,10 +271,13 @@ Filters accept either a single string or an array of strings: ## D1 Storage Layout -The D1 database (`DB` binding, database `webhooker`) holds three tables: +The D1 database (`DB` binding, database `webhooker`) holds four tables: | Table | Purpose | | ---------------- | ---------------------------------------------------------------------------------------------- | | `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) | +| `audit_logs` | One row per admin operation (login/logout, group/route/member/invite changes) | | `discord_links` | Maps `discord_user_id` → `github_user_id` for `/gh` Discord commands | | `telegram_links` | Maps `telegram_user_id` → `github_user_id` for `/gh` Telegram commands | + +`audit_logs` is pruned automatically by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90). diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 92364ff..b3158f1 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -81,6 +81,8 @@ If the database already has these tables/columns (e.g. previously migrated with npx wrangler d1 execute webhooker --remote --file ./migrations/0001_init.sql npx wrangler d1 execute webhooker --remote --file ./migrations/0002_log_detail.sql npx wrangler d1 execute webhooker --remote --file ./migrations/0003_telegram_links.sql +npx wrangler d1 execute webhooker --remote --file ./migrations/0004_add_group_id.sql +npx wrangler d1 execute webhooker --remote --file ./migrations/0005_audit_logs.sql ``` ::: diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index 30831b4..fc46ede 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -26,6 +26,8 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars` | --------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------- | | `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` | | `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 | +| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 | +| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` | | `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 | | `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 | | `TELEGRAM_WEBHOOK_SECRET` | `POST /telegram/webhook` 验签密钥(X-Telegram-Bot-Api-Secret-Token) | 未设置时不校验 | @@ -48,26 +50,31 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 ### 设置 -1. 配置 `ADMIN_USER_IDS`,填写允许管理路由的 GitHub 用户 ID,也支持登录名,例如 `ADMIN_USER_IDS=12345,RhenCloud`。未设置时控制台禁用。 +1. 配置 `ADMIN_USER_IDS`,填写允许管理路由的 GitHub 用户 ID,也支持登录名,例如 `ADMIN_USER_IDS=12345,RhenCloud`。未设置时控制台禁用(除非开启 `ALLOW_SELF_SIGNUP`)。 2. 打开 `/admin` 并使用 GitHub 登录。 -3. 只有白名单中的用户会获得会话 Cookie;其他人收到 `403`。 +3. 没有任何权限的用户收到 `403`,除非开启 `ALLOW_SELF_SIGNUP=1`(自动获得个人分组)或通过分组[邀请链接](#邀请)加入。 ### 端点 -| 端点 | 说明 | -| ---------------------------------- | ---------------------------- | -| `GET /admin` | 配置控制台页面 | -| `GET /admin/login` | 开始 GitHub OAuth 登录 | -| `GET /admin/logout` | 销毁会话 | -| `GET /admin/api/me` | 当前会话、权限范围和分组 | -| `GET /admin/api/routes` | 列出路由(仅管理员) | -| `PUT /admin/api/routes` | 替换路由(仅管理员) | -| `GET /admin/api/groups` | 列出分组(按权限过滤) | -| `PUT /admin/api/groups` | 替换分组(仅超级管理员) | -| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | -| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由 | -| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | -| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | +| 端点 | 说明 | +| ------------------------------------- | ------------------------------------ | +| `GET /admin` | 配置控制台页面 | +| `GET /admin/login` | 开始 GitHub OAuth 登录 | +| `GET /admin/logout` | 销毁会话 | +| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | +| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | +| `GET /admin/api/routes` | 列出路由(按权限过滤) | +| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | +| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | +| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) | +| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | +| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) | +| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | +| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | +| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) | +| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) | +| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) | +| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | 控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。 @@ -167,29 +174,53 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 { "id": "backend-team", "name": "后端团队", - "adminIds": ["rhencloud"], + "members": [ + { "login": "rhencloud", "role": "owner" }, + { "login": "octobot", "role": "admin" }, + { "login": "reader", "role": "viewer" } + ], "owners": ["myorg"], "providers": ["github", "gitea"] } ``` -| 字段 | 类型 | 必需 | 说明 | -| ----------- | -------- | ---- | ----------------------------------------------------------- | -| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 | -| `name` | string | 是 | 可读的分组名称 | -| `adminIds` | string[] | 是 | 可管理该分组路由的 GitHub 用户 ID 或登录名 | -| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | -| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 | -| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) | +| 字段 | 类型 | 必需 | 说明 | +| ----------- | -------- | ---- | ------------------------------------------------------------------ | +| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 | +| `name` | string | 是 | 可读的分组名称 | +| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` | +| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | +| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | +| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 | +| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) | + +### 角色 + +每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。 + +| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 | +| -------- | ------------- | -------- | -------------- | ------------ | +| `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) | +| `admin` | ✓ | ✓ | ✗ | ✗ | +| `viewer` | ✓(只读) | ✗ | ✗ | ✗ | ### 权限模型 -- **超级管理员**(`ADMIN_USER_IDS`)可查看和编辑所有分组及全部路由。 -- **分组管理员**(`adminIds`)只能查看和编辑其管理的分组;提交其分组之外的路由返回 `403`。 +- **超级管理员**(`ADMIN_USER_IDS`)可查看和编辑所有分组及全部路由;只有他们能修改分组的 `owners` 列表。 +- **owner** 管理本组的路由、成员、邀请、名称、`emoji` 与 `providers`;不能移除最后一位 owner,也没有其他 owner 时不能把自己降级。 +- **admin** 可编辑本组路由并查看日志;**viewer** 只读控制台。 - 分组管理端点通过 `/admin/api/groups/:id/routes` 一次只操作一个分组;`groupId` 由路径参数强制指定。 - `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。 - `providers` 列表限定哪个 forge(`github`、`gitea`)的事件会被该分组的路由投递。即使组织/用户同名,也可以借此将 GitHub 与 Gitea 分组区分开。 +### 邀请 + +owner(及超级管理员)可在分组的「成员」面板创建一次性邀请链接,7 天内有效。接受邀请后用户以邀请角色(`admin` 或 `viewer`,绝不授予 `owner`)加入;已有的 `viewer` 会被升级为 `admin`。邀请存储在 KV `invite:{token}`。 + +### 自助注册 + +开启 `ALLOW_SELF_SIGNUP=1` 后,没有分组权限的 GitHub 用户首次登录会获得一个由自己担任 owner 的个人分组(`u-{userId}`),而不是 `403`。这是全自助 SaaS 部署的入口;关闭它则控制台保持仅邀请制。 + ## 过滤器类型 实操指南见[过滤器教程](./filters),包含完整示例。 @@ -230,6 +261,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 | `token:{userId}` | `{ userId, accessToken, expiresAt, refreshToken? }` | 0.9 × Token 有效期 | | `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 | +| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 | | `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 | | `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run`) | 7 天 | | `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 | @@ -239,10 +271,13 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 ## D1 存储布局 -D1 数据库(`DB` 绑定,数据库 `webhooker`)包含三张表: +D1 数据库(`DB` 绑定,数据库 `webhooker`)包含四张表: | 表 | 用途 | | ---------------- | ---------------------------------------------------------------------- | | `send_logs` | 每次分发尝试一行(路由 id、事件、目标、成功/失败、耗时、错误码、详情) | +| `audit_logs` | 每次管理操作一行(登录/登出、分组/路由/成员/邀请变更) | | `discord_links` | 映射 `discord_user_id` → `github_user_id`,用于 Discord `/gh` 命令 | | `telegram_links` | 映射 `telegram_user_id` → `github_user_id`,用于 Telegram `/gh` 命令 | + +`audit_logs` 由定时触发器按 `AUDIT_RETENTION_DAYS`(默认 90)自动清理。 diff --git a/docs/zh/guide/deployment.md b/docs/zh/guide/deployment.md index 2ad4c39..d6f67a8 100644 --- a/docs/zh/guide/deployment.md +++ b/docs/zh/guide/deployment.md @@ -80,6 +80,8 @@ npm run db:migrate # 将迁移应用到本地(Miniflare)数据库 npx wrangler d1 execute webhooker --remote --file ./migrations/0001_init.sql npx wrangler d1 execute webhooker --remote --file ./migrations/0002_log_detail.sql npx wrangler d1 execute webhooker --remote --file ./migrations/0003_telegram_links.sql +npx wrangler d1 execute webhooker --remote --file ./migrations/0004_add_group_id.sql +npx wrangler d1 execute webhooker --remote --file ./migrations/0005_audit_logs.sql ``` :::