Skip to content
Merged
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
20 changes: 20 additions & 0 deletions apps/hosted/server/src/contracts/app-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ import {
RequireOrganization,
} from "./organization.ts";

/** A connection's own pinned accounts are no longer among the caller's usable app links. */
export const ConnectionAccountUnlinked = UserFacingError.define({
tag: "ConnectionAccountUnlinked",
status: 409,
fields: { app: AppId, accounts: Schema.NonEmptyArray(AccountId) },
presentation: ({ accounts }) => ({
title: "This connection needs an account",
description:
accounts.length === 1
? `This connection runs as account ${accounts[0]}, which is no longer linked to this app for you. Relink it, or edit the connection to run as another of your accounts.`
: `This connection runs as accounts ${accounts.join(", ")}, none of which are still linked to this app for you. Relink one, or edit the connection to run as another of your accounts.`,
recovery: {
action: "Relink the account, or edit the connection to run as another account.",
instructions:
"Only the connection's owner can fix this; organization admins cannot edit another member's connection. Open the connection under Connect and save it to relink the account it runs as, or choose another of your accounts for this app. Relinking the account on the app's Accounts tab also works. For a single-account app, relinking replaces your current link. Linking a different account does not change which account the connection runs as. Do not change organization roles or bypass account access.",
},
}),
});
export type ConnectionAccountUnlinked = typeof ConnectionAccountUnlinked.Type;

