Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";

import { getDynamicDataDisplayInfo } from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/ArgumentsEditor/dynamicDataUtils";
import { FieldMessage } from "@/components/ui/field-message";
import { InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import type {
Expand All @@ -13,8 +14,12 @@ import type {
} from "@/models/componentSpec";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useIOActions } from "@/routes/v2/pages/Editor/store/actions/useIOActions";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import type { DynamicDataArgument } from "@/utils/componentSpec";
import {
type DynamicDataArgument,
isSecretArgument,
} from "@/utils/componentSpec";

import {
canResetArgument,
Expand Down Expand Up @@ -63,6 +68,8 @@ export const ArgumentRow = observer(function ArgumentRow({
}: ArgumentRowProps) {
const { track } = useAnalytics();
const { editor } = useSharedStores();
const { pipelineFile } = useEditorSession();
const secretsOwner = pipelineFile.activePipelineFile?.secretsOwner;
const {
setArgument,
removeArgument,
Expand All @@ -79,6 +86,9 @@ export const ArgumentRow = observer(function ArgumentRow({
const isBound = binding !== undefined;

const isDynamic = isDynamicDataValue(currentValue);
const secretsLocked =
!!secretsOwner &&
task.arguments.some((argument) => isSecretArgument(argument.value));
const dynamicDisplayInfo = isDynamic
? getDynamicDataDisplayInfo(currentValue.dynamicData)
: null;
Expand All @@ -98,7 +108,7 @@ export const ArgumentRow = observer(function ArgumentRow({
}, [isFocused, externalEditor]);

const handleClick = () => {
if (isDynamic) return;
if (isDynamic || secretsLocked) return;
track("v2.pipeline_editor.task_arguments.argument_row.edit_started");
onSelectionChanged?.(inputSpec.name);
if (externalEditor) {
Expand Down Expand Up @@ -198,6 +208,8 @@ export const ArgumentRow = observer(function ArgumentRow({
inputType={inputSpec.type}
canReset={canReset}
canUnset={canUnset}
disabled={secretsLocked}
secretsOwner={secretsOwner}
excludeEntityIds={[task.$id]}
taskAnnotations={taskAnnotations}
onResetToDefault={handleResetToDefault}
Expand All @@ -219,9 +231,15 @@ export const ArgumentRow = observer(function ArgumentRow({
task={task}
inputSpec={inputSpec}
currentValue={currentValue}
readOnly={secretsLocked}
onChangeComplete={handleChangeComplete}
onBlur={handleBlur}
/>
{secretsLocked && isSecretArgument(currentValue) && (
<FieldMessage icon="Lock" tone="info" className="mt-1">
Only {secretsOwner} can change tasks with secrets.
</FieldMessage>
)}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface ArgumentValueDisplayProps {
task: Task;
inputSpec: InputSpec;
currentValue: unknown;
readOnly?: boolean;
onChangeComplete: (value: string) => void;
onBlur: () => void;
}
Expand All @@ -36,6 +37,7 @@ export function ArgumentValueDisplay({
task,
inputSpec,
currentValue,
readOnly,
onChangeComplete,
onBlur,
}: ArgumentValueDisplayProps) {
Expand Down Expand Up @@ -70,8 +72,9 @@ export function ArgumentValueDisplay({
key={`${task.$id}-${inputSpec.name}-${String(currentValue ?? "")}`}
defaultValue={typeof currentValue === "string" ? currentValue : ""}
expandDialogTitle={`Value for ${inputSpec.name}`}
onChangeComplete={onChangeComplete}
onChangeComplete={readOnly ? undefined : onChangeComplete}
onBlur={onBlur}
readOnly={readOnly}
highlightSyntax
placeholder={isBound ? bindingLabel || "" : placeholder}
className={cn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface ThunderMenuProps {
canReset: boolean;
canUnset: boolean;
disabled?: boolean;
secretsOwner?: string;
excludeEntityIds: string[];
taskAnnotations?: TaskAnnotations;
onResetToDefault: () => void;
Expand All @@ -48,6 +49,7 @@ export const ThunderMenu = observer(function ThunderMenu({
canReset,
canUnset,
disabled = false,
secretsOwner,
excludeEntityIds,
taskAnnotations,
onResetToDefault,
Expand Down Expand Up @@ -144,6 +146,7 @@ export const ThunderMenu = observer(function ThunderMenu({
<DropdownMenuSeparator />
<DynamicDataSubmenu
groups={dynamicDataGroups}
secretsOwner={secretsOwner}
onOpenSecretDialog={handleOpenSecretDialog}
onSelectSystemData={handleSelectSystemData}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ import {
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
import { Icon } from "@/components/ui/icon";
import { InlineStack } from "@/components/ui/layout";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { tracking } from "@/utils/tracking";

type DynamicDataGroup = ReturnType<typeof getDynamicDataGroups>[number];

interface DynamicDataSubmenuProps {
groups: DynamicDataGroup[];
secretsOwner?: string;
onOpenSecretDialog: () => void;
onSelectSystemData: (key: string) => void;
}

export function DynamicDataSubmenu({
groups,
secretsOwner,
onOpenSecretDialog,
onSelectSystemData,
}: DynamicDataSubmenuProps) {
Expand All @@ -44,7 +47,19 @@ export function DynamicDataSubmenu({
{group.title}
</InlineStack>
</DropdownMenuLabel>
{group.requiresDialog ? (
{group.requiresDialog && secretsOwner ? (
<BlockStack gap="1" className="px-2 py-1.5">
<InlineStack gap="2" blockAlign="center">
<Icon name="Lock" size="sm" className="text-info" />
<Text size="sm" tone="subdued">
Select Secret...
</Text>
</InlineStack>
<Text size="xs" tone="subdued">
Ask {secretsOwner} to add secrets.
</Text>
</BlockStack>
) : group.requiresDialog ? (
<DropdownMenuItem
{...tracking(
"v2.pipeline_editor.task_arguments.thunder_menu.dynamic_data.secret_dialog_open",
Expand Down
28 changes: 15 additions & 13 deletions src/services/cloudPipelineService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,20 +485,22 @@ describe("remote pipeline writes", () => {
expect(body).not.toHaveProperty("versioning_mode");
});

it("rejects updates to another owner's pipeline before issuing a write", async () => {
fetchMock.mockResolvedValueOnce(accountResponse());
it("saves another owner's pipeline by id so the backend can check edit access", async () => {
const shared = pipeline({ user_id: "another-owner@example.com" });
saveResponses(shared);

await expect(
writeCloudPipeline(
{
filePath: FILE_PATH,
componentSpec: localSpec,
existingPipeline: pipeline({ user_id: "another-owner@example.com" }),
},
connection,
),
).rejects.toThrow("Only the owner");
expect(fetchMock).toHaveBeenCalledTimes(1);
await writeCloudPipeline(
{
filePath: FILE_PATH,
componentSpec: localSpec,
existingPipeline: shared,
},
connection,
);

const request = requestAt(1);
expect(request.method).toBe("PUT");
expect(new URL(request.url).pathname).toBe(`/api/pipelines/${shared.id}`);
});

it("creates a copy under the viewer's account when no existing pipeline is supplied", async () => {
Expand Down
50 changes: 39 additions & 11 deletions src/services/cloudPipelineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { isRecord } from "@/utils/typeGuards";

const USER_PIPELINES_PATH = "/api/users/me/pipelines";
const PIPELINE_BY_ID_PATH = "/api/pipelines/{pipeline_id}";
const PIPELINE_SCOPES_PATH =
"/api/access_control/pipeline/{pipeline_id}/my_scopes";

const accountSchema = z.object({
id: z.string().min(1),
Expand Down Expand Up @@ -175,6 +177,22 @@ export async function getCloudPipeline(
return pipeline;
}

export async function getCloudPipelineScopes(
pipelineId: string,
connection: CloudConnection,
): Promise<string[]> {
const result = await client.get<unknown>({
...requestOptions(connection),
url: PIPELINE_SCOPES_PATH,
path: { pipeline_id: pipelineId },
});
if (!result.response.ok) return [];
const scopes = z
.object({ my_scopes: z.array(z.string()) })
.safeParse(result.data);
return scopes.success ? scopes.data.my_scopes : [];
}

export async function writeCloudPipeline(
{
filePath,
Expand All @@ -186,26 +204,36 @@ export async function writeCloudPipeline(
): Promise<CloudPipeline> {
const account =
connection.account ?? (await getCloudPipelineAccount(connection));
requireWritePermission(account, existingPipeline);
const owner = existingPipeline?.user_id ?? account.id;
requireWritePermission(account);
if (existingPipeline && existingPipeline.file_path !== filePath) {
throw new Error("A remote pipeline's storage path cannot change.");
}

const template = existingPipeline ?? sourcePipeline;
const task = componentSpecToCloudTask(componentSpec, template);
const result = await client.put<unknown>({
...requestOptions(connection),
url: USER_PIPELINES_PATH,
query: { file_path: filePath },
body: {
root_pipeline_task: task,
pipeline_run_annotations: template?.pipeline_run_annotations ?? {},
},
});
const body = {
root_pipeline_task: task,
pipeline_run_annotations: template?.pipeline_run_annotations ?? {},
};
const result =
existingPipeline && owner !== account.id
? await client.put<unknown>({
...requestOptions(connection),
url: PIPELINE_BY_ID_PATH,
path: { pipeline_id: existingPipeline.id },
body,
})
: await client.put<unknown>({
...requestOptions(connection),
url: USER_PIPELINES_PATH,
query: { file_path: filePath },
body,
});
requireSuccessfulResponse(result);
const savedPipeline = pipelineSchema.parse(result.data);
if (
savedPipeline.user_id !== account.id ||
savedPipeline.user_id !== owner ||
savedPipeline.file_path !== filePath ||
(existingPipeline && savedPipeline.id !== existingPipeline.id)
) {
Expand Down
3 changes: 3 additions & 0 deletions src/services/pipelineStorage/PipelineFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export class PipelineFile {
get savedTaskArguments(): Record<string, ArgumentType> {
return this.redirectedFile?.savedTaskArguments ?? {};
}
get secretsOwner(): string | undefined {
return this.redirectedFile?.secretsOwner;
}

async retry(): Promise<void> {
await this.redirectedFile?.retry();
Expand Down
14 changes: 12 additions & 2 deletions src/services/pipelineStorage/RemotePipelineFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { RemotePipelineStore } from "./RemotePipelineStore";

export class RemotePipelineFile extends PipelineFile {
@observable.ref accessor recovery: RemotePipelineRecovery;
@observable.ref accessor sharedScopes: string[] = [];
private pendingWrites = observable.box(0);

constructor(
Expand All @@ -45,12 +46,21 @@ export class RemotePipelineFile extends PipelineFile {
const account = this.store.account;
return pipeline
? account
? account.id === pipeline.user_id &&
account.permissions.includes("write")
? account.permissions.includes("write") &&
(account.id === pipeline.user_id ||
this.sharedScopes.includes("pipeline:update"))
: this.recovery.ownerId === pipeline.user_id
: !account || account.permissions.includes("write");
}

override get secretsOwner(): string | undefined {
const pipeline = this.recovery.pipeline ?? this.summary;
const account = this.store.account;
return pipeline && account && account.id !== pipeline.user_id
? pipeline.user_id
: undefined;
}

override get displayName(): string {
return this.recovery.displayName;
}
Expand Down
1 change: 1 addition & 0 deletions src/services/pipelineStorage/RemotePipelineStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock("@/services/cloudPipelineService", () => ({
getCloudPipelineSavedTaskArguments: vi.fn(),
getCloudPipeline: vi.fn(),
getCloudPipelineAccount: vi.fn(),
getCloudPipelineScopes: vi.fn(async () => []),
listCloudPipelinePage: vi.fn(),
listCloudPipelines: vi.fn(),
writeCloudPipeline: vi.fn(),
Expand Down
8 changes: 7 additions & 1 deletion src/services/pipelineStorage/RemotePipelineStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,11 @@ export class RemotePipelineStore {
);
// Other owners can use the same file path; never attach our draft to their pipeline.
const ownRecord = record?.pipeline?.id === pipeline.id ? record : undefined;
return this.fromSummary(
const sharedScopes =
pipeline.user_id === this.account?.id
? []
: await this.driver.scopes(pipeline);
const file = this.fromSummary(
pipeline,
ownRecord
? { ...ownRecord, pipeline }
Expand All @@ -262,6 +266,8 @@ export class RemotePipelineStore {
: undefined,
},
);
file.sharedScopes = sharedScopes;
return file;
}

async resolveLocal(fileId: string): Promise<RemotePipelineFile | undefined> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
deleteCloudPipeline,
getCloudPipeline,
getCloudPipelineAccount,
getCloudPipelineScopes,
listCloudPipelinePage,
listCloudPipelines,
writeCloudPipeline,
Expand Down Expand Up @@ -121,6 +122,10 @@ export class RemotePipelineStorageDriver implements PipelineStorageDriver<Remote
};
}

async scopes(pipeline: CloudPipelineSummary): Promise<string[]> {
return getCloudPipelineScopes(pipeline.id, await this.connect());
}

async write(
storageKey: string,
content: string,
Expand Down
Loading
Loading