diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 1fd2b432b..44e4c7537 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -204,6 +204,7 @@ jobs: TF_VAR_modal_api_secret: ${{ secrets.MODAL_API_SECRET }} TF_VAR_allowed_users: ${{ secrets.ALLOWED_USERS }} TF_VAR_allowed_email_domains: ${{ secrets.ALLOWED_EMAIL_DOMAINS }} + TF_VAR_allowed_github_orgs: ${{ secrets.ALLOWED_GITHUB_ORGS }} TF_VAR_deployment_name: ${{ secrets.DEPLOYMENT_NAME }} TF_VAR_enable_github_bot: "${{ secrets.ENABLE_GITHUB_BOT || 'false' }}" TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -335,6 +336,7 @@ jobs: TF_VAR_modal_api_secret: ${{ secrets.MODAL_API_SECRET }} TF_VAR_allowed_users: ${{ secrets.ALLOWED_USERS }} TF_VAR_allowed_email_domains: ${{ secrets.ALLOWED_EMAIL_DOMAINS }} + TF_VAR_allowed_github_orgs: ${{ secrets.ALLOWED_GITHUB_ORGS }} TF_VAR_deployment_name: ${{ secrets.DEPLOYMENT_NAME }} TF_VAR_enable_github_bot: "${{ secrets.ENABLE_GITHUB_BOT || 'false' }}" TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }} diff --git a/README.md b/README.md index cdf5e7826..e18007a53 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ built for internal use where all employees are trusted and have access to compan web interface 2. **Install GitHub App only on intended repositories** - The App's installation scope defines what the system can access -3. **Use GitHub's repository selection** - When installing the App, select specific repositories +3. **Restrict sign-in** - Configure allowed GitHub users, email domains, or active GitHub + organization membership (`ALLOWED_GITHUB_ORGS`) +4. **Use GitHub's repository selection** - When installing the App, select specific repositories rather than "All repositories" ## Architecture diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 7d3890716..8ef011bd8 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -215,21 +215,25 @@ access. - Issues: **Read & Write** _(required if enabling GitHub bot)_ - Pull requests: **Read & Write** - Metadata: **Read-only** -6. Click **"Create GitHub App"** -7. Note the **App ID** and **Client ID** (top of page) -8. Under **"Client secrets"**, click **"Generate a new client secret"** and note the **Client +6. If using `ALLOWED_GITHUB_ORGS`/`allowed_github_orgs`, set **Organization permissions**: + - Members: **Read-only** + - For existing GitHub Apps, republish the permission change and request/approve installation + updates before testing org membership sign-in. +7. Click **"Create GitHub App"** +8. Note the **App ID** and **Client ID** (top of page) +9. Under **"Client secrets"**, click **"Generate a new client secret"** and note the **Client Secret** -9. Scroll down to **"Private keys"** and click **"Generate a private key"** (downloads a .pem file) -10. **Convert the key to PKCS#8 format** (required for Cloudflare Workers): +10. Scroll down to **"Private keys"** and click **"Generate a private key"** (downloads a .pem file) +11. **Convert the key to PKCS#8 format** (required for Cloudflare Workers): ```bash openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \ -in ~/Downloads/your-app-name.*.private-key.pem \ -out private-key-pkcs8.pem ``` -11. **Install the app** on your account/organization: +12. **Install the app** on your account/organization: - Click "Install App" in the sidebar - Select the repositories you want Open-Inspect to access -12. Note the **Installation ID** from the URL after installing: +13. Note the **Installation ID** from the URL after installing: ``` https://github.com/settings/installations/INSTALLATION_ID ``` @@ -416,15 +420,20 @@ enable_service_bindings = false # Access Control (set at least one allowlist for production) allowed_users = "your-github-username" # Comma-separated GitHub usernames, or empty allowed_email_domains = "" # Comma-separated domains (e.g., "example.com,corp.io") +allowed_github_orgs = "" # Comma-separated orgs whose active members can sign in # Explicitly opt into open access only if you want any authenticated GitHub user -# to be able to sign in when both allowlists are empty. +# to be able to sign in when all allowlists are empty. unsafe_allow_all_users = false ``` -> **Note**: Review `allowed_users` and `allowed_email_domains` carefully - these control who can -> sign in. Terraform now fails if both are empty unless you explicitly set -> `unsafe_allow_all_users = true`. +> **Note**: Review `allowed_users`, `allowed_email_domains`, and `allowed_github_orgs` carefully - +> these control who can sign in. Terraform now fails if all are empty unless you explicitly set +> `unsafe_allow_all_users = true`. **Allowlists use OR semantics**: matching any configured +> username, email domain, or active GitHub org membership grants access. `allowed_github_orgs` +> checks membership at sign-in only with the signing-in user's OAuth token; existing sessions last +> until session expiry. The `read:org` OAuth scope is requested only when org access is configured. +> GitHub Apps using org access need Organization permissions: Members read-only. --- @@ -683,8 +692,9 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `INTERNAL_CALLBACK_SECRET` | Generated callback secret | | `MODAL_API_SECRET` | Generated Modal API secret | | `NEXTAUTH_SECRET` | Generated NextAuth secret | -| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) | -| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) | +| `ALLOWED_USERS` | Comma-separated GitHub usernames allowed to sign in | +| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains allowed to sign in | +| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | | `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | | `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | | `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index b64a98efe..5c50772bc 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -90,8 +90,14 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane-..workers.d INTERNAL_CALLBACK_SECRET=your_shared_secret # Optional access control +# Allowlists are OR-based: matching any configured user, email domain, or GitHub org grants access. +# ALLOWED_GITHUB_ORGS requests read:org only when set, then checks active org membership +# with the signing-in user's OAuth token. Existing sessions last until session expiry. +# Requires GitHub App Organization permissions: Members read-only. ALLOWED_USERS= ALLOWED_EMAIL_DOMAINS= +ALLOWED_GITHUB_ORGS= +UNSAFE_ALLOW_ALL_USERS=false # Optional whitelabel branding (defaults shown). NEXT_PUBLIC_* vars are # inlined into the client bundle at build time — restart `npm run dev` @@ -212,7 +218,10 @@ Your GitHub callback URL does not exactly match the running app URL. ### Access denied after sign-in -Check `ALLOWED_USERS` and `ALLOWED_EMAIL_DOMAINS` in `packages/web/.env.local`. +Check `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` in +`packages/web/.env.local`. If `ALLOWED_GITHUB_ORGS` is set, make sure your GitHub App has +Organization permissions: Members read-only and that the updated permission was republished and +approved for the installation. ### Web can load, but session APIs return 401 diff --git a/packages/web/.env.example b/packages/web/.env.example index 5846fe893..fbe749d6f 100644 --- a/packages/web/.env.example +++ b/packages/web/.env.example @@ -14,6 +14,12 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev # Generate with: openssl rand -base64 32 INTERNAL_CALLBACK_SECRET= -# Access Control (comma-separated, leave empty to allow all GitHub users) +# Access Control (comma-separated; configure at least one allowlist unless UNSAFE_ALLOW_ALL_USERS=true) +# **OR-based**: matching any configured user, email domain, or GitHub org grants access. +# ALLOWED_GITHUB_ORGS requests read:org only when set, then checks active org membership +# with the signing-in user's OAuth token. Existing sessions last until session expiry. +# Requires GitHub App Organization permissions: Members read-only. ALLOWED_EMAIL_DOMAINS= ALLOWED_USERS= +ALLOWED_GITHUB_ORGS= +UNSAFE_ALLOW_ALL_USERS=false diff --git a/packages/web/README.md b/packages/web/README.md index 8ccfccbc1..3224bcea0 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -84,17 +84,23 @@ NEXTAUTH_SECRET=your_random_secret # Generate: openssl rand -base64 32 # Access Control ALLOWED_USERS=username1,username2 # Comma-separated GitHub usernames ALLOWED_EMAIL_DOMAINS=example.com,corp.io # Comma-separated email domains -UNSAFE_ALLOW_ALL_USERS=false # Set true to explicitly allow all users when both lists are empty +ALLOWED_GITHUB_ORGS=acme,umbrella # Comma-separated GitHub orgs with active members allowed +UNSAFE_ALLOW_ALL_USERS=false # Set true to explicitly allow all users when all lists are empty # Control Plane CONTROL_PLANE_URL=http://localhost:8787 NEXT_PUBLIC_WS_URL=ws://localhost:8787 ``` -> **Access Control**: If both `ALLOWED_USERS` and `ALLOWED_EMAIL_DOMAINS` are empty, sign-in is -> denied unless `UNSAFE_ALLOW_ALL_USERS=true`. For Terraform-managed production deploys, Terraform -> also fails validation unless you set at least one allowlist or explicitly opt in with -> `unsafe_allow_all_users = true`. +> **Access Control**: If `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` are all +> empty, sign-in is denied unless `UNSAFE_ALLOW_ALL_USERS=true`. For Terraform-managed production +> deploys, Terraform also fails validation unless you set at least one allowlist or explicitly opt +> in with `unsafe_allow_all_users = true`. **Allowlists use OR semantics**: matching any configured +> username, email domain, or active GitHub org membership grants access. `ALLOWED_GITHUB_ORGS` is +> checked at sign-in with the signing-in user's OAuth token; existing sessions last until session +> expiry. The `read:org` OAuth scope is requested only when `ALLOWED_GITHUB_ORGS` is configured. +> GitHub Apps using org access need Organization permissions: Members read-only; existing GitHub +> Apps must republish/request approval after that permission changes. ### Development diff --git a/packages/web/src/lib/access-control.test.ts b/packages/web/src/lib/access-control.test.ts index a4bfb61fd..ee872810e 100644 --- a/packages/web/src/lib/access-control.test.ts +++ b/packages/web/src/lib/access-control.test.ts @@ -1,5 +1,16 @@ -import { describe, it, expect } from "vitest"; -import { parseAllowlist, parseBooleanEnv, checkAccessAllowed } from "./access-control"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { + parseAllowlist, + parseBooleanEnv, + checkAccessAllowed, + checkGitHubOrganizationAccess, + getAccessAllowReason, +} from "./access-control"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); describe("parseAllowlist", () => { it("returns empty array for undefined", () => { @@ -65,6 +76,28 @@ describe("checkAccessAllowed", () => { }); }); + describe("when allowedOrganizations is set", () => { + const config = { + allowedDomains: [], + allowedUsers: [], + allowedOrganizations: ["Acme"], + unsafeAllowAllUsers: false, + }; + + it("allows users with active org membership", () => { + expect(checkAccessAllowed(config, { activeOrganizations: ["acme"] })).toBe(true); + }); + + it("allows users with different org case", () => { + expect(checkAccessAllowed(config, { activeOrganizations: ["Acme"] })).toBe(true); + }); + + it("denies users without matching active org membership", () => { + expect(checkAccessAllowed(config, { activeOrganizations: ["other"] })).toBe(false); + expect(checkAccessAllowed(config, {})).toBe(false); + }); + }); + describe("when allowedUsers is set", () => { const config = { allowedDomains: [], @@ -157,6 +190,29 @@ describe("checkAccessAllowed", () => { }); }); + describe("when allowedUsers, allowedDomains, and allowedOrganizations are set (OR logic)", () => { + const config = { + allowedDomains: ["company.com"], + allowedUsers: ["specialuser"], + allowedOrganizations: ["acme"], + unsafeAllowAllUsers: false, + }; + + it("allows users matching org membership", () => { + expect(checkAccessAllowed(config, { activeOrganizations: ["acme"] })).toBe(true); + }); + + it("denies users matching none of the configured policies", () => { + expect( + checkAccessAllowed(config, { + githubUsername: "randomuser", + email: "user@other.com", + activeOrganizations: ["other"], + }) + ).toBe(false); + }); + }); + describe("when unsafeAllowAllUsers is true with populated allowlists", () => { const config = { allowedDomains: ["company.com"], @@ -173,6 +229,19 @@ describe("checkAccessAllowed", () => { expect(checkAccessAllowed(config, { githubUsername: "randomuser" })).toBe(false); expect(checkAccessAllowed(config, { email: "user@other.com" })).toBe(false); }); + + it("does not bypass populated organization allowlists", () => { + const orgConfig = { + allowedDomains: [], + allowedUsers: [], + allowedOrganizations: ["acme"], + unsafeAllowAllUsers: true, + }; + + expect(checkAccessAllowed(orgConfig, {})).toBe(false); + expect(checkAccessAllowed(orgConfig, { activeOrganizations: ["other"] })).toBe(false); + expect(checkAccessAllowed(orgConfig, { activeOrganizations: ["acme"] })).toBe(true); + }); }); describe("multiple values in allowlists", () => { @@ -193,3 +262,306 @@ describe("checkAccessAllowed", () => { }); }); }); + +describe("getAccessAllowReason", () => { + it("returns the matching allow reason", () => { + expect( + getAccessAllowReason( + { allowedDomains: [], allowedUsers: ["alice"], unsafeAllowAllUsers: false }, + { githubUsername: "Alice" } + ) + ).toBe("username_allowlist"); + + expect( + getAccessAllowReason( + { allowedDomains: ["company.com"], allowedUsers: [], unsafeAllowAllUsers: false }, + { email: "user@company.com" } + ) + ).toBe("email_domain_allowlist"); + + expect( + getAccessAllowReason( + { + allowedDomains: [], + allowedUsers: [], + allowedOrganizations: ["acme"], + unsafeAllowAllUsers: false, + }, + { activeOrganizations: ["Acme"] } + ) + ).toBe("org_membership"); + }); +}); + +describe("checkGitHubOrganizationAccess", () => { + it("returns true when any configured organization membership is active", async () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "pending" }))) + .mockResolvedValueOnce( + new Response(JSON.stringify({ state: "active" })) + ) as unknown as typeof fetch; + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["pending-org", "active-org"], + fetchImpl, + userAgent: "Test App", + }) + ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.github.com/user/memberships/orgs/active-org", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Test App", + }) as HeadersInit, + }) + ); + }); + + it("returns early after the first active membership", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["active-org", "other-org"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("returns false for pending membership", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "pending" }))); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "not_member" }); + + expect(info).toHaveBeenCalledWith( + "[github-org-access] membership not active", + expect.objectContaining({ + org: "acme", + state: "pending", + elapsedMs: expect.any(Number), + }) + ); + }); + + it("returns not_member for denied GitHub responses", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn( + async () => + new Response("Not Found", { + status: 404, + headers: { + "x-github-request-id": "github-request-id", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1710000000", + }, + }) + ); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "not_member" }); + + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership request failed", + expect.objectContaining({ + org: "acme", + status: 404, + requestId: "github-request-id", + rateLimitLimit: "60", + rateLimitRemaining: "59", + rateLimitReset: "1710000000", + elapsedMs: expect.any(Number), + hint: expect.any(String), + }) + ); + }); + + it("returns unavailable for operational GitHub responses", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn( + async () => + new Response("rate limited", { + status: 429, + headers: { + "x-github-request-id": "github-request-id", + "x-ratelimit-remaining": "0", + "retry-after": "30", + }, + }) + ); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership request failed", + expect.objectContaining({ + org: "acme", + status: 429, + requestId: "github-request-id", + rateLimitRemaining: "0", + retryAfter: "30", + elapsedMs: expect.any(Number), + }) + ); + }); + + it("returns false without an access token or org allowlist", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect( + checkGitHubOrganizationAccess({ accessToken: undefined, allowedOrganizations: ["acme"] }) + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + + await expect( + checkGitHubOrganizationAccess({ accessToken: "token", allowedOrganizations: [] }) + ).resolves.toEqual({ allowed: false, reason: "not_member" }); + + expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { + reason: "missing_access_token", + organizationCount: 1, + }); + }); + + it("URL-encodes organization names", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); + + await checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme labs"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.github.com/user/memberships/orgs/acme%20labs", + expect.any(Object) + ); + }); + + it("logs missing membership state", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: null }))); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership response missing state", + expect.objectContaining({ + org: "acme", + state: null, + elapsedMs: expect.any(Number), + }) + ); + }); + + it("logs unexpected membership state", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "unknown" }))); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership response unexpected state", + expect.objectContaining({ + org: "acme", + state: "unknown", + elapsedMs: expect.any(Number), + }) + ); + }); + + it("returns unavailable for malformed membership responses", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => new Response("not-json")); + + await expect( + checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership request error", + expect.objectContaining({ + org: "acme", + error: expect.any(String), + message: expect.any(String), + elapsedMs: expect.any(Number), + }) + ); + }); + + it("aborts timed out membership requests", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }) + ) as unknown as typeof fetch; + + const result = checkGitHubOrganizationAccess({ + accessToken: "token", + allowedOrganizations: ["acme"], + fetchImpl, + timeoutMs: 50, + }); + + await vi.advanceTimersByTimeAsync(50); + await expect(result).resolves.toEqual({ allowed: false, reason: "unavailable" }); + expect(warn).toHaveBeenCalledWith( + "[github-org-access] membership request error", + expect.objectContaining({ + org: "acme", + error: "AbortError", + message: "Aborted", + elapsedMs: expect.any(Number), + }) + ); + }); +}); diff --git a/packages/web/src/lib/access-control.ts b/packages/web/src/lib/access-control.ts index 7e5e1c0f6..128fab299 100644 --- a/packages/web/src/lib/access-control.ts +++ b/packages/web/src/lib/access-control.ts @@ -1,14 +1,43 @@ export interface AccessControlConfig { allowedDomains: string[]; allowedUsers: string[]; + allowedOrganizations?: string[]; unsafeAllowAllUsers: boolean; } export interface AccessCheckParams { githubUsername?: string; email?: string; + activeOrganizations?: string[]; } +export interface GitHubOrganizationAccessParams { + accessToken?: string; + allowedOrganizations: string[]; + fetchImpl?: typeof fetch; + userAgent?: string; + timeoutMs?: number; +} + +export const GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS = 10_000; + +export type GitHubOrganizationAccessResult = + | { + allowed: true; + reason: "active_membership"; + organization: string; + } + | { + allowed: false; + reason: "not_member" | "unavailable"; + }; + +export type AccessAllowReason = + | "unsafe_allow_all" + | "username_allowlist" + | "email_domain_allowlist" + | "org_membership"; + /** * Parse comma-separated environment variable into a lowercase, trimmed array */ @@ -28,9 +57,10 @@ export function parseBooleanEnv(value: string | undefined): boolean { * Check if a user is allowed to sign in based on access control configuration. * * Returns true if: - * - Both allowlists are empty and unsafeAllowAllUsers is true + * - All allowlists are empty and unsafeAllowAllUsers is true * - User's GitHub username is in allowedUsers * - User's email domain is in allowedDomains + * - User has active membership in an allowed GitHub organization * * Logic is OR-based: matching either list grants access. */ @@ -38,26 +68,187 @@ export function checkAccessAllowed( config: AccessControlConfig, params: AccessCheckParams ): boolean { + return getAccessAllowReason(config, params) !== null; +} + +export function getAccessAllowReason( + config: AccessControlConfig, + params: AccessCheckParams +): AccessAllowReason | null { const { allowedDomains, allowedUsers, unsafeAllowAllUsers } = config; - const { githubUsername, email } = params; + const allowedOrganizations = (config.allowedOrganizations ?? []).map((org) => org.toLowerCase()); + const { githubUsername, email, activeOrganizations } = params; // Empty allowlists only permit sign-in when explicitly enabled. - if (allowedDomains.length === 0 && allowedUsers.length === 0) { - return unsafeAllowAllUsers; + if ( + allowedDomains.length === 0 && + allowedUsers.length === 0 && + allowedOrganizations.length === 0 + ) { + return unsafeAllowAllUsers ? "unsafe_allow_all" : null; } // Check explicit user allowlist (GitHub username) if (githubUsername && allowedUsers.includes(githubUsername.toLowerCase())) { - return true; + return "username_allowlist"; } // Check email domain allowlist if (email) { const domain = email.toLowerCase().split("@")[1]; if (domain && allowedDomains.includes(domain)) { - return true; + return "email_domain_allowlist"; + } + } + + // Check GitHub organization membership allowlist + if (activeOrganizations) { + const normalizedActiveOrganizations = activeOrganizations.map((org) => org.toLowerCase()); + if (allowedOrganizations.some((org) => normalizedActiveOrganizations.includes(org))) { + return "org_membership"; } } - return false; + return null; +} + +/** + * Check if a GitHub user access token belongs to at least one allowed organization. + */ +export async function checkGitHubOrganizationAccess({ + accessToken, + allowedOrganizations, + fetchImpl = fetch, + userAgent = "Open-Inspect", + timeoutMs = GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS, +}: GitHubOrganizationAccessParams): Promise { + if (allowedOrganizations.length === 0) { + return { allowed: false, reason: "not_member" }; + } + + if (!accessToken) { + console.warn("[github-org-access] membership check skipped", { + reason: "missing_access_token", + organizationCount: allowedOrganizations.length, + }); + return { allowed: false, reason: "unavailable" }; + } + + let isUnavailable = false; + + for (const org of allowedOrganizations) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = performance.now(); + + try { + const response = await fetchImpl( + `https://api.github.com/user/memberships/orgs/${encodeURIComponent(org)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": userAgent, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + console.warn("[github-org-access] membership request failed", { + org, + status: response.status, + ...getGitHubResponseDiagnostics(response, startedAt), + hint: getGitHubMembershipFailureHint(response.status), + }); + if (isGitHubMembershipUnavailableStatus(response.status)) { + isUnavailable = true; + } + continue; + } + + const membership = (await response.json()) as { state?: string | null }; + if (membership.state === "active") { + return { allowed: true, reason: "active_membership", organization: org }; + } + + if (membership.state == null) { + isUnavailable = true; + console.warn("[github-org-access] membership response missing state", { + org, + state: membership.state ?? null, + ...getGitHubResponseDiagnostics(response, startedAt), + }); + } else if (membership.state === "pending") { + console.info("[github-org-access] membership not active", { + org, + state: membership.state, + ...getGitHubResponseDiagnostics(response, startedAt), + }); + } else { + isUnavailable = true; + console.warn("[github-org-access] membership response unexpected state", { + org, + state: membership.state, + ...getGitHubResponseDiagnostics(response, startedAt), + }); + } + } catch (error) { + isUnavailable = true; + console.warn("[github-org-access] membership request error", { + org, + error: error instanceof Error ? error.name : "unknown", + message: error instanceof Error ? error.message : String(error), + elapsedMs: getElapsedMs(startedAt), + }); + } finally { + clearTimeout(timeout); + } + } + + return { allowed: false, reason: isUnavailable ? "unavailable" : "not_member" }; +} + +function getGitHubMembershipFailureHint(status: number): string | undefined { + if (status === 401) { + return "GitHub rejected the OAuth token while checking organization membership."; + } + + if (status === 403) { + return "Verify the GitHub OAuth token has read:org access and any organization SAML requirements are satisfied. If this deployment also uses a GitHub App, make sure membership read permission changes were republished and approved."; + } + + if (status === 429) { + return "GitHub rate limited the organization membership check."; + } + + if (status === 404) { + return "GitHub returns 404 when the user is not an organization member or the token cannot read that membership."; + } + + if (status >= 500) { + return "GitHub returned a server error while checking organization membership."; + } + + return undefined; +} + +function isGitHubMembershipUnavailableStatus(status: number): boolean { + return status !== 404; +} + +function getGitHubResponseDiagnostics(response: Response, startedAt: number) { + return { + requestId: response.headers.get("x-github-request-id"), + rateLimitLimit: response.headers.get("x-ratelimit-limit"), + rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), + rateLimitReset: response.headers.get("x-ratelimit-reset"), + retryAfter: response.headers.get("retry-after"), + elapsedMs: getElapsedMs(startedAt), + }; +} + +function getElapsedMs(startedAt: number): number { + return Math.round(performance.now() - startedAt); } diff --git a/packages/web/src/lib/auth.test.ts b/packages/web/src/lib/auth.test.ts new file mode 100644 index 000000000..71b39148a --- /dev/null +++ b/packages/web/src/lib/auth.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NextAuthOptions } from "next-auth"; + +vi.mock("@open-inspect/shared", () => ({ + DEFAULT_APP_NAME: "Open-Inspect", +})); + +vi.mock("next-auth/providers/github", () => ({ + default: (config: unknown) => ({ + id: "github", + type: "oauth", + options: config, + }), +})); + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetAuthEnv(); +}); + +describe("buildGitHubOAuthScope", () => { + it("requests base scopes when organization access is disabled", async () => { + const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); + + expect(buildGitHubOAuthScope([])).toBe(BASE_GITHUB_OAUTH_SCOPE); + }); + + it("requests read:org only when organization access is configured", async () => { + const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); + + expect(buildGitHubOAuthScope(["acme"])).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); + }); +}); + +describe("GitHub provider scope", () => { + it("omits read:org when organization access is disabled", async () => { + const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "", + }); + + expect(getGitHubProviderScope(authOptions)).toBe(BASE_GITHUB_OAUTH_SCOPE); + }); + + it("includes read:org when organization access is configured", async () => { + const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + }); + + expect(getGitHubProviderScope(authOptions)).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); + }); +}); + +describe("authOptions signIn", () => { + it("logs static allow decisions without sensitive token data", async () => { + const { authOptions } = await importAuthModule({ + ALLOWED_USERS: "alice", + }); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + + await expect( + getSignIn(authOptions)({ + account: { access_token: "secret-token" }, + profile: { login: "Alice" }, + user: { email: "alice@example.com" }, + } as never) + ).resolves.toBe(true); + + expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { + login: "Alice", + decision: "allow", + reason: "username_allowlist", + }); + expect(JSON.stringify(info.mock.calls)).not.toContain("secret-token"); + }); + + it("checks configured organization membership with the OAuth access token", async () => { + const { authOptions } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + NEXT_PUBLIC_APP_NAME: "Test App", + }); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ state: "active" })) + ) as unknown as typeof fetch; + vi.stubGlobal("fetch", fetchImpl); + + await expect( + getSignIn(authOptions)({ + account: { access_token: "oauth-token" }, + profile: { login: "member" }, + user: { email: "member@example.com" }, + } as never) + ).resolves.toBe(true); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.github.com/user/memberships/orgs/acme", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer oauth-token", + "User-Agent": "Test App", + }) as HeadersInit, + }) + ); + expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { + login: "member", + decision: "allow", + reason: "org_membership", + }); + }); + + it("denies organization access when the OAuth access token is missing", async () => { + const { authOptions } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + }); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn() as unknown as typeof fetch; + vi.stubGlobal("fetch", fetchImpl); + + await expect( + getSignIn(authOptions)({ + account: {}, + profile: { login: "member" }, + user: { email: "member@example.com" }, + } as never) + ).resolves.toBe(false); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { + reason: "missing_access_token", + organizationCount: 1, + }); + expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { + login: "member", + decision: "deny", + reason: "org_membership_unavailable", + }); + }); + + it.each([ + ["404 response", () => new Response("Not Found", { status: 404 })], + ["pending membership", () => new Response(JSON.stringify({ state: "pending" }))], + ])("denies organization access for %s", async (_label, responseFactory) => { + const { authOptions } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + }); + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; + vi.stubGlobal("fetch", fetchImpl); + + await expect( + getSignIn(authOptions)({ + account: { access_token: "oauth-token" }, + profile: { login: "member" }, + user: { email: "member@example.com" }, + } as never) + ).resolves.toBe(false); + }); + + it.each([ + ["429 response", () => new Response("Rate Limited", { status: 429 })], + ["server error", () => new Response("Server Error", { status: 500 })], + [ + "network error", + () => { + throw new TypeError("fetch failed"); + }, + ], + ["malformed JSON", () => new Response("not-json")], + ])("reports organization verification unavailable for %s", async (_label, responseFactory) => { + const { authOptions } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + }); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; + vi.stubGlobal("fetch", fetchImpl); + + await expect( + getSignIn(authOptions)({ + account: { access_token: "oauth-token" }, + profile: { login: "member" }, + user: { email: "member@example.com" }, + } as never) + ).resolves.toBe(false); + + expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { + login: "member", + decision: "deny", + reason: "org_membership_unavailable", + }); + }); + + it("does not let unsafe open access bypass configured org allowlists", async () => { + const { authOptions } = await importAuthModule({ + ALLOWED_GITHUB_ORGS: "acme", + UNSAFE_ALLOW_ALL_USERS: "true", + }); + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => new Response("Not Found", { status: 404 })); + vi.stubGlobal("fetch", fetchImpl); + + await expect( + getSignIn(authOptions)({ + account: { access_token: "oauth-token" }, + profile: { login: "outsider" }, + user: { email: "outsider@example.com" }, + } as never) + ).resolves.toBe(false); + }); +}); + +async function importAuthModule(env: Record = {}) { + vi.resetModules(); + resetAuthEnv(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + return import("./auth"); +} + +function resetAuthEnv(): void { + for (const key of [ + "ALLOWED_EMAIL_DOMAINS", + "ALLOWED_USERS", + "ALLOWED_GITHUB_ORGS", + "UNSAFE_ALLOW_ALL_USERS", + "NEXT_PUBLIC_APP_NAME", + ]) { + if (ORIGINAL_ENV[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = ORIGINAL_ENV[key]; + } + } +} + +function getGitHubProviderScope(authOptions: NextAuthOptions): string { + const provider = authOptions.providers[0] as { + options: { authorization: { params: { scope: string } } }; + }; + return provider.options.authorization.params.scope; +} + +function getSignIn(authOptions: NextAuthOptions) { + const signIn = authOptions.callbacks?.signIn; + if (!signIn) { + throw new Error("signIn callback is not configured"); + } + + return signIn; +} diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index c7441b127..bd765240c 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -1,6 +1,13 @@ import type { NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; -import { checkAccessAllowed, parseAllowlist, parseBooleanEnv } from "./access-control"; +import { DEFAULT_APP_NAME } from "@open-inspect/shared"; +import { + type AccessControlConfig, + checkGitHubOrganizationAccess, + getAccessAllowReason, + parseAllowlist, + parseBooleanEnv, +} from "./access-control"; // Extend NextAuth types to include GitHub-specific user info declare module "next-auth" { @@ -25,6 +32,37 @@ declare module "next-auth/jwt" { } } +export const BASE_GITHUB_OAUTH_SCOPE = "read:user user:email repo"; + +export function buildGitHubOAuthScope( + allowedOrganizations = parseAllowlist(process.env.ALLOWED_GITHUB_ORGS) +): string { + return allowedOrganizations.length > 0 + ? `${BASE_GITHUB_OAUTH_SCOPE} read:org` + : BASE_GITHUB_OAUTH_SCOPE; +} + +function getAccessControlConfig(): AccessControlConfig { + return { + allowedDomains: parseAllowlist(process.env.ALLOWED_EMAIL_DOMAINS), + allowedUsers: parseAllowlist(process.env.ALLOWED_USERS), + allowedOrganizations: parseAllowlist(process.env.ALLOWED_GITHUB_ORGS), + unsafeAllowAllUsers: parseBooleanEnv(process.env.UNSAFE_ALLOW_ALL_USERS), + }; +} + +function logSignInDecision( + login: string | undefined, + decision: "allow" | "deny", + reason: string +): void { + console.info("[auth] sign-in decision", { + login: login ?? null, + decision, + reason, + }); +} + export const authOptions: NextAuthOptions = { debug: process.env.NODE_ENV === "development" || process.env.NEXTAUTH_DEBUG === "true", providers: [ @@ -33,29 +71,44 @@ export const authOptions: NextAuthOptions = { clientSecret: process.env.GITHUB_CLIENT_SECRET!, authorization: { params: { - scope: "read:user user:email repo", + scope: buildGitHubOAuthScope(), }, }, }), ], callbacks: { - async signIn({ profile, user }) { - const config = { - allowedDomains: parseAllowlist(process.env.ALLOWED_EMAIL_DOMAINS), - allowedUsers: parseAllowlist(process.env.ALLOWED_USERS), - unsafeAllowAllUsers: parseBooleanEnv(process.env.UNSAFE_ALLOW_ALL_USERS), - }; - + async signIn({ account, profile, user }) { + const config = getAccessControlConfig(); + const allowedOrganizations = config.allowedOrganizations ?? []; const githubProfile = profile as { login?: string }; - const isAllowed = checkAccessAllowed(config, { + const staticAllowReason = getAccessAllowReason(config, { githubUsername: githubProfile.login, email: user.email ?? undefined, }); - if (!isAllowed) { + if (staticAllowReason) { + logSignInDecision(githubProfile.login, "allow", staticAllowReason); + return true; + } + + if (allowedOrganizations.length === 0) { + logSignInDecision(githubProfile.login, "deny", "no_matching_policy"); return false; } - return true; + + const orgMembership = await checkGitHubOrganizationAccess({ + accessToken: account?.access_token, + allowedOrganizations, + userAgent: process.env.NEXT_PUBLIC_APP_NAME?.trim() || DEFAULT_APP_NAME, + }); + + logSignInDecision( + githubProfile.login, + orgMembership.allowed ? "allow" : "deny", + getOrgMembershipDecisionReason(orgMembership) + ); + + return orgMembership.allowed; }, async jwt({ token, account, profile }) { if (account) { @@ -88,3 +141,15 @@ export const authOptions: NextAuthOptions = { error: "/access-denied", }, }; + +function getOrgMembershipDecisionReason( + orgMembership: Awaited> +): string { + if (orgMembership.allowed) { + return "org_membership"; + } + + return orgMembership.reason === "unavailable" + ? "org_membership_unavailable" + : "org_membership_denied"; +} diff --git a/terraform/environments/production/checks.tf b/terraform/environments/production/checks.tf index dfbd31faa..4faeb2d1f 100644 --- a/terraform/environments/production/checks.tf +++ b/terraform/environments/production/checks.tf @@ -20,9 +20,10 @@ resource "terraform_data" "access_control_gate" { condition = ( var.unsafe_allow_all_users || length([for item in split(",", var.allowed_users) : trimspace(item) if trimspace(item) != ""]) > 0 || - length([for item in split(",", var.allowed_email_domains) : trimspace(item) if trimspace(item) != ""]) > 0 + length([for item in split(",", var.allowed_email_domains) : trimspace(item) if trimspace(item) != ""]) > 0 || + length([for item in split(",", var.allowed_github_orgs) : trimspace(item) if trimspace(item) != ""]) > 0 ) - error_message = "At least one access control allowlist must be configured. Set allowed_users or allowed_email_domains, or set unsafe_allow_all_users = true to explicitly allow all authenticated GitHub users." + error_message = "At least one access control allowlist must be configured. Set allowed_users, allowed_email_domains, or allowed_github_orgs, or set unsafe_allow_all_users = true to explicitly allow all authenticated GitHub users." } } } diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index 563c82201..c1d153d40 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -271,6 +271,8 @@ enable_service_bindings = false # ============================================================================= # Access Control # ============================================================================= +# Allowlists are OR-based: matching any configured user, email domain, or GitHub +# org grants access. # Comma-separated list of GitHub usernames allowed to sign in # Set at least one access-control allowlist unless you intentionally opt into # open access with unsafe_allow_all_users = true. @@ -280,6 +282,13 @@ allowed_users = "" # Example: "example.com,corp.io" allowed_email_domains = "" +# Comma-separated list of GitHub organizations whose active members can sign in +# Requests read:org only when set, then checks membership at sign-in with the +# signing-in user's OAuth token. Existing sessions last until session expiry. +# Requires GitHub App Organization permissions: Members read-only; existing apps +# must republish/request approval after this permission changes. +allowed_github_orgs = "" + # Explicitly bypass the Terraform access-control safety check and allow any -# authenticated GitHub user to sign in when both allowlists are empty. +# authenticated GitHub user to sign in when all allowlists are empty. unsafe_allow_all_users = false diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 95a55aa51..3cde5039f 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -406,19 +406,25 @@ variable "r2_media_bucket_name" { # ============================================================================= variable "allowed_users" { - description = "Comma-separated list of GitHub usernames allowed to sign in. Leave empty only when allowed_email_domains is set or unsafe_allow_all_users is true." + description = "Comma-separated list of GitHub usernames allowed to sign in. Leave empty only when another allowlist is set or unsafe_allow_all_users is true." type = string default = "" } variable "allowed_email_domains" { - description = "Comma-separated list of email domains allowed to sign in (e.g., 'example.com,corp.io'). Leave empty only when allowed_users is set or unsafe_allow_all_users is true." + description = "Comma-separated list of email domains allowed to sign in (e.g., 'example.com,corp.io'). Leave empty only when another allowlist is set or unsafe_allow_all_users is true." + type = string + default = "" +} + +variable "allowed_github_orgs" { + description = "Comma-separated list of GitHub organization logins whose active members are allowed to sign in. Leave empty only when another allowlist is set or unsafe_allow_all_users is true." type = string default = "" } variable "unsafe_allow_all_users" { - description = "Bypass Terraform's access-control safety check and allow any authenticated GitHub user to sign in when both allowlists are empty. Set to true only for intentionally open deployments." + description = "Bypass Terraform's access-control safety check and allow any authenticated GitHub user to sign in when all allowlists are empty. Set to true only for intentionally open deployments." type = bool default = false } diff --git a/terraform/environments/production/web-cloudflare.tf b/terraform/environments/production/web-cloudflare.tf index e5717a74d..3aad59b71 100644 --- a/terraform/environments/production/web-cloudflare.tf +++ b/terraform/environments/production/web-cloudflare.tf @@ -77,6 +77,7 @@ resource "local_file" "web_app_wrangler_production" { NEXT_PUBLIC_APP_ICON_URL = "${var.app_icon_url}" ALLOWED_USERS = "${var.allowed_users}" ALLOWED_EMAIL_DOMAINS = "${var.allowed_email_domains}" + ALLOWED_GITHUB_ORGS = "${var.allowed_github_orgs}" UNSAFE_ALLOW_ALL_USERS = "${tostring(var.unsafe_allow_all_users)}" [assets] diff --git a/terraform/environments/production/web-vercel.tf b/terraform/environments/production/web-vercel.tf index 98a8f305e..5c796d4e0 100644 --- a/terraform/environments/production/web-vercel.tf +++ b/terraform/environments/production/web-vercel.tf @@ -99,6 +99,12 @@ module "web_app" { targets = ["production", "preview"] sensitive = false }, + { + key = "ALLOWED_GITHUB_ORGS" + value = var.allowed_github_orgs + targets = ["production", "preview"] + sensitive = false + }, { key = "UNSAFE_ALLOW_ALL_USERS" value = tostring(var.unsafe_allow_all_users)