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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint
CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id");
7 changes: 7 additions & 0 deletions apps/cloud/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
"when": 1778192434062,
"tag": "0014_repair_openapi_oauth_cutover_residue",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1778192434063,
"tag": "0015_add_credential_binding_secret_scope",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions apps/cloud/src/services/executor-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const credential_binding = pgTable(
kind: text("kind").notNull(),
text_value: text("text_value"),
secret_id: text("secret_id"),
secret_scope_id: text("secret_scope_id"),
connection_id: text("connection_id"),
created_at: timestamp("created_at").notNull(),
updated_at: timestamp("updated_at").notNull(),
Expand All @@ -161,6 +162,7 @@ export const credential_binding = pgTable(
index("credential_binding_slot_key_idx").on(table.slot_key),
index("credential_binding_kind_idx").on(table.kind),
index("credential_binding_secret_id_idx").on(table.secret_id),
index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
index("credential_binding_connection_id_idx").on(table.connection_id),
],
);
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/services/sources-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ describe("sources api (HTTP)", () => {
value: {
kind: "secret",
secretId: SecretId.make("alice_pat"),
secretScopeId: ScopeId.make(aliceScope),
},
}),
);
Expand Down Expand Up @@ -858,6 +859,7 @@ describe("sources api (HTTP)", () => {
value: {
kind: "secret",
secretId: SecretId.make("bob_pat"),
secretScopeId: ScopeId.make(bobScope),
},
}),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint
CREATE INDEX `credential_binding_secret_scope_id_idx` ON `credential_binding` (`secret_scope_id`);
7 changes: 7 additions & 0 deletions apps/local/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
"when": 1778192434062,
"tag": "0009_repair_openapi_oauth_cutover_residue",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1778192434063,
"tag": "0010_add_credential_binding_secret_scope",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions apps/local/src/server/executor-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const credential_binding = sqliteTable(
kind: text("kind").notNull(),
text_value: text("text_value"),
secret_id: text("secret_id"),
secret_scope_id: text("secret_scope_id"),
connection_id: text("connection_id"),
created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
Expand All @@ -150,6 +151,7 @@ export const credential_binding = sqliteTable(
index("credential_binding_slot_key_idx").on(table.slot_key),
index("credential_binding_kind_idx").on(table.kind),
index("credential_binding_secret_id_idx").on(table.secret_id),
index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
index("credential_binding_connection_id_idx").on(table.connection_id),
],
);
Expand Down
4 changes: 1 addition & 3 deletions apps/local/src/server/migrate-oauth-connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
);
`);

db.prepare(
"INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)",
).run(
db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run(
"dealcloud_api",
"org-1",
JSON.stringify({
Expand Down
47 changes: 47 additions & 0 deletions notes/product-scope-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Product Scope Language

Executor has a real scope model, but the product should mostly avoid saying
"scope" to users. Use ownership and usage language instead.

## Product Terms

- **Personal**: only this user can use or update the credential/connection.
- **Organization**: everyone with access to the source can use the shared
credential/connection.
- **Source owner**: where the source definition and shared auth method live.
This is usually implicit from the current page/context and should not be
shown as debug information.
- **Used by**: who uses a specific credential value for a shared auth slot.
- **Saved to**: where a newly created secret or OAuth token/connection is
stored.

## UI Rules

- Communicate source auth as two separate choices:
- the shared authentication method for the source, such as bearer header,
query parameter, or OAuth;
- the credential/connection value used for that method.
- Do not imply users can change the auth method per person when the backend only
allows credential values to vary per scope.
- Put secret storage choices in the new-secret flow, because choosing Personal
or Organization there creates a reusable secret at that ownership level.
- Put credential usage choices next to the credential picker as **Used by**,
because attaching a secret to a source slot is separate from where the secret
itself is stored.
- Put OAuth token/connection storage next to **Connect via OAuth** as **Token
saved to**. This is independent from OAuth client ID/client secret storage.
- Secret lists should show secrets from all visible ownership levels with a
Personal/Organization badge.

## Preferred Copy

- Use "Personal" and "Organization" for selectors.
- Use "Used by" for source credential bindings.
- Use "Save secret to" in secret creation.
- Use "Token saved to" for OAuth sign-in results.
- Use "Add without credentials" whenever the source can be added with missing
initial credential values, not only for OAuth.

Avoid copy like "scope", "target scope", "source scope", "binding scope", or
"credential target scope" in product UI unless it is explicitly a developer or
debug surface.
4 changes: 3 additions & 1 deletion packages/core/sdk/src/core-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const coreSchema = {
kind: { type: credentialBindingKinds, required: true, index: true },
text_value: { type: "string", required: false },
secret_id: { type: "string", required: false, index: true },
secret_scope_id: { type: "string", required: false, index: true },
connection_id: { type: "string", required: false, index: true },
created_at: { type: "date", required: true },
updated_at: { type: "date", required: true },
Expand Down Expand Up @@ -244,7 +245,7 @@ export type ConnectionRow = InferDBFieldsOutput<CoreSchema["connection"]["fields
type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>;
type CredentialBindingRowBase = Omit<
CredentialBindingRowFields,
"kind" | "text_value" | "secret_id" | "connection_id"
"kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id"
>;

export type CredentialBindingRow = CredentialBindingRowBase &
Expand All @@ -256,6 +257,7 @@ export type CredentialBindingRow = CredentialBindingRowBase &
| {
kind: "secret";
secret_id: string;
secret_scope_id?: string;
}
| {
kind: "connection";
Expand Down
128 changes: 127 additions & 1 deletion packages/core/sdk/src/credential-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@ describe("credential bindings", () => {
sourceId: TEST_SOURCE_ID,
sourceScope: harness.scopes.org.id,
slotKey: "oauth.connection",
value: { kind: "connection", connectionId: ConnectionId.make("oauth-connection") },
value: {
kind: "connection",
connectionId: ConnectionId.make("oauth-connection"),
},
});

const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]);
Expand Down Expand Up @@ -608,4 +611,127 @@ describe("credential bindings", () => {
expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);
}),
);

it.effect("a personal binding can point at an organization-owned secret", () =>
Effect.gen(function* () {
const harness = makeHarness();
const orgExecutor = yield* harness.create([harness.scopes.org]);
yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);
yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org");

const userExecutor = yield* harness.create([
harness.scopes.userWorkspaceA,
harness.scopes.org,
]);

const binding = yield* userExecutor.credentialBindings.set({
targetScope: harness.scopes.userWorkspaceA.id,
pluginId: TEST_PLUGIN_ID,
sourceId: TEST_SOURCE_ID,
sourceScope: harness.scopes.org.id,
slotKey: TEST_SLOT,
value: {
kind: "secret",
secretId: SecretId.make("shared-token-id"),
secretScopeId: harness.scopes.org.id,
},
});

expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id);
expect(binding.value).toMatchObject({
kind: "secret",
secretId: SecretId.make("shared-token-id"),
secretScopeId: harness.scopes.org.id,
});

const resolved = yield* userExecutor.credentialBindings.resolve({
pluginId: TEST_PLUGIN_ID,
sourceId: TEST_SOURCE_ID,
sourceScope: harness.scopes.org.id,
slotKey: TEST_SLOT,
});

expect(resolved.status).toBe("resolved");
expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id);
}),
);

it.effect(
"removing an organization secret is blocked when a personal binding references it",
() =>
Effect.gen(function* () {
const harness = makeHarness();
const orgExecutor = yield* harness.create([harness.scopes.org]);
yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);
yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org");

const userExecutor = yield* harness.create([
harness.scopes.userWorkspaceA,
harness.scopes.org,
]);
yield* userExecutor.credentialBindings.set({
targetScope: harness.scopes.userWorkspaceA.id,
pluginId: TEST_PLUGIN_ID,
sourceId: TEST_SOURCE_ID,
sourceScope: harness.scopes.org.id,
slotKey: TEST_SLOT,
value: {
kind: "secret",
secretId: SecretId.make("shared-token-id"),
secretScopeId: harness.scopes.org.id,
},
});

const result = yield* Effect.result(
userExecutor.secrets.remove(
new RemoveSecretInput({
id: SecretId.make("shared-token-id"),
targetScope: harness.scopes.org.id,
}),
),
);

expect(Result.isFailure(result)).toBe(true);
if (!Result.isFailure(result)) return;
expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);
}),
);

it.effect("rejects an organization binding to a personal secret", () =>
Effect.gen(function* () {
const harness = makeHarness();
const orgExecutor = yield* harness.create([harness.scopes.org]);
yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);

const userExecutor = yield* harness.create([
harness.scopes.userWorkspaceA,
harness.scopes.org,
]);
yield* setSecret(
userExecutor,
harness.scopes.userWorkspaceA.id,
"personal-token",
"sk-user-a",
);

const result = yield* Effect.result(
userExecutor.credentialBindings.set({
targetScope: harness.scopes.org.id,
pluginId: TEST_PLUGIN_ID,
sourceId: TEST_SOURCE_ID,
sourceScope: harness.scopes.org.id,
slotKey: TEST_SLOT,
value: {
kind: "secret",
secretId: SecretId.make("personal-token"),
secretScopeId: harness.scopes.userWorkspaceA.id,
},
}),
);

expect(Result.isFailure(result)).toBe(true);
if (!Result.isFailure(result)) return;
expect(Predicate.isTagged("StorageError")(result.failure)).toBe(true);
}),
);
});
12 changes: 11 additions & 1 deletion packages/core/sdk/src/credential-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const CredentialBindingValue = Schema.Union([
Schema.Struct({
kind: Schema.Literal("secret"),
secretId: SecretId,
secretScopeId: Schema.optional(ScopeId),
}),
Schema.Struct({
kind: Schema.Literal("connection"),
Expand All @@ -36,6 +37,14 @@ export class ConfiguredCredentialBinding extends Schema.Class<ConfiguredCredenti
export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]);
export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type;

export const ScopedSecretCredentialInput = Schema.Struct({
secretId: Schema.String,
prefix: Schema.optional(Schema.String),
targetScope: ScopeId,
secretScopeId: Schema.optional(ScopeId),
});
export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type;

export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>(
"CredentialBindingRef",
)({
Expand Down Expand Up @@ -172,9 +181,10 @@ export const credentialBindingValueFromRow = (row: CredentialBindingRow): Creden
kind: "text" as const,
text: text_value,
})),
Match.when({ kind: "secret" }, ({ secret_id }) => ({
Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({
kind: "secret" as const,
secretId: SecretId.make(secret_id),
secretScopeId: ScopeId.make(secret_scope_id ?? scope_id),
})),
Match.when({ kind: "connection" }, ({ connection_id }) => ({
kind: "connection" as const,
Expand Down
Loading
Loading