diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 9f0252083f..c292e5a259 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -1,19 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { whoami, deployPermission, protectionRules } = vi.hoisted(() => ({ +const { whoami, deployPermission } = vi.hoisted(() => ({ whoami: vi.fn(), - deployPermission: vi.fn(), - protectionRules: vi.fn() + deployPermission: vi.fn() })) vi.mock('$lib/gen', () => ({ UserService: { whoami } })) vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployPermission: deployPermission })) -// Only the fetch is stubbed — the bypass evaluation is the real one, so the test -// exercises the rule semantics rather than a restatement of them. -vi.mock('$lib/workspaceProtectionRules.svelte', async (importOriginal) => ({ - ...(await importOriginal()), - fetchProtectionRulesForWorkspace: protectionRules -})) import { resolveSessionAccess } from './sessionAccess' @@ -46,7 +39,6 @@ describe('resolveSessionAccess', () => { beforeEach(() => { vi.clearAllMocks() deployPermission.mockResolvedValue({ ok: true }) - protectionRules.mockResolvedValue([]) }) it('gives a developer every capability', async () => { @@ -63,24 +55,21 @@ describe('resolveSessionAccess', () => { // The two gates have OPPOSITE precedence in the backend, so a role ladder would // get one of them wrong: drafts.rs returns Ok for `authed.is_admin` before the // operator branch, while jobs.rs checks the operator flag first with no escape. - it('lets an admin who is also an operator write drafts but not run previews', async () => { - deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' }) - const caps = await capabilitiesFor({ is_admin: true, operator: true }) - expect(caps.has('write_draft')).toBe(true) - expect(caps.has('run_preview')).toBe(false) - }) - - // `ApiAuthed.is_admin` is `usr.is_admin || super_admin`, but whoami reports the two - // separately — so a superadmin who is an operator must resolve the same way. - it('treats a superadmin as an admin for the draft gate', async () => { - deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' }) - const caps = await capabilitiesFor({ is_super_admin: true, operator: true }) - expect(caps.has('write_draft')).toBe(true) - expect(caps.has('run_preview')).toBe(false) - }) + // `authed.is_admin` is `usr.is_admin || super_admin`, so both spellings must land + // on the same side of it. + it.each([{ is_admin: true }, { is_super_admin: true }])( + 'lets an admin who is also an operator write drafts but not run previews (%o)', + async (role) => { + deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' }) + const caps = await capabilitiesFor({ ...role, operator: true }) + expect(caps.has('write_draft')).toBe(true) + expect(caps.has('run_preview')).toBe(false) + } + ) // Deploy is operation-shaped: protection rulesets can block a plain developer, and - // wm_deployers can unblock a non-admin. The resolver must not second-guess it. + // wm_deployers can unblock a non-admin. The resolver must not second-guess the shared + // preflight, and must not let a deploy refusal take drafting down with it. it('takes deploy from the shared permission check, not from the role', async () => { deployPermission.mockResolvedValue({ ok: false, reason: 'restricted to deployers' }) const caps = await capabilitiesFor({}) @@ -88,41 +77,6 @@ describe('resolveSessionAccess', () => { expect(caps.has('write_draft')).toBe(true) }) - // checkDeployPermission tests `me.is_admin` alone, while the backend rule it mirrors - // receives `authed.is_admin` (workspace admin OR superadmin). The resolver folds the - // two before delegating, so a superadmin keeps deploy under RestrictDeployToDeployers. - it('presents a superadmin as admin to the deploy check', async () => { - whoami.mockResolvedValueOnce(user({ is_super_admin: true })) - await resolveSessionAccess('ws') - expect(deployPermission).toHaveBeenCalledWith( - 'ws', - expect.objectContaining({ is_admin: true }), - // An admin bypasses every ruleset, so none are fetched to hand over. - undefined - ) - }) - - // `checkDeployPermission` covers only the operator and RestrictDeployToDeployers halves - // of the gate, but the endpoints the deploy tools call run the backend's - // `check_deploy_rules`, which blocks on DisableDirectDeployment too. Drafting is - // untouched by that rule — it is the deploy that the workspace refuses. - it('withholds deploy under DisableDirectDeployment without a bypass', async () => { - protectionRules.mockResolvedValue([ - { name: 'lock', rules: ['DisableDirectDeployment'], bypass_users: [], bypass_groups: [] } - ]) - const caps = await capabilitiesFor({}) - expect(caps.has('deploy')).toBe(false) - expect(caps.has('write_draft')).toBe(true) - }) - - it('keeps deploy under DisableDirectDeployment for a bypass user', async () => { - protectionRules.mockResolvedValue([ - { name: 'lock', rules: ['DisableDirectDeployment'], bypass_users: ['u'], bypass_groups: [] } - ]) - const caps = await capabilitiesFor({}) - expect(caps.has('deploy')).toBe(true) - }) - // Fail open, matching checkDeployPermission: a transient whoami failure must not // strip a session's toolset — the server is still the enforcement point. it('grants everything when the role cannot be resolved', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index d9452dcd00..3b1bebdbd8 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -1,9 +1,5 @@ import { UserService, type User } from '$lib/gen' import { checkDeployPermission } from '$lib/utils_workspace_deploy' -import { - canUserBypassRuleKindInRulesets, - fetchProtectionRulesForWorkspace -} from '$lib/workspaceProtectionRules.svelte' /** * What a user may do in ONE workspace, as the AI session toolset needs to know it. @@ -45,15 +41,6 @@ export function hasCapabilities( return requires.every((c) => access.capabilities.has(c)) } -/** - * `ApiAuthed.is_admin` is `usr.is_admin || super_admin` (windmill-api-auth/src/auth.rs), - * while `whoami` reports the two separately — so every rule below that mirrors an - * `authed.is_admin` check must OR them back together. - */ -function isAuthedAdmin(me: User): boolean { - return !!me.is_admin || !!me.is_super_admin -} - export async function resolveSessionAccess(workspace: string): Promise { let me: User try { @@ -66,8 +53,9 @@ export async function resolveSessionAccess(workspace: string): Promise