diff --git a/docs/github_pages_strategy_switch.md b/docs/github_pages_strategy_switch.md index 60f713b..2f2a7ea 100644 --- a/docs/github_pages_strategy_switch.md +++ b/docs/github_pages_strategy_switch.md @@ -82,6 +82,9 @@ GITHUB_CLIENT_SECRET SESSION_SECRET RUNTIME_SETTINGS_DISPATCH_TOKEN ALLOWED_GITHUB_LOGINS +ALLOWED_GITHUB_ORGS +STRATEGY_SWITCH_ADMIN_LOGINS +STRATEGY_SWITCH_ADMIN_ORGS ``` GitHub Actions Environment secret: @@ -96,8 +99,8 @@ Do not reuse these tokens. `RUNTIME_SETTINGS_DISPATCH_TOKEN` only dispatches the 1. Merge `docs/index.html` and enable GitHub Pages from `/docs`. 2. Configure `Manual Strategy Switch` and `RUNTIME_SETTINGS_GH_TOKEN`. -3. Deploy the Worker with GitHub OAuth and `ALLOWED_GITHUB_LOGINS`. -4. Test dispatch with `apply=false`. +3. Deploy the Worker with GitHub OAuth, allowed users/orgs, and admin users/orgs. +4. Test sign-in, account dropdown loading, and workflow dispatch with a controlled account. 5. Test `apply=true` on a low-risk target. ## Why Pages Does Not Dispatch Directly diff --git a/docs/github_pages_strategy_switch.zh-CN.md b/docs/github_pages_strategy_switch.zh-CN.md index 7ba06b1..3ffad93 100644 --- a/docs/github_pages_strategy_switch.zh-CN.md +++ b/docs/github_pages_strategy_switch.zh-CN.md @@ -82,6 +82,9 @@ GITHUB_CLIENT_SECRET SESSION_SECRET RUNTIME_SETTINGS_DISPATCH_TOKEN ALLOWED_GITHUB_LOGINS +ALLOWED_GITHUB_ORGS +STRATEGY_SWITCH_ADMIN_LOGINS +STRATEGY_SWITCH_ADMIN_ORGS ``` GitHub Actions Environment secret: @@ -96,8 +99,8 @@ RUNTIME_SETTINGS_GH_TOKEN 1. 先合并 `docs/index.html`,启用 GitHub Pages,只发布只读控制台。 2. 配置 `Manual Strategy Switch` workflow 和 `RUNTIME_SETTINGS_GH_TOKEN`。 -3. 部署 Worker,配置 GitHub OAuth 和 `ALLOWED_GITHUB_LOGINS`。 -4. 用 `apply=false` 测试网页触发 workflow。 +3. 部署 Worker,配置 GitHub OAuth、允许登录用户/组织和管理员用户/组织。 +4. 先用受控账号测试登录、账号下拉和 workflow dispatch。 5. 再用低风险目标测试 `apply=true`。 ## 为什么不直接在 GitHub Pages 一键切换 diff --git a/docs/strategy_switch_admin_backend.md b/docs/strategy_switch_admin_backend.md index 5610a34..c4338fa 100644 --- a/docs/strategy_switch_admin_backend.md +++ b/docs/strategy_switch_admin_backend.md @@ -6,8 +6,8 @@ Goal: keep the open-source switch page public and read-only by default, while al - Login method: GitHub OAuth 2.0. - Public access: unsigned visitors can view the page, but cannot dispatch the workflow. -- Allowed switch users: `ALLOWED_GITHUB_LOGINS`, KV `auth_config.allowed_logins`, and all admins. -- Admin users: `STRATEGY_SWITCH_ADMIN_LOGINS` plus KV `auth_config.admin_logins`. +- Allowed switch users/orgs: `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, KV `auth_config.allowed_logins`, KV `auth_config.allowed_orgs`, and all admins. +- Admin users/orgs: `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, KV `auth_config.admin_logins`, and KV `auth_config.admin_orgs`. - Account dropdowns: KV `account_options` first, falling back to `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. - Audit log: each admin save appends to KV `audit_log`, capped at 50 entries. @@ -35,13 +35,14 @@ Without the KV binding, `/admin` is read-only and the Worker falls back to secre - Not signed in: public read-only page. - Signed in but not allowlisted: no switch and no admin page. -- Allowlisted: can dispatch switches. -- Admin-listed: can open `/admin` and manage allowed logins, admin logins, and account dropdown JSON. -- `STRATEGY_SWITCH_ADMIN_LOGINS` remains the break-glass admin source and is preserved on save. +- Allowlisted users or organization members: can dispatch switches. +- Admin users or admin organization members: can open `/admin` and manage allowed logins, allowed orgs, admin logins, admin orgs, and account dropdown JSON. +- `STRATEGY_SWITCH_ADMIN_LOGINS` and `STRATEGY_SWITCH_ADMIN_ORGS` remain break-glass admin sources and are preserved on save. ## Security Boundary -- The admin backend stores GitHub logins and account routing metadata only. +- The admin backend stores GitHub logins, GitHub organization names, and account routing metadata only. +- OAuth requests the `read:org` scope to verify membership in configured admin or allowlist organizations. - Broker passwords, tokens, API keys, and cloud credentials stay out of this config. - Admin writes use POST and same-origin checks. - Sessions use HttpOnly, Secure, SameSite=Lax, and HMAC-signed cookies. diff --git a/docs/strategy_switch_admin_backend.zh-CN.md b/docs/strategy_switch_admin_backend.zh-CN.md index 8735b03..d7b198b 100644 --- a/docs/strategy_switch_admin_backend.zh-CN.md +++ b/docs/strategy_switch_admin_backend.zh-CN.md @@ -6,8 +6,8 @@ - 登录方式:GitHub OAuth 2.0。 - 公开访问:未登录用户只能看到只读切换页,不能触发 workflow。 -- 可切换用户:来自 `ALLOWED_GITHUB_LOGINS`、KV `auth_config.allowed_logins` 和管理员名单。 -- 管理员:来自 `STRATEGY_SWITCH_ADMIN_LOGINS` 和 KV `auth_config.admin_logins`。 +- 可切换用户/组织:来自 `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、KV `auth_config.allowed_logins`、KV `auth_config.allowed_orgs` 和管理员配置。 +- 管理员用户/组织:来自 `STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS`、KV `auth_config.admin_logins` 和 KV `auth_config.admin_orgs`。 - 账号下拉:优先读取 KV `account_options`,没有 KV 配置时回退 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 - 审计:管理员保存配置后写入 KV `audit_log`,保留最近 50 条。 @@ -35,13 +35,14 @@ audit_log - 未登录:只能查看公开页面。 - 登录但不在 allowlist:不能切换,也不能进入后台。 -- allowlist 用户:可以一键切换。 -- admin 用户:可以进入 `/admin`,维护 allowlist、admin list 和账号下拉 JSON。 -- `STRATEGY_SWITCH_ADMIN_LOGINS` 是兜底管理员来源,后台保存时会自动保留,避免把自己锁在外面。 +- allowlist 用户或组织成员:可以一键切换。 +- admin 用户或管理员组织成员:可以进入 `/admin`,维护 allowlist、admin list、组织名单和账号下拉 JSON。 +- `STRATEGY_SWITCH_ADMIN_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_ORGS` 是兜底管理员来源,后台保存时会自动保留,避免把自己锁在外面。 ## 安全边界 -- 后台只保存 GitHub login 和账号路由信息。 +- 后台只保存 GitHub login、GitHub 组织名和账号路由信息。 +- OAuth 会请求 `read:org` scope,用于校验登录用户是否属于配置的管理员组织或 allowlist 组织。 - 不保存 broker 密码、token、API key 或云密钥。 - 后台写操作使用 POST,并校验 Same-Origin。 - session cookie 使用 HttpOnly、Secure、SameSite=Lax 和 HMAC 签名。 diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index e16a845..9469bf6 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -14,7 +14,9 @@ GITHUB_CLIENT_SECRET SESSION_SECRET RUNTIME_SETTINGS_DISPATCH_TOKEN ALLOWED_GITHUB_LOGINS +ALLOWED_GITHUB_ORGS STRATEGY_SWITCH_ADMIN_LOGINS +STRATEGY_SWITCH_ADMIN_ORGS ``` Optional variables: @@ -26,10 +28,11 @@ RUNTIME_SETTINGS_REF=main STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` -`ALLOWED_GITHUB_LOGINS` and `STRATEGY_SWITCH_ADMIN_LOGINS` are comma-separated lists: +`ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, and `STRATEGY_SWITCH_ADMIN_ORGS` are comma-separated lists. Prefer the organization name for admin access: ```text -your-github-login +STRATEGY_SWITCH_ADMIN_ORGS=QuantStrategyLab +STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login ``` The login entrypoint is `/login` on the Worker domain. The page header keeps a single Login Management entry. After sign-in, `/api/session` returns: @@ -43,11 +46,11 @@ The login entrypoint is `/login` on the Worker domain. The page header keeps a s } ``` -`admin=true` means the login is listed in `STRATEGY_SWITCH_ADMIN_LOGINS` or the KV-backed admin list. Open `/admin` to manage allowed GitHub logins and account dropdown routes; non-admin users receive 403. +`admin=true` means the login or one of its GitHub organizations is listed in `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, or the KV-backed admin config. Open `/admin` to manage allowed GitHub logins, organizations, and account dropdown routes; non-admin users receive 403. ## Admin Management -GitHub OAuth 2.0 is the only login method. Keep your own GitHub login in `STRATEGY_SWITCH_ADMIN_LOGINS`; that secret is the break-glass admin source and cannot be removed from the UI. +GitHub OAuth 2.0 is the only login method. The Worker requests the `read:org` scope to verify GitHub organization membership. Put `QuantStrategyLab` in `STRATEGY_SWITCH_ADMIN_ORGS`, and keep your own GitHub login in `STRATEGY_SWITCH_ADMIN_LOGINS` as a break-glass admin. For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWITCH_CONFIG`. The Worker uses these KV keys: @@ -57,7 +60,7 @@ account_options audit_log ``` -Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. +Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`. ## Page Asset @@ -130,7 +133,9 @@ wrangler secret put GITHUB_CLIENT_SECRET wrangler secret put SESSION_SECRET wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put ALLOWED_GITHUB_ORGS wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_ORGS wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index b9c03be..57c5375 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -16,7 +16,9 @@ GITHUB_CLIENT_SECRET SESSION_SECRET RUNTIME_SETTINGS_DISPATCH_TOKEN ALLOWED_GITHUB_LOGINS +ALLOWED_GITHUB_ORGS STRATEGY_SWITCH_ADMIN_LOGINS +STRATEGY_SWITCH_ADMIN_ORGS ``` 可选: @@ -28,10 +30,11 @@ RUNTIME_SETTINGS_REF=main STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` -`ALLOWED_GITHUB_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_LOGINS` 用英文逗号分隔,例如: +`ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_ORGS` 用英文逗号分隔。个人系统建议用组织名做管理员入口,例如: ```text -your-github-login +STRATEGY_SWITCH_ADMIN_ORGS=QuantStrategyLab +STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login ``` 登录入口是 Worker 域名下的 `/login`,页面顶部保留一个“登录管理”入口。登录成功后访问 `/api/session` 会返回: @@ -45,11 +48,11 @@ your-github-login } ``` -`admin=true` 表示该账号在 `STRATEGY_SWITCH_ADMIN_LOGINS` 或 KV 后台管理员名单中。直接访问 `/admin` 可以管理允许登录的 GitHub 用户和账号下拉路由;非管理员会返回 403。 +`admin=true` 表示该账号在 `STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS`,或 KV 后台管理员名单/组织中。直接访问 `/admin` 可以管理允许登录的 GitHub 用户、组织和账号下拉路由;非管理员会返回 403。 ## 登录管理后台 -登录方式使用 GitHub OAuth 2.0。建议把你自己的 GitHub login 放在 `STRATEGY_SWITCH_ADMIN_LOGINS`,它是兜底管理员来源,后台里不能把这个入口删掉。 +登录方式使用 GitHub OAuth 2.0,并请求 `read:org` scope 来校验 GitHub 组织成员关系。建议把 `QuantStrategyLab` 放在 `STRATEGY_SWITCH_ADMIN_ORGS`,同时把你自己的 GitHub login 放在 `STRATEGY_SWITCH_ADMIN_LOGINS` 作为兜底管理员。 如果要让 `/admin` 保存修改,需要绑定 Cloudflare KV namespace:`STRATEGY_SWITCH_CONFIG`。Worker 会使用这些 key: @@ -59,7 +62,7 @@ account_options audit_log ``` -没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`STRATEGY_SWITCH_ADMIN_LOGINS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 +没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 ## 文件结构 @@ -136,7 +139,9 @@ wrangler secret put GITHUB_CLIENT_SECRET wrangler secret put SESSION_SECRET wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put ALLOWED_GITHUB_ORGS wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_ORGS wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json ``` @@ -165,7 +170,7 @@ wrangler deploy 1. 访问控制台页面。 2. 未登录时只能查看公开示例,“一键切换”按钮禁用。 3. 点击“登录管理”,也可以直接访问 `/login`。 -4. 如果登录账号在 `ALLOWED_GITHUB_LOGINS` 或 `STRATEGY_SWITCH_ADMIN_LOGINS`,且账号配置已加载,按钮启用。 +4. 如果登录账号在 allowlist 用户/组织或管理员用户/组织中,且账号配置已加载,按钮启用。 5. 顶部只保留“登录管理”入口;如果登录账号是管理员,点击后进入 `/admin` 管理登录权限和账号下拉。 6. 选择平台、账号、策略和模式后点击“一键切换”。 7. 页面返回 GitHub Actions 链接,用于查看运行结果。 diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 6c5fc4a..93a66ec 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -41,7 +41,7 @@ async function startLogin(request, env) { const authorizeUrl = new URL("https://github.com/login/oauth/authorize"); authorizeUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID); authorizeUrl.searchParams.set("redirect_uri", `${url.origin}/callback`); - authorizeUrl.searchParams.set("scope", "read:user"); + authorizeUrl.searchParams.set("scope", "read:user read:org"); authorizeUrl.searchParams.set("state", state); return redirect(authorizeUrl.toString(), { "Set-Cookie": cookie(OAUTH_STATE_COOKIE, state, 600), @@ -89,11 +89,12 @@ async function finishLogin(request, env) { } const authConfig = await loadAuthConfig(env); - if (!isAllowedLogin(login, authConfig)) { - return html(renderMessage("没有权限", `${login} 不在允许登录名单中。`), 403, clearOAuthCookie()); + const orgLogins = await fetchGithubOrgLogins(tokenPayload.access_token); + if (!isAllowedPrincipal(login, orgLogins, authConfig)) { + return html(renderMessage("没有权限", `${login} 不在允许登录名单或组织中。`), 403, clearOAuthCookie()); } - const session = await makeSession(login, env); + const session = await makeSession(login, authorizedOrgLogins(orgLogins, authConfig), env); return redirect("/", { "Set-Cookie": [ cookie(SESSION_COOKIE, session, SESSION_TTL_SECONDS), @@ -141,15 +142,21 @@ async function saveAdminConfig(request, env) { return json({ ok: false, error: "request body must be valid JSON" }, 400); } const bootstrapAdmins = parseLoginList(env.STRATEGY_SWITCH_ADMIN_LOGINS || "", "STRATEGY_SWITCH_ADMIN_LOGINS"); + const bootstrapAdminOrgs = parseOrgList(env.STRATEGY_SWITCH_ADMIN_ORGS || "", "STRATEGY_SWITCH_ADMIN_ORGS"); const allowedLogins = normalizeLoginList(raw.allowed_logins, "allowed_logins"); + const allowedOrgs = normalizeOrgList(raw.allowed_orgs, "allowed_orgs"); const submittedAdmins = normalizeLoginList(raw.admin_logins, "admin_logins"); + const submittedAdminOrgs = normalizeOrgList(raw.admin_orgs, "admin_orgs"); const effectiveAdmins = uniqueStrings([...bootstrapAdmins, ...submittedAdmins]); - if (!effectiveAdmins.includes(session.login)) { - throw new Error("current admin login must remain in admin_logins"); + const effectiveAdminOrgs = uniqueStrings([...bootstrapAdminOrgs, ...submittedAdminOrgs]); + if (!effectiveAdmins.includes(session.login) && !hasOrgMatch(session.orgs, effectiveAdminOrgs)) { + throw new Error("current admin login or org must remain in admin config"); } const authConfig = { allowed_logins: uniqueStrings([...allowedLogins, ...effectiveAdmins]), + allowed_orgs: allowedOrgs, admin_logins: effectiveAdmins, + admin_orgs: effectiveAdminOrgs, }; const accountOptions = normalizeAccountOptionsInput(raw.account_options, "account_options"); @@ -160,7 +167,9 @@ async function saveAdminConfig(request, env) { login: session.login, action: "save_config", allowed_count: authConfig.allowed_logins.length, + allowed_org_count: authConfig.allowed_orgs.length, admin_count: authConfig.admin_logins.length, + admin_org_count: authConfig.admin_orgs.length, account_counts: accountCounts(accountOptions), }); return json(await buildAdminState(session, env)); @@ -307,16 +316,24 @@ async function renderAdminPage(state) {