const mismatch = {
requirement: {
title: "This app has no such account requirement here",
Expand Down
2 changes: 2 additions & 0 deletions apps/hosted/server/src/contracts/app-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
import { withLegacyKeys } from "@executor-js/utils/legacy-keys";
import { RequiredAction } from "./authorization.ts";
/** Authored app operations, scoped to an explicit hosted organization and configured app. */
Expand All @@ -18,6 +19,7 @@ const payload = Schema.Struct({
input: Json,
}).pipe(withLegacyKeys({ version: "deployment" }));
const errors = [
ConnectionAccountUnlinked,
...appDataErrors,
OrganizationForbidden,
AuthenticationUnavailable,
Expand Down
8 changes: 7 additions & 1 deletion apps/hosted/server/src/contracts/schedules.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
/** Organization-aware schedule management, with browser-only human review. */
import { Context, Effect, Schema } from "effect";
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api";
Expand Down Expand Up @@ -42,7 +43,12 @@ const organization = { organization: OrganizationReference };
const app = { ...organization, app: AppId };
const copy = { ...app, name: Schema.NonEmptyString, copy: ScheduleId };
const prefix = "/api/organizations/:organization";
const errors = [...ScheduleErrors, OrganizationForbidden, Forbidden] as const;
const errors = [
...ScheduleErrors,
ConnectionAccountUnlinked,
OrganizationForbidden,
Forbidden,
] as const;
/** How a by-name route picks between the app schedule and the caller's copy; agents read it. */
const scopeRule =
"scope \"app\" is the app declaration's one schedule, which runs without a caller with the app's linked accounts; scope \"user\" is the caller's own copy of a user declaration. Without scope, the schedule saved under the name decides; with none saved, the app's declaration; otherwise user. When both the app schedule and the caller's copy are saved under the name, the call fails with ScheduleScopeRequired: retry with scope.";
Expand Down
8 changes: 5 additions & 3 deletions apps/hosted/server/src/contracts/skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { withLegacyKeys } from "@executor-js/utils/legacy-keys";
import { RequiredAction } from "./authorization.ts";
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
/** Organization-scoped skill reads expose only the skill directory, never general app source. */
import { Schema } from "effect";
import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api";
Expand All @@ -16,6 +17,7 @@ import {
} from "@executor-js/sdk/core";
import { OrganizationReference, RequireOrganization } from "./organization.ts";

const errors = [...AppSkillErrors, ConnectionAccountUnlinked] as const;
const app = { organization: OrganizationReference, app: AppId };
const version = { version: SkillSelection.version, revision: SkillSelection.revision };
const prefix = "/api/organizations/:organization/apps/:app/skills";
Expand All @@ -26,15 +28,15 @@ export const HostedSkills = HttpApiGroup.make("skills")
params: app,
query: Schema.Struct(version).pipe(withLegacyKeys({ version: "deployment" })),
success: AppSkillBundle,
error: AppSkillErrors,
error: errors,
}).annotate(RequiredAction, "discover"),
)
.add(
HttpApiEndpoint.get("list", prefix, {
params: app,
query: Schema.Struct(version).pipe(withLegacyKeys({ version: "deployment" })),
success: AppSkillCatalog,
error: AppSkillErrors,
error: errors,
}).annotate(RequiredAction, "discover"),
)
.add(
Expand All @@ -44,7 +46,7 @@ export const HostedSkills = HttpApiGroup.make("skills")
withLegacyKeys({ version: "deployment" }),
),
success: AppSkillDocument,
error: [...AppSkillErrors, AppSkillNotFound],
error: [...errors, AppSkillNotFound],
}).annotate(RequiredAction, "discover"),
)
.middleware(RequireOrganization);
2 changes: 2 additions & 0 deletions apps/hosted/server/src/contracts/tools.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
import { withLegacyKeys } from "@executor-js/utils/legacy-keys";
import { RequiredAction } from "./authorization.ts";
/** Account-dependent discovery and execution within a configured app. */
Expand Down Expand Up @@ -54,6 +55,7 @@ import {

const params = { organization: OrganizationReference, app: AppId };
const discoveryErrors = [
ConnectionAccountUnlinked,
StorageError,
CredentialsError,
AppNotFound,
Expand Down
3 changes: 2 additions & 1 deletion apps/hosted/server/src/contracts/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
import { RequiredAction } from "./authorization.ts";
/** Organization-authorized management of ordinary app webhook subscriptions. */
import { Schema } from "effect";
Expand All @@ -24,7 +25,7 @@ const app = { organization: OrganizationReference, app: AppId };
const target = { ...app, subscription: WebhookId };
const source = { ...app, account: AccountId };
const path = "/api/organizations/:organization/apps/:app";
const errors = [...WebhookErrors, OrganizationForbidden] as const;
const errors = [...WebhookErrors, ConnectionAccountUnlinked, OrganizationForbidden] as const;
/**
* Subscriptions and webhook sources run as their actor. Reads and changes of one's own need app
* use; changing someone else's needs app management. Creating one runs it as the caller. Every
Expand Down
9 changes: 7 additions & 2 deletions apps/hosted/server/src/contracts/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionAccountUnlinked } from "./app-accounts.ts";
/** Organization authorization surrounds app-scoped workflow lifecycle operations. */
import { withLegacyKeys } from "@executor-js/utils/legacy-keys";
import { Schema } from "effect";
Expand All @@ -21,8 +22,12 @@ import {
} from "./organization.ts";
const app = { organization: OrganizationReference, app: AppId };
const run = { ...app, run: WorkflowRunId };
const errors = [...WorkflowErrors, OrganizationForbidden] as const;
const definitionErrors = [...WorkflowDefinitionErrors, OrganizationForbidden] as const;
const errors = [...WorkflowErrors, ConnectionAccountUnlinked, OrganizationForbidden] as const;
const definitionErrors = [
...WorkflowDefinitionErrors,
ConnectionAccountUnlinked,
OrganizationForbidden,
] as const;
const path = "/api/organizations/:organization/apps/:app";
/**
* Members who can use the app read definitions and start runs with their accounts. Runs belong to
Expand Down
33 changes: 30 additions & 3 deletions apps/hosted/server/src/implementation/app-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/
import {
AccountId,
CurrentCaller,
type LinkedAccounts,
AccountNotFound,
AppId,
StorageError,
Expand All @@ -22,12 +24,12 @@ import {
type Executor,
type WebhookSourceConfig,
} from "@executor-js/sdk/core";
import { permitsAccounts, permittedTargets } from "@executor-js/authorization";
import { permitsAccounts, permitsApp, permittedTargets } from "@executor-js/authorization";
import { Effect, Schema } from "effect";
import type { SqlClient } from "effect/sql";
import { CurrentUserId } from "../contracts/auth.ts";
import { CurrentAuthorization } from "../contracts/authorization.ts";
import { RequirementMismatch } from "../contracts/app-accounts.ts";
import { ConnectionAccountUnlinked, RequirementMismatch } from "../contracts/app-accounts.ts";
import { OrganizationForbidden } from "../contracts/organization.ts";
import {
AppAccountReads,
Expand Down Expand Up @@ -164,6 +166,29 @@ const callerRequirements = (version: AppVersion, links?: CallerLinks) =>
};
});

/**
* A connection pinned only to accounts cannot run with zero matching user links, even for
* many(). Check before resolving plain requirements too, and before MCP's accountless shortcut.
* Only name IDs in the caller's own connection policy, never look up another person's account.
*/
export const requireConnectionAccounts = (app: AppId, linked: LinkedAccounts) =>
Effect.gen(function* () {
const policy = yield* CurrentAuthorization;
const caller = yield* CurrentCaller;
if (caller?.provenance?.connectionId === undefined || !permitsApp(policy, app)) return;
const targets = permittedTargets(policy.tools, app);
if (targets === undefined || targets.app || Object.keys(linked).length === 0) return;
const accounts = [...targets.accounts];
const first = accounts[0];
if (
first !== undefined &&
!Object.values(linked).some((requirement) =>
requirement.accounts.some((account) => targets.accounts.has(account.id)),
)
)
return yield* new ConnectionAccountUnlinked({ app, accounts: [first, ...accounts.slice(1)] });
});

/**
* The accounts an operation the caller starts runs with, for every requirement of the version,
* resolved by the shared rules: app requirements from the app links, user requirements from the
Expand All @@ -172,10 +197,12 @@ const callerRequirements = (version: AppVersion, links?: CallerLinks) =>
*/
export const versionAccounts = (version: AppVersion, links?: CallerLinks) =>
Effect.gen(function* () {
const requirements = yield* callerRequirements(version, links);
yield* requireConnectionAccounts(version.app, requirements.userLinks);
const resolved = yield* resolveLinkedAccounts({
app: version.app,
version: version.version,
...(yield* callerRequirements(version, links)),
...requirements,
});
const policy = yield* CurrentAuthorization;
if (!permitsAccounts(policy, version.app, userAccountIds(resolved)))
Expand Down
8 changes: 6 additions & 2 deletions apps/hosted/server/src/implementation/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "../contracts/product-analytics.ts";
import { McpSchema } from "effect/ai";
import { authorizeApp, authorizeSavedTool, authorizeTool } from "./authorization.ts";
import { callerLinkedAccounts, linkedAccounts } from "./app-accounts.ts";
import { callerLinkedAccounts, linkedAccounts, requireConnectionAccounts } from "./app-accounts.ts";
import { GrantForbidden, untargetedLinkRefusal } from "@executor-js/mcp-auth";
import {
permittedAppIds,
Expand Down Expand Up @@ -189,13 +189,15 @@ export const hostedMcpBackend = Effect.gen(function* () {
)
? yield* discoveryAccounts
: [];
return linkedRequirements(
const accounts = linkedRequirements(
requirements.accounts,
"user",
linked
.filter((account) => account.app === app.id)
.map((link) => ({ requirement: link.requirement, account: link })),
);
yield* requireConnectionAccounts(app.id, accounts);
return accounts;
});
const backend = {
eventDefinitions: (input) =>
Expand Down Expand Up @@ -245,6 +247,8 @@ export const hostedMcpBackend = Effect.gen(function* () {
OrganizationForbidden: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
AppNotFound: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
AccountRequired: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
ConnectionAccountUnlinked: () =>
Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
AppNotDeployed: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
VersionNotFound: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })),
StorageError: () => Effect.fail(new ElicitationFailed({ reason: "transport" })),
Expand Down
1 change: 1 addition & 0 deletions apps/hosted/web/src/contracts/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const errorMessage = Match.type<HostedError>().pipe(
})[reason],
ScheduleNotFound: () => "This schedule or run is no longer available.",
ConnectionNotFound: () => "This connection was revoked or no longer exists.",
ConnectionAccountUnlinked: (error) => error.description,
ConnectionIdTaken: () => "This connection could not be created. Close the form and try again.",
ConnectedAgentNotFound: () => "This agent is no longer connected.",
ConnectionKeyNotFound: () => "This key was already revoked.",
Expand Down
24 changes: 23 additions & 1 deletion e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6947,6 +6947,28 @@ export const scenarios = plan({
local: na("Local connections are covered by the local scoped connection scenario."),
},
},
connectionAccountUnlinkedMany: {
fixtures: "actors",
file: "connection-account-unlinked.spec.ts",
title:
"Pinned many-account connections report unlinked accounts and recover without changing organization access",
targets: {
"self-host": scheduled,
cloud: scheduled,
local: na("Hosted member accounts and authorization."),
},
},
connectionAccountUnlinkedPlain: {
fixtures: "actors",
file: "connection-account-unlinked.spec.ts",
title:
"Pinned plain-account connections report unlinked accounts and recover without changing organization access",
targets: {
"self-host": scheduled,
cloud: scheduled,
local: na("Hosted member accounts and authorization."),
},
},
scopedConnectionAccounts: {
fixtures: "actors",
file: "mcp-connections.spec.ts",
Expand All @@ -6961,7 +6983,7 @@ export const scenarios = plan({
fixtures: "actors",
file: "mcp-connections.spec.ts",
title:
"a scoped connection refuses a call that runs with an account linked in place of the granted one during an execution, as the grant not allowing that account",
"a scoped connection refuses a call after its pinned account is replaced during an execution, as an unlinked connection account",
targets: {
"self-host": scheduled,
cloud: scheduled,
Expand Down
Loading
Loading