fix: check session admin rights in the session's workspace (#12071)

This commit is contained in:
AlexRV12
2026-10-28 18:48:06 +01:00
committed by GitHub
parent d86614dd50
commit 09ddf1e0c7
6 changed files with 33 additions and 21 deletions
@@ -10,7 +10,6 @@
import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte'
import AgentSlackSettings from './AgentSlackSettings.svelte'
import { WorkspaceService } from '$lib/gen'
import { userStore } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte'
@@ -36,8 +35,8 @@
let nextRunAs = $derived(
pickedRunAs?.preserve && pickedRunAs.permissionedAs
? pickedRunAs.permissionedAs
: $userStore?.username
? `u/${$userStore.username}`
: acting.current?.username
? `u/${acting.current.username}`
: undefined
)
// Only a workspace that deploys somewhere has anything to keep an agent out of.
@@ -9,7 +9,7 @@
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { SlackAgentsService, type SlackAgentChannel } from '$lib/gen'
import { userStore } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import { sendUserToast } from '$lib/toast'
import type { AgentDraftHandle } from '../agentDraft.svelte'
@@ -26,16 +26,18 @@
let { draft, path, workspace, nextRunAs }: Props = $props()
const acting = useActingUser(() => workspace)
let readOnly = $derived(!draft.canWrite)
let obo = $derived(!!draft.state?.args.run_on_behalf_of)
let runsAs = $derived(
readOnly
? (draft.onBehalfOf ?? 'its run-as user')
: !nextRunAs || nextRunAs === `u/${$userStore?.username}`
: !nextRunAs || nextRunAs === `u/${acting.current?.username}`
? 'you'
: nextRunAs
)
let isAdmin = $derived(!!$userStore?.is_admin)
let isAdmin = $derived(!!acting.current?.is_admin)
const slack = resource(
() => ({ ws: workspace, path, deployed: !draft.noDeployed }),
@@ -9,8 +9,10 @@
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import { Plus, RefreshCw, Unplug } from 'lucide-svelte'
import { userStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { connectNativeAccount, getServiceConfig } from './utils'
interface Props {
@@ -45,6 +47,7 @@
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service)
const confirmationModal = createAsyncConfirmationModal()
@@ -95,7 +98,7 @@
}
async function reconnect(c: NativeTriggerConnection) {
if (c.owner && c.owner !== $userStore?.username) {
if (c.owner && c.owner !== operatingUser.current?.username) {
const confirmed = await confirmationModal.ask({
title: `Reconnect ${c.path} with your account?`,
confirmationText: 'Reconnect',
@@ -130,13 +133,16 @@
// Members cannot write the workspace connection; the server refuses anyone else it does not own.
function canDisconnect(c: NativeTriggerConnection): boolean {
if ($userStore?.is_admin) return true
return !c.workspace && !!c.owner && c.owner === $userStore?.username
if (operatingUser.current?.is_admin) return true
return !c.workspace && !!c.owner && c.owner === operatingUser.current?.username
}
// The server lets only the connection's owner or an admin replace it.
function canReconnect(c: NativeTriggerConnection): boolean {
return !!$userStore?.is_admin || (!!c.owner && c.owner === $userStore?.username)
return (
!!operatingUser.current?.is_admin ||
(!!c.owner && c.owner === operatingUser.current?.username)
)
}
const items = $derived.by(() => {
+3 -4
View File
@@ -10,10 +10,9 @@
* files, then createAppRaw/updateAppRaw. The source→AppDraftValue projection is
* shared via `rawAppDraftValue` so the two deploy paths can't drift.
*/
import { get } from 'svelte/store'
import { AppService } from '$lib/gen'
import type { Policy } from '$lib/gen'
import { userStore } from '$lib/stores'
import { isAdminIn } from '$lib/user'
import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge'
import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
@@ -63,7 +62,7 @@ export async function deployRawAppValue({
const bundle = await bundleRawAppDraft({ workspace, files })
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const isAdmin = await isAdminIn(workspace)
await AppService.updateAppRaw({
workspace,
path,
@@ -131,7 +130,7 @@ export async function deployRawAppDraft(
// non-admin deploying a draft for an app that has a custom route would hit
// RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue
// fallback even when unchanged).
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const isAdmin = await isAdminIn(workspace)
await AppService.updateAppRaw({
workspace,
path,
+6
View File
@@ -39,6 +39,12 @@ export function getWorkspaceRole(workspace: string): Promise<RoleLookup> {
return lookup
}
/** Admin of `workspace`, which may not be the navigated one; an unknown user is not. */
export async function isAdminIn(workspace: string): Promise<boolean> {
const role = await getWorkspaceRole(workspace)
return role.kind === 'resolved' && !!(role.user.is_admin || role.user.is_super_admin)
}
/** Roles are per-identity, so anything that changes who is logged in must drop this. */
export function clearWorkspaceRoleCache(): void {
workspaceRoleCache.clear()
+5 -5
View File
@@ -12,7 +12,7 @@
* draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`);
* a draft on an already-deployed item just deletes the draft row.
*/
import { get, writable } from 'svelte/store'
import { writable } from 'svelte/store'
import {
DraftService,
ScriptService,
@@ -43,7 +43,7 @@ import { classicAppDraftParts } from '$lib/appDiffSides'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { userStore } from '$lib/stores'
import { isAdminIn } from '$lib/user'
import { deployTriggers, type Trigger } from '$lib/components/triggers/utils'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks'
@@ -449,7 +449,7 @@ async function deployDraftTriggers(
): Promise<void> {
const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig)
if (triggers.length === 0) return
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const isAdmin = await isAdminIn(workspace)
await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew)
}
@@ -586,7 +586,7 @@ export async function deployDraft(
// (or a default). custom_path requires admin on update; non-admins send
// undefined so the backend preserves the existing route. The draft has no
// custom_path, so admins fall back to the deployed route (`''` when none).
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const isAdmin = await isAdminIn(workspace)
const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true }
const requestBody = {
value: appValue,
@@ -720,7 +720,7 @@ export async function deployDraft(
} else if (kind in TRIGGER_SAVERS) {
const getter = OVERLAY_GETTERS[kind]!
const { draft: d } = splitOverlay(await getter(workspace, path))
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
const isAdmin = await isAdminIn(workspace)
const ok = await TRIGGER_SAVERS[kind]!(
path,
{ ...d, path: d.path ?? path },