History

sdk(secrets): fold per-scope routing-table lookup into one IN query

Reads used to walk the scope stack with a findOne per scope, short-circuiting on first hit. That's O(N) DB hops on a miss where N = scope-stack depth. Swap for a single findMany by id (the scope adapter injects scope_id IN (scopes) automatically) and sort the resulting rows in-process by innermost-first. Provider calls stay sequential, bounded by the number of registered rows for the id (usually 0 or 1), not by scope depth. Adds a test covering innermost-shadows-outer ordering.

Rhys Sullivan committed 6 months ago

Commit c75d1041 parent d4ec7112 files · +72 −21Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/executor.test.ts +42 −0
  2. packages/core/sdk/src/executor.ts +30 −21
packages/core/sdk/src/executor.test.ts+42−0
@@ -933,4 +933,46 @@ describe("tenant isolation (SDK)", () => { · 932 unmodified lines
933 expect(value).toBeNull();933 expect(value).toBeNull();
934 }),934 }),
935 );935 );
936
937 it.effect("secrets.get — innermost scope shadows outer on same id", () =>
938 Effect.gen(function* () {
939 const plugins = [tenantPlugin()] as const;
940 const schema = collectSchemas(plugins);
941 const adapter = makeMemoryAdapter({ schema });
942 const blobs = makeInMemoryBlobStore();
943
944 const innerScope = ScopeId.make("user-org:u1:o1");
945 const outerScope = ScopeId.make("o1");
946
947 const exec = yield* createExecutor({
948 scopes: [
949 new Scope({ id: innerScope, name: "inner", createdAt: new Date() }),
950 new Scope({ id: outerScope, name: "outer", createdAt: new Date() }),
951 ],
952 adapter,
953 blobs,
954 plugins,
955 });
956
957 yield* exec.secrets.set(
958 new SetSecretInput({
959 id: SecretId.make("token"),
960 scope: outerScope,
961 name: "org token",
962 value: "org-value",
963 }),
964 );
965 yield* exec.secrets.set(
966 new SetSecretInput({
967 id: SecretId.make("token"),
968 scope: innerScope,
969 name: "user token",
970 value: "user-value",
971 }),
972 );
973
974 const value = yield* exec.secrets.get("token");
975 expect(value).toBe("user-value");
976 }),
977 );
936});978});
packages/core/sdk/src/executor.ts+30−21
@@ -537,31 +537,40 @@ export const createExecutor = < · 536 unmodified lines
537 // walk because their secrets must be registered through set() to537 // walk because their secrets must be registered through set() to
538 // be known at all.538 // be known at all.
539 //539 //
540 // Multi-scope behavior: the routing-table lookup walks the scope540 // Multi-scope behavior: the routing-table lookup pulls every row
541 // stack explicitly, innermost first, so that a secret registered541 // for this id across the scope stack in a single `IN (...)` query,
542 // in a deeper scope shadows one with the same id at a shallower542 // then sorts innermost-first so a secret registered in a deeper
543 // scope (e.g. a user's personal OAuth token wins over an org-wide543 // scope shadows one with the same id at a shallower scope (e.g. a
544 // one). The provider-enumeration fallback is scope-agnostic —544 // user's personal OAuth token wins over an org-wide one). Provider
545 // providers like env or 1password don't partition their inventory545 // calls stay sequential — scope-partitioning providers (workos-vault,
546 // by executor scope.546 // 1password-per-vault) have to be asked per scope because the object
547 // name includes the scope — but they're bounded by the number of
548 // registered rows for this id, not by scope-stack depth. The
549 // provider-enumeration fallback is scope-agnostic: providers like
550 // env or 1password don't partition their inventory by executor scope.
551 const scopePrecedence = new Map<string, number>();
552 scopeIds.forEach((s, i) => scopePrecedence.set(s, i));
553
547 const secretsGet = (554 const secretsGet = (
548 id: string,555 id: string,
549 ): Effect.Effect<string | null, StorageFailure> =>556 ): Effect.Effect<string | null, StorageFailure> =>
550 Effect.gen(function* () {557 Effect.gen(function* () {
551 for (const scopeId of scopeIds) {558 // The scope-wrapped adapter injects `scope_id IN (scopeIds)`
552 const row = yield* core.findOne({559 // automatically, so we only filter by id here.
553 model: "secret",560 const rows = yield* core.findMany({
554 where: [561 model: "secret",
555 { field: "id", value: id },562 where: [{ field: "id", value: id }],
556 { field: "scope_id", value: scopeId },563 });
557 ],564 const ordered = [...rows].sort(
558 });565 (a, b) =>
559 if (row) {566 (scopePrecedence.get(a.scope_id as string) ?? Infinity) -
560 const provider = secretProviders.get(row.provider);567 (scopePrecedence.get(b.scope_id as string) ?? Infinity),
561 if (!provider) continue;568 );
562 const value = yield* provider.get(id, scopeId);569 for (const row of ordered) {
563 if (value !== null) return value;570 const provider = secretProviders.get(row.provider as string);
564 }571 if (!provider) continue;
572 const value = yield* provider.get(id, row.scope_id as string);
573 if (value !== null) return value;
565 }574 }
566 575
567 // Fallback: ask every enumerating provider in parallel. First576 // Fallback: ask every enumerating provider in parallel. First