@stigmer/server 3.36.0 → 3.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/domain/agentexecution/constants.d.ts +0 -10
  2. package/dist/domain/agentexecution/constants.d.ts.map +1 -1
  3. package/dist/domain/agentexecution/constants.js +0 -12
  4. package/dist/domain/agentexecution/constants.js.map +1 -1
  5. package/dist/domain/agentexecution/usage.d.ts +0 -2
  6. package/dist/domain/agentexecution/usage.d.ts.map +1 -1
  7. package/dist/domain/agentexecution/usage.js +12 -10
  8. package/dist/domain/agentexecution/usage.js.map +1 -1
  9. package/dist/domain/organization/controller.d.ts +4 -1
  10. package/dist/domain/organization/controller.d.ts.map +1 -1
  11. package/dist/domain/organization/controller.js +30 -8
  12. package/dist/domain/organization/controller.js.map +1 -1
  13. package/dist/domain/workflow/controller.js +2 -1
  14. package/dist/domain/workflow/controller.js.map +1 -1
  15. package/dist/domain/workflow/steps.d.ts +7 -4
  16. package/dist/domain/workflow/steps.d.ts.map +1 -1
  17. package/dist/domain/workflow/steps.js +29 -17
  18. package/dist/domain/workflow/steps.js.map +1 -1
  19. package/dist/extensions/organization-directory.d.ts +29 -6
  20. package/dist/extensions/organization-directory.d.ts.map +1 -1
  21. package/dist/extensions/organization-directory.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/pipeline/steps/guard-memory-capture.d.ts +9 -4
  26. package/dist/pipeline/steps/guard-memory-capture.d.ts.map +1 -1
  27. package/dist/pipeline/steps/guard-memory-capture.js +10 -12
  28. package/dist/pipeline/steps/guard-memory-capture.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/domain/agentexecution/__tests__/agentexecution.test.ts +2 -8
  31. package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +78 -0
  32. package/src/domain/agentexecution/constants.ts +0 -15
  33. package/src/domain/agentexecution/usage.ts +16 -13
  34. package/src/domain/organization/controller.ts +47 -10
  35. package/src/domain/workflow/__tests__/version-audit-degradation.test.ts +192 -0
  36. package/src/domain/workflow/controller.ts +2 -1
  37. package/src/domain/workflow/steps.ts +30 -18
  38. package/src/extensions/__tests__/extension-composition.test.ts +90 -18
  39. package/src/extensions/organization-directory.ts +33 -7
  40. package/src/index.ts +5 -1
  41. package/src/pipeline/steps/__tests__/guard-memory-capture.test.ts +67 -5
  42. package/src/pipeline/steps/guard-memory-capture.ts +24 -19
@@ -9,7 +9,10 @@
9
9
  * __tests__/organization.test.ts.
10
10
  *
11
11
  * getByExternalOrgId is implemented ONLY when the composed
12
- * OrganizationDirectory provides the lookup (C2, ruling Q7) — with no
12
+ * OrganizationDirectory provides the lookup (C2, ruling Q7). It answers
13
+ * only a caller who may view the identity provider the request names,
14
+ * and resolves the external id within that provider alone (see the
15
+ * handler); with no
13
16
  * directory the method stays absent from the partial service
14
17
  * implementation and ConnectRPC answers Unimplemented (the SDK
15
18
  * capability-probes this and must see UNIMPLEMENTED, not NotFound;
@@ -32,6 +35,7 @@ import type { FindApiResourcesRequest } from "@stigmer/protos/ai/stigmer/commons
32
35
  import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
33
36
  import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
34
37
  import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
38
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
35
39
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
36
40
  import {
37
41
  OrganizationListSchema,
@@ -61,7 +65,10 @@ import { newPipeline } from "../../pipeline/pipeline.js";
61
65
  import type { PipelineStep } from "../../pipeline/pipeline.js";
62
66
  import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
63
67
  import { RequestContext } from "../../pipeline/request-context.js";
64
- import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
68
+ import {
69
+ authorizeResolvedResource,
70
+ newAuthorizeStep,
71
+ } from "../../pipeline/steps/authorize.js";
65
72
  import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-labels.js";
66
73
  import { newBuildNewStateStep } from "../../pipeline/steps/defaults.js";
67
74
  import { newBuildUpdateStateStep } from "../../pipeline/steps/build-update-state.js";
@@ -129,7 +136,7 @@ export function registerOrganizationServices(
129
136
  // partial implementation and ConnectRPC answers Unimplemented (the
130
137
  // capability-probed pin; see the module header).
131
138
  const externalLookup =
132
- deps.organizationDirectory?.getOrganizationIdByExternalOrgId?.bind(
139
+ deps.organizationDirectory?.lookupExternalOrganization?.bind(
133
140
  deps.organizationDirectory,
134
141
  );
135
142
  router.service(OrganizationQueryController, {
@@ -548,15 +555,22 @@ async function findMyOrganizations(
548
555
 
549
556
  /**
550
557
  * GetByExternalOrgId — registered only with a directory lookup composed
551
- * (ruling Q7). The chain stays controller-owned: Authorize → validate →
552
- * directory resolves the external id → LoadTarget-shaped store read.
553
- * Both miss arms (no mapping, mapped row gone) answer the same NotFound
554
- * — an external caller cannot distinguish a stale mapping from a
555
- * missing org.
558
+ * (ruling Q7). The chain stays controller-owned: Authorize (a no-op for
559
+ * this skip-annotated method) → validate → the directory resolves the
560
+ * named identity provider and the external id within it → `can_view` on
561
+ * that provider → LoadTarget-shaped store read.
562
+ *
563
+ * The contract's check is the provider's, so it runs here, on the id the
564
+ * directory resolved mid-chain (`authorizeResolvedResource`, the pattern
565
+ * for skip lanes the annotation cannot express), and before the answer
566
+ * says whether a mapping exists: a caller who may not view the provider
567
+ * learns nothing about its tenants. Every miss arm (no such provider, no
568
+ * mapping, mapped row gone) answers the same NotFound, so an external
569
+ * caller cannot tell a stale mapping from a missing org.
556
570
  */
557
571
  async function getByExternalOrgId(
558
572
  deps: OrganizationControllerDeps,
559
- lookup: (externalOrgId: string) => Promise<string | undefined>,
573
+ lookup: NonNullable<OrganizationDirectory["lookupExternalOrganization"]>,
560
574
  input: OrganizationExternalLookup,
561
575
  ctx: HandlerContext,
562
576
  ): Promise<Organization> {
@@ -582,7 +596,27 @@ async function getByExternalOrgId(
582
596
  if (input.externalOrgId === "") {
583
597
  throw invalidArgumentError("external_org_id is required");
584
598
  }
585
- const orgId = await lookup(input.externalOrgId);
599
+ const found = await lookup(
600
+ {
601
+ org: input.identityProviderRef?.org ?? "",
602
+ slug: input.identityProviderRef?.slug ?? "",
603
+ },
604
+ input.externalOrgId,
605
+ );
606
+ if (found.kind === "no-identity-provider") {
607
+ throw notFoundError("Organization", input.externalOrgId);
608
+ }
609
+ await authorizeResolvedResource(
610
+ deps.authorizer,
611
+ reqCtx.callerIdentity,
612
+ {
613
+ permission: IamPermission.can_view,
614
+ resourceKind: ApiResourceKind.identity_provider,
615
+ resourceId: found.identityProviderId,
616
+ },
617
+ EXTERNAL_LOOKUP_DENIED_MESSAGE,
618
+ );
619
+ const orgId = found.organizationId;
586
620
  if (orgId === undefined) {
587
621
  throw notFoundError("Organization", input.externalOrgId);
588
622
  }
@@ -599,3 +633,6 @@ async function getByExternalOrgId(
599
633
  throw internalError(error, "failed to load organization");
600
634
  }
601
635
  }
636
+
637
+ /** The lookup's deny copy: the provider's own `get` annotation's words. */
638
+ const EXTERNAL_LOOKUP_DENIED_MESSAGE = "unauthorized to view identity provider";
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Pins SaveVersionAudit's safe-degradation arms at step level — the
3
+ * composed suite (workflow.test.ts) cannot make the real store fail
4
+ * selectively:
5
+ *
6
+ * - archive (saveAudit) failure clears status.version_hash and
7
+ * metadata.version.id, so the head never references an unresolvable
8
+ * audit entry, and the tag is never assigned;
9
+ * - tag assignment (setAuditTag) failure clears the live
10
+ * metadata.version.tag, so the head never advertises a tag the audit
11
+ * column cannot resolve (stigmer/stigmer#855);
12
+ * - persistOnRevert flushes either revert to the stored row, because on
13
+ * create no Persist step follows this one; without it (update) the
14
+ * chain's own Persist flushes the revert and the step writes nothing;
15
+ * - a failing re-persist is logged, never a failed apply.
16
+ */
17
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
22
+ import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
23
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
24
+
25
+ import { createLogger } from "../../../boot/logger.js";
26
+ import { RequestContext } from "../../../pipeline/request-context.js";
27
+ import { AuditNotFoundError } from "../../../store/interface.js";
28
+ import type { Store } from "../../../store/interface.js";
29
+ import { newSaveVersionAuditStep } from "../steps.js";
30
+
31
+ const silentLogger = createLogger({
32
+ level: "error",
33
+ pretty: false,
34
+ write: () => {},
35
+ });
36
+
37
+ const HASH = "c".repeat(64);
38
+
39
+ /** What the stored row carried at each re-persist. */
40
+ interface PersistedHead {
41
+ versionHash: string;
42
+ versionId: string;
43
+ tag: string;
44
+ }
45
+
46
+ interface FakeAuditStore {
47
+ saveAuditError: Error | undefined;
48
+ setAuditTagError: Error | undefined;
49
+ saveResourceError: Error | undefined;
50
+ setAuditTagCalls: number;
51
+ persisted: PersistedHead[];
52
+ }
53
+
54
+ /** A store exposing ONLY the members the step touches; the cast is the seam. */
55
+ function fakeStore(overrides?: Partial<FakeAuditStore>): {
56
+ store: Store;
57
+ state: FakeAuditStore;
58
+ } {
59
+ const state: FakeAuditStore = {
60
+ saveAuditError: undefined,
61
+ setAuditTagError: undefined,
62
+ saveResourceError: undefined,
63
+ setAuditTagCalls: 0,
64
+ persisted: [],
65
+ ...overrides,
66
+ };
67
+ const store = {
68
+ async getAuditByHash(): Promise<never> {
69
+ throw new AuditNotFoundError("not archived");
70
+ },
71
+ async saveAudit(): Promise<void> {
72
+ if (state.saveAuditError !== undefined) {
73
+ throw state.saveAuditError;
74
+ }
75
+ },
76
+ async setAuditTag(): Promise<void> {
77
+ state.setAuditTagCalls += 1;
78
+ if (state.setAuditTagError !== undefined) {
79
+ throw state.setAuditTagError;
80
+ }
81
+ },
82
+ async saveResource(
83
+ _k: unknown,
84
+ _id: unknown,
85
+ _schema: unknown,
86
+ wf: Workflow,
87
+ ): Promise<void> {
88
+ state.persisted.push({
89
+ versionHash: wf.status?.versionHash ?? "",
90
+ versionId: wf.metadata?.version?.id ?? "",
91
+ tag: wf.metadata?.version?.tag ?? "",
92
+ });
93
+ if (state.saveResourceError !== undefined) {
94
+ throw state.saveResourceError;
95
+ }
96
+ },
97
+ } as unknown as Store;
98
+ return { store, state };
99
+ }
100
+
101
+ function contextWithWorkflow(
102
+ tag: string,
103
+ ): RequestContext<typeof WorkflowSchema> {
104
+ return new RequestContext(
105
+ WorkflowSchema,
106
+ create(WorkflowSchema, {
107
+ metadata: {
108
+ id: "wfl_test",
109
+ org: "acme",
110
+ version: { id: HASH, tag },
111
+ },
112
+ status: { versionHash: HASH },
113
+ }),
114
+ testCallerIdentity(),
115
+ ApiResourceKind.workflow,
116
+ );
117
+ }
118
+
119
+ /** The create chain's arguments: isCreate, and no Persist follows. */
120
+ function createStep(store: Store) {
121
+ return newSaveVersionAuditStep(store, silentLogger, true, true);
122
+ }
123
+
124
+ describe("SaveVersionAudit — safe degradation", () => {
125
+ it("tag failure on create clears the live tag and re-persists the row tagless", async () => {
126
+ const { store, state } = fakeStore({
127
+ setAuditTagError: new Error("tag column locked"),
128
+ });
129
+ const ctx = contextWithWorkflow("stable");
130
+
131
+ await createStep(store).execute(ctx);
132
+
133
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
134
+ expect(state.persisted).toEqual([
135
+ { versionHash: HASH, versionId: HASH, tag: "" },
136
+ ]);
137
+ });
138
+
139
+ it("tag failure without persistOnRevert clears the live tag and leaves the write to the chain's Persist", async () => {
140
+ const { store, state } = fakeStore({
141
+ setAuditTagError: new Error("tag column locked"),
142
+ });
143
+ const ctx = contextWithWorkflow("stable");
144
+
145
+ await newSaveVersionAuditStep(store, silentLogger, true, false).execute(
146
+ ctx,
147
+ );
148
+
149
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
150
+ expect(state.persisted).toEqual([]);
151
+ });
152
+
153
+ it("archive failure on create clears the hash and version id, re-persists, and never assigns the tag", async () => {
154
+ const { store, state } = fakeStore({
155
+ saveAuditError: new Error("disk full"),
156
+ });
157
+ const ctx = contextWithWorkflow("stable");
158
+
159
+ await createStep(store).execute(ctx);
160
+
161
+ expect(ctx.newState.status?.versionHash).toBe("");
162
+ expect(ctx.newState.metadata?.version?.id).toBe("");
163
+ expect(state.setAuditTagCalls).toBe(0);
164
+ expect(state.persisted).toEqual([
165
+ { versionHash: "", versionId: "", tag: "stable" },
166
+ ]);
167
+ });
168
+
169
+ it("a failing re-persist after a tag failure is logged, and the step does not throw", async () => {
170
+ const { store, state } = fakeStore({
171
+ setAuditTagError: new Error("tag column locked"),
172
+ saveResourceError: new Error("connection reset"),
173
+ });
174
+ const ctx = contextWithWorkflow("stable");
175
+
176
+ await expect(createStep(store).execute(ctx)).resolves.toBeUndefined();
177
+
178
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
179
+ expect(state.persisted).toHaveLength(1);
180
+ });
181
+
182
+ it("a clean archive and tag write nothing beyond the audit row", async () => {
183
+ const { store, state } = fakeStore();
184
+ const ctx = contextWithWorkflow("stable");
185
+
186
+ await createStep(store).execute(ctx);
187
+
188
+ expect(ctx.newState.metadata?.version?.tag).toBe("stable");
189
+ expect(state.setAuditTagCalls).toBe(1);
190
+ expect(state.persisted).toEqual([]);
191
+ });
192
+ });
@@ -217,7 +217,8 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
217
217
  * created via the in-process client AFTER Persist (children need the
218
218
  * parent's id), status.default_instance_id lands in an explicit second
219
219
  * persist, and v1 archives LAST so the snapshot captures it (the audit
220
- * step re-persists on revert because no Persist follows it here).
220
+ * step re-persists either of its reverts, a stripped hash or a cleared
221
+ * tag, because no Persist follows it here).
221
222
  */
222
223
  async function createWorkflow(
223
224
  deps: WorkflowControllerDeps,
@@ -354,10 +354,13 @@ export function newPopulateVersionHashStep(
354
354
  * duplicate row beats a failed apply.
355
355
  *
356
356
  * On archive failure the version hash is stripped from the workflow so a
357
- * set hash always resolves to an audit entry (the revert invariant);
358
- * persistOnRevert re-persists when this step runs AFTER the final persist
359
- * (the create path archives last so default_instance_id is captured; the
360
- * update path has a Persist step following that flushes the revert).
357
+ * set hash always resolves to an audit entry (the revert invariant); on
358
+ * tag-assignment failure the live tag is cleared so the head never
359
+ * advertises a tag the audit column cannot resolve (stigmer/stigmer#855).
360
+ * persistOnRevert flushes either revert to the stored row, for a chain in
361
+ * which no Persist step follows this one (the create path archives last so
362
+ * default_instance_id is captured; the update path has a Persist step
363
+ * following that flushes the revert itself).
361
364
  */
362
365
  export function newSaveVersionAuditStep(
363
366
  store: Store,
@@ -385,6 +388,27 @@ export function newSaveVersionAuditStep(
385
388
  ? ctx.apiResourceKind
386
389
  : ApiResourceKind.workflow;
387
390
 
391
+ // A revert changes the head after the chain's last write, so it
392
+ // reaches the stored row only through this re-persist. A failure here
393
+ // is logged, never a failed apply: the response is already consistent
394
+ // and the next apply rewrites the row.
395
+ const flushRevert = async (revert: string): Promise<void> => {
396
+ if (!persistOnRevert) {
397
+ return;
398
+ }
399
+ try {
400
+ await store.saveResource(kind, workflowId, WorkflowSchema, wf);
401
+ } catch (persistError) {
402
+ logger.error(`failed to re-persist workflow after ${revert}`, {
403
+ error:
404
+ persistError instanceof Error
405
+ ? persistError.message
406
+ : String(persistError),
407
+ workflowId,
408
+ });
409
+ }
410
+ };
411
+
388
412
  let alreadyArchived = false;
389
413
  try {
390
414
  await store.getAuditByHash(kind, workflowId, versionHash, WorkflowSchema);
@@ -428,20 +452,7 @@ export function newSaveVersionAuditStep(
428
452
  wf.metadata.version.id = "";
429
453
  }
430
454
  ctx.setNewState(wf);
431
-
432
- if (persistOnRevert) {
433
- try {
434
- await store.saveResource(kind, workflowId, WorkflowSchema, wf);
435
- } catch (persistError) {
436
- logger.error("failed to re-persist workflow after reverting version hash", {
437
- error:
438
- persistError instanceof Error
439
- ? persistError.message
440
- : String(persistError),
441
- workflowId,
442
- });
443
- }
444
- }
455
+ await flushRevert("reverting version hash");
445
456
  return;
446
457
  }
447
458
  }
@@ -468,6 +479,7 @@ export function newSaveVersionAuditStep(
468
479
  if (wf.metadata?.version !== undefined) {
469
480
  wf.metadata.version.tag = "";
470
481
  ctx.setNewState(wf);
482
+ await flushRevert("clearing its tag");
471
483
  }
472
484
  }
473
485
  }
@@ -8,7 +8,10 @@
8
8
  * services), and the registry-declared edition answers on getServerInfo.
9
9
  * Later entries add their own composed arms below: caller guards
10
10
  * (20260902.02), the require-authentication posture (20260904.02), the
11
- * O5 drivers, the O4 slots and hooks, the C2 tuple lifecycle.
11
+ * O5 drivers, the O4 slots and hooks, the C2 tuple lifecycle, and the
12
+ * organization directory's external-id lookup: scoped to the identity
13
+ * provider the request names, and answered only to a caller who may view
14
+ * that provider, with one NotFound for every miss.
12
15
  *
13
16
  * The empty-set arm — no extensions composed, wire behavior byte-identical
14
17
  * to before the parameter existed — is pinned where it belongs: the
@@ -43,6 +46,7 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
43
46
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
44
47
  import { ApiKeyCommandController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/command_pb";
45
48
  import { ApiKeyQueryController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/query_pb";
49
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
46
50
  import {
47
51
  PlatformQueryController,
48
52
  ServerEdition,
@@ -62,6 +66,7 @@ import type { ComposedServer } from "../../boot/compose.js";
62
66
  import { createLogger } from "../../boot/logger.js";
63
67
  import { AUTHENTICATION_TOKEN_MISSING_MESSAGE } from "../../pipeline/interceptors/auth.js";
64
68
  import type { PipelineStep } from "../../pipeline/pipeline.js";
69
+ import type { AuthzCheck } from "../authorizer.js";
65
70
  import type { GateSlotName } from "../gate-slots.js";
66
71
  import type { OrganizationDirectory } from "../organization-directory.js";
67
72
  import type {
@@ -73,6 +78,16 @@ import type {
73
78
  import type { AgentExecutionStatusTransition } from "../status-hooks.js";
74
79
  import type { ServerExtension } from "../registry.js";
75
80
 
81
+ /** The refusal a call answered; a call that succeeds fails the case. */
82
+ async function refusalOf(work: Promise<unknown>): Promise<ConnectError> {
83
+ try {
84
+ await work;
85
+ } catch (error) {
86
+ return ConnectError.from(error);
87
+ }
88
+ throw new Error("the call was admitted");
89
+ }
90
+
76
91
  const BILLING_PROCEDURE =
77
92
  "/ai.stigmer.billing.v1.BillingQueryController/getBillingAccount";
78
93
 
@@ -1019,16 +1034,46 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
1019
1034
  // The directory answers are test-mutable so each case can stage its
1020
1035
  // own world without a second composed server.
1021
1036
  const myOrgIds: string[] = [];
1037
+ // Providers by `org/slug` → id, and mappings by `providerId:externalId`:
1038
+ // the directory scopes every lookup to the provider the request names.
1039
+ const providers = new Map<string, string>([
1040
+ ["c2seededorg/test-idp", "idp_viewable"],
1041
+ ["c2seededorg/other-idp", "idp_other"],
1042
+ ["c2seededorg/hidden-idp", "idp_hidden"],
1043
+ ]);
1022
1044
  const externalOrgMap = new Map<string, string>();
1023
1045
  const fakeDirectory: OrganizationDirectory = {
1024
1046
  refusesEnumeration: true,
1025
1047
  listMyOrganizationIds: async () => [...myOrgIds],
1026
- getOrganizationIdByExternalOrgId: async (externalOrgId) =>
1027
- externalOrgMap.get(externalOrgId),
1048
+ lookupExternalOrganization: async (ref, externalOrgId) => {
1049
+ const providerId = providers.get(`${ref.org}/${ref.slug}`);
1050
+ return providerId === undefined
1051
+ ? { kind: "no-identity-provider" }
1052
+ : {
1053
+ kind: "resolved",
1054
+ identityProviderId: providerId,
1055
+ organizationId: externalOrgMap.get(`${providerId}:${externalOrgId}`),
1056
+ };
1057
+ },
1028
1058
  };
1059
+ // Every check is allowed except viewing the providers named here, so the
1060
+ // lookup's own authorization is the one refusal the suite can see.
1061
+ const deniedProviders = new Set<string>(["idp_hidden"]);
1062
+ const lookupChecks: AuthzCheck[] = [];
1029
1063
 
1030
1064
  const iamExtension: ServerExtension = {
1031
1065
  name: "fake-iam",
1066
+ authorizer: {
1067
+ authorize: (_caller, check) => {
1068
+ if (check.resourceKind === ApiResourceKind.identity_provider) {
1069
+ lookupChecks.push(check);
1070
+ if (deniedProviders.has(check.resourceId)) {
1071
+ return Promise.resolve({ kind: "deny", reason: "" });
1072
+ }
1073
+ }
1074
+ return Promise.resolve({ kind: "allow" });
1075
+ },
1076
+ },
1032
1077
  drivers: {
1033
1078
  resourceAuthorizationLifecycle: fakeLifecycle,
1034
1079
  organizationDirectory: fakeDirectory,
@@ -1077,7 +1122,8 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
1077
1122
  expect(event.parentLinks).toEqual([]);
1078
1123
  expect(event.caller.identityId).not.toBe("");
1079
1124
  myOrgIds.push(org.metadata?.id ?? "");
1080
- externalOrgMap.set("ext-org-42", org.metadata?.id ?? "");
1125
+ externalOrgMap.set("idp_viewable:ext-org-42", org.metadata?.id ?? "");
1126
+ externalOrgMap.set("idp_hidden:ext-org-42", org.metadata?.id ?? "");
1081
1127
  });
1082
1128
 
1083
1129
  it("agent create fires creation events for the agent AND its in-process default instance", async () => {
@@ -1236,26 +1282,52 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
1236
1282
  expect(mine.entries.map((org) => org.metadata?.id)).toEqual(myOrgIds);
1237
1283
  });
1238
1284
 
1239
- it("getByExternalOrgId registers with the directory lookup and resolves the mapping", async () => {
1285
+ it("getByExternalOrgId resolves a mapping under the named provider for a caller who may view it", async () => {
1240
1286
  const query = createClient(OrganizationQueryController, portTransport);
1241
- // identity_provider_ref is proto-required on the lookup message; the
1242
- // directory's resolution key is the (globally unique) external id.
1243
- const idpRef = { org: "c2seededorg", slug: "test-idp" };
1287
+ lookupChecks.length = 0;
1244
1288
  const org = await query.getByExternalOrgId({
1245
- identityProviderRef: idpRef,
1289
+ identityProviderRef: { org: "c2seededorg", slug: "test-idp" },
1246
1290
  externalOrgId: "ext-org-42",
1247
1291
  });
1248
1292
  expect(org.metadata?.id).toBe(myOrgIds[0]);
1293
+ expect(lookupChecks).toEqual([
1294
+ {
1295
+ permission: IamPermission.can_view,
1296
+ resourceKind: ApiResourceKind.identity_provider,
1297
+ resourceId: "idp_viewable",
1298
+ },
1299
+ ]);
1300
+ });
1249
1301
 
1250
- let missing: ConnectError | undefined;
1251
- try {
1252
- await query.getByExternalOrgId({
1253
- identityProviderRef: idpRef,
1254
- externalOrgId: "ext-org-unknown",
1255
- });
1256
- } catch (error) {
1257
- missing = ConnectError.from(error);
1302
+ it("getByExternalOrgId refuses a caller who may not view the named provider, before saying whether a mapping exists", async () => {
1303
+ const query = createClient(OrganizationQueryController, portTransport);
1304
+ for (const externalOrgId of ["ext-org-42", "ext-org-unknown"]) {
1305
+ const refused = await refusalOf(
1306
+ query.getByExternalOrgId({
1307
+ identityProviderRef: { org: "c2seededorg", slug: "hidden-idp" },
1308
+ externalOrgId,
1309
+ }),
1310
+ );
1311
+ expect(refused.code).toBe(Code.PermissionDenied);
1312
+ expect(refused.rawMessage).toBe("unauthorized to view identity provider");
1313
+ }
1314
+ });
1315
+
1316
+ it("getByExternalOrgId answers one NotFound for an unknown provider, an unmapped id, and another provider's mapping", async () => {
1317
+ const query = createClient(OrganizationQueryController, portTransport);
1318
+ for (const [slug, externalOrgId] of [
1319
+ ["no-such-idp", "ext-org-42"],
1320
+ ["test-idp", "ext-org-unknown"],
1321
+ ["other-idp", "ext-org-42"],
1322
+ ] as const) {
1323
+ const missing = await refusalOf(
1324
+ query.getByExternalOrgId({
1325
+ identityProviderRef: { org: "c2seededorg", slug },
1326
+ externalOrgId,
1327
+ }),
1328
+ );
1329
+ expect(missing.code).toBe(Code.NotFound);
1330
+ expect(missing.rawMessage).toBe(`Organization not found: ${externalOrgId}`);
1258
1331
  }
1259
- expect(missing?.code).toBe(Code.NotFound);
1260
1332
  });
1261
1333
  });
@@ -15,7 +15,11 @@
15
15
  * and the built-in directory filter to what the caller may view.
16
16
  * - `getByExternalOrgId` (capability `externalOrgLookup`): deliberately
17
17
  * absent from the OSS partial registration (UNIMPLEMENTED by
18
- * construction); the cloud resolves external IdP org ids.
18
+ * construction); the cloud resolves external IdP org ids. The lookup
19
+ * is scoped to the identity provider the request names: the directory
20
+ * resolves that provider and the external id within it, and the
21
+ * controller answers only a caller who may view the provider, so one
22
+ * integrator's tenant ids never answer another's organizations.
19
23
  *
20
24
  * No directory composed = the trusted-local behavior, byte-identical; the
21
25
  * built-in directory (authorization/organization-directory.ts) composes
@@ -47,12 +51,34 @@ export interface OrganizationDirectory {
47
51
  caller: CallerIdentity,
48
52
  ): Promise<ReadonlyArray<string> | typeof ALL_ORGANIZATIONS>;
49
53
  /**
50
- * Resolves an external IdP organization id to the org's resource id;
51
- * undefined = no mapping. When this method is present, the controller
52
- * registers `getByExternalOrgId` (its chain stays controller-owned);
53
- * when absent, the RPC stays unregistered and answers UNIMPLEMENTED.
54
+ * Resolves the identity provider a lookup names, and the external IdP
55
+ * organization id within that provider alone. When this method is
56
+ * present, the controller registers `getByExternalOrgId` (its chain,
57
+ * the provider's authorization included, stays controller-owned); when
58
+ * absent, the RPC stays unregistered and answers UNIMPLEMENTED.
54
59
  */
55
- getOrganizationIdByExternalOrgId?(
60
+ lookupExternalOrganization?(
61
+ identityProviderRef: IdentityProviderRef,
56
62
  externalOrgId: string,
57
- ): Promise<string | undefined>;
63
+ ): Promise<ExternalOrganizationLookup>;
58
64
  }
65
+
66
+ /** The identity provider a lookup names: its organization and its slug. */
67
+ export interface IdentityProviderRef {
68
+ readonly org: string;
69
+ readonly slug: string;
70
+ }
71
+
72
+ /**
73
+ * What a directory found for a lookup: no such identity provider, or the
74
+ * provider's id with the organization its external id maps to under it
75
+ * (undefined = no mapping). The controller authorizes on the provider's
76
+ * id before it reveals whether a mapping exists.
77
+ */
78
+ export type ExternalOrganizationLookup =
79
+ | { readonly kind: "no-identity-provider" }
80
+ | {
81
+ readonly kind: "resolved";
82
+ readonly identityProviderId: string;
83
+ readonly organizationId: string | undefined;
84
+ };
package/src/index.ts CHANGED
@@ -116,7 +116,11 @@ export type {
116
116
  VisibilityTupleShape,
117
117
  ResolvedParentLink,
118
118
  } from "./extensions/resource-authorization.js";
119
- export type { OrganizationDirectory } from "./extensions/organization-directory.js";
119
+ export type {
120
+ ExternalOrganizationLookup,
121
+ IdentityProviderRef,
122
+ OrganizationDirectory,
123
+ } from "./extensions/organization-directory.js";
120
124
  export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
121
125
  // The license-status seam (drivers.licenseStatus): the provider an
122
126
  // Enterprise composition registers so getLicenseStatus answers from its