登录权限 / Login Access

-

每行一个 GitHub 用户名。管理员会自动拥有切换权限;secret 里的管理员始终保留为兜底入口。

+

每行一个 GitHub 用户名或组织名。管理员会自动拥有切换权限;secret 里的管理员和管理员组织始终保留为兜底入口。

+ +
@@ -372,7 +389,9 @@ async function renderAdminPage(state) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ allowed_logins: parseLogins(document.getElementById("allowed-logins").value), + allowed_orgs: parseLogins(document.getElementById("allowed-orgs").value), admin_logins: parseLogins(document.getElementById("admin-logins").value), + admin_orgs: parseLogins(document.getElementById("admin-orgs").value), account_options: accountOptions, }), }); @@ -668,35 +687,68 @@ function githubHeaders(token) { }; } +async function fetchGithubOrgLogins(token) { + const orgs = []; + for (let page = 1; page <= 5; page += 1) { + const response = await fetch(`https://api.github.com/user/orgs?per_page=100&page=${page}`, { + headers: githubHeaders(token), + }); + if (!response.ok) return orgs; + const payload = await response.json(); + if (!Array.isArray(payload) || !payload.length) break; + for (const org of payload) { + const login = cleanGithubOrg(org?.login || "", "github org"); + if (login) orgs.push(login); + } + if (payload.length < 100) break; + } + return uniqueStrings(orgs); +} + function requireEnv(env, name) { if (!env[name]) throw new Error(`${name} is not configured`); } async function loadAuthConfig(env) { const bootstrapAdmins = parseLoginList(env.STRATEGY_SWITCH_ADMIN_LOGINS || "", "STRATEGY_SWITCH_ADMIN_LOGINS"); + const bootstrapAdminOrgs = parseOrgList(env.STRATEGY_SWITCH_ADMIN_ORGS || "", "STRATEGY_SWITCH_ADMIN_ORGS"); const envAllowed = parseLoginList( env.ALLOWED_GITHUB_LOGINS || env.ALLOWED_GITHUB_LOGIN || "", "ALLOWED_GITHUB_LOGINS", ); + const envAllowedOrgs = parseOrgList( + env.ALLOWED_GITHUB_ORGS || env.ALLOWED_GITHUB_ORG || "", + "ALLOWED_GITHUB_ORGS", + ); let storedAllowed = []; + let storedAllowedOrgs = []; let storedAdmins = []; + let storedAdminOrgs = []; let source = "secret"; if (hasConfigStore(env)) { const stored = await readConfigJson(env, AUTH_CONFIG_KEY); if (stored) { const normalized = normalizeAuthConfigPayload(stored, AUTH_CONFIG_KEY); storedAllowed = normalized.allowed_logins; + storedAllowedOrgs = normalized.allowed_orgs; storedAdmins = normalized.admin_logins; + storedAdminOrgs = normalized.admin_orgs; source = "kv"; } } const adminLogins = uniqueStrings([...bootstrapAdmins, ...storedAdmins]); + const adminOrgs = uniqueStrings([...bootstrapAdminOrgs, ...storedAdminOrgs]); const allowedLogins = uniqueStrings([...envAllowed, ...storedAllowed, ...adminLogins]); + const allowedOrgs = uniqueStrings([...envAllowedOrgs, ...storedAllowedOrgs]); return { allowed_logins: allowedLogins, + allowed_orgs: allowedOrgs, admin_logins: adminLogins, + admin_orgs: adminOrgs, bootstrap_admin_logins: bootstrapAdmins, + bootstrap_admin_orgs: bootstrapAdminOrgs, env_allowed_logins: envAllowed, + env_allowed_orgs: envAllowedOrgs, source, kv_available: hasConfigStore(env), }; @@ -708,7 +760,9 @@ function normalizeAuthConfigPayload(payload, fieldName) { } return { allowed_logins: normalizeLoginList(payload.allowed_logins || [], `${fieldName}.allowed_logins`), + allowed_orgs: normalizeOrgList(payload.allowed_orgs || [], `${fieldName}.allowed_orgs`), admin_logins: normalizeLoginList(payload.admin_logins || [], `${fieldName}.admin_logins`), + admin_orgs: normalizeOrgList(payload.admin_orgs || [], `${fieldName}.admin_orgs`), }; } @@ -722,6 +776,16 @@ function normalizeLoginList(value, fieldName) { return uniqueStrings(items.map((item) => cleanGithubLogin(item, fieldName)).filter(Boolean)); } +function parseOrgList(value, fieldName) { + return normalizeOrgList(value, fieldName); +} + +function normalizeOrgList(value, fieldName) { + const items = Array.isArray(value) ? value : String(value || "").split(/[\s,]+/); + if (items.length > 80) throw new Error(`${fieldName} supports at most 80 orgs`); + return uniqueStrings(items.map((item) => cleanGithubOrg(item, fieldName)).filter(Boolean)); +} + function cleanGithubLogin(value, fieldName) { const login = String(value || "").trim().toLowerCase(); if (!login) return ""; @@ -735,12 +799,35 @@ function cleanGithubLogin(value, fieldName) { return login; } -function isAdminLogin(login, authConfig) { +function cleanGithubOrg(value, fieldName) { + return cleanGithubLogin(value, fieldName); +} + +function isAdminLogin(login, orgLogins, authConfig) { return authConfig.admin_logins.includes(String(login || "").toLowerCase()); } -function isAllowedLogin(login, authConfig) { - return authConfig.allowed_logins.includes(String(login || "").toLowerCase()) || isAdminLogin(login, authConfig); +function isAdminPrincipal(login, orgLogins, authConfig) { + return isAdminLogin(login, orgLogins, authConfig) || hasOrgMatch(orgLogins, authConfig.admin_orgs); +} + +function isAllowedPrincipal(login, orgLogins, authConfig) { + const normalizedLogin = String(login || "").toLowerCase(); + return ( + authConfig.allowed_logins.includes(normalizedLogin) || + hasOrgMatch(orgLogins, authConfig.allowed_orgs) || + isAdminPrincipal(normalizedLogin, orgLogins, authConfig) + ); +} + +function authorizedOrgLogins(orgLogins, authConfig) { + const authorized = new Set([...authConfig.allowed_orgs, ...authConfig.admin_orgs]); + return uniqueStrings(orgLogins).filter((org) => authorized.has(org)); +} + +function hasOrgMatch(orgLogins, configuredOrgs) { + const orgs = new Set(uniqueStrings(orgLogins)); + return configuredOrgs.some((org) => orgs.has(String(org || "").toLowerCase())); } async function loadAccountOptionsConfig(env) { @@ -827,9 +914,10 @@ function uniqueStrings(items) { return result; } -async function makeSession(login, env) { +async function makeSession(login, orgs, env) { const payload = base64UrlEncodeJson({ login, + orgs: uniqueStrings(orgs), exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, }); const signature = await hmac(payload, env.SESSION_SECRET); @@ -847,9 +935,10 @@ async function readSession(request, env) { const session = JSON.parse(base64UrlDecode(payload)); if (!session.exp || session.exp < Math.floor(Date.now() / 1000)) return null; const login = String(session.login || "").toLowerCase(); + const orgs = normalizeOrgList(session.orgs || [], "session.orgs"); const authConfig = await loadAuthConfig(env); - const admin = isAdminLogin(login, authConfig); - return { login, allowed: isAllowedLogin(login, authConfig), admin }; + const admin = isAdminPrincipal(login, orgs, authConfig); + return { login, orgs, allowed: isAllowedPrincipal(login, orgs, authConfig), admin }; } async function hmac(value, secret) { diff --git a/web/strategy-switch-console/wrangler.toml.example b/web/strategy-switch-console/wrangler.toml.example index b315466..6059362 100644 --- a/web/strategy-switch-console/wrangler.toml.example +++ b/web/strategy-switch-console/wrangler.toml.example @@ -9,7 +9,9 @@ workers_dev = true # - SESSION_SECRET # - RUNTIME_SETTINGS_DISPATCH_TOKEN # - ALLOWED_GITHUB_LOGINS +# - ALLOWED_GITHUB_ORGS # - STRATEGY_SWITCH_ADMIN_LOGINS +# - STRATEGY_SWITCH_ADMIN_ORGS # - STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON # # Optional non-secret variables can be placed here or in the Cloudflare dashboard: