History

Project enterprise-managed state onto connections and health results

Rhys Sullivan committed 2 months ago

Commit 7f0bfa11 parent 2c75d7a8 files · +66 −0Browse files

Unified · Split

8 files
  1. packages/core/api/src/connections/api.ts +5 −0
  2. packages/core/api/src/handlers/connections.ts +1 −0
  3. packages/core/api/src/handlers/oauth.ts +1 −0
  4. packages/core/api/src/oauth/api.ts +6 −0
  5. packages/core/sdk/src/connection.ts +15 −0
  6. packages/core/sdk/src/executor.ts +22 −0
  7. packages/core/sdk/src/health-check.ts +13 −0
  8. packages/react/src/api/atoms.tsx +3 −0
packages/core/api/src/connections/api.ts+5−0
@@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({ · 61 unmodified lines
6262 // Last persisted health-check verdict (written by every checkHealth run),
6363 // so the list can show alive/expired at a glance without probing.
6464 lastHealth: Schema.NullOr(HealthCheckResult),
65+ // True when the connection was minted through MCP Enterprise-Managed
66+ // Authorization: it mirrors organization policy and holds no durable local
67+ // grant, so a console must not offer to delete it. Read-only — nothing on
68+ // this surface can set it.
69+ enterpriseManaged: Schema.Boolean,
6570 });
6671
6772 const ToolResponse = Schema.Struct({
packages/core/api/src/handlers/connections.ts+1−0
@@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({ · 29 unmodified lines
3030 oauthScope: c.oauthScope ?? null,
3131 missingOAuthScopes: c.missingOAuthScopes ?? [],
3232 lastHealth: c.lastHealth ?? null,
33+ enterpriseManaged: c.enterpriseManaged ?? false,
3334 });
3435
3536 const toolToResponse = (t: Tool) => ({
packages/core/api/src/handlers/oauth.ts+1−0
@@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({ · 45 unmodified lines
4646 oauthClientOwner: c.oauthClientOwner ?? null,
4747 oauthScope: c.oauthScope ?? null,
4848 missingOAuthScopes: c.missingOAuthScopes ?? [],
49+ enterpriseManaged: c.enterpriseManaged ?? false,
4950 });
5051
5152 const startResultToResponse = (result: ConnectResult) =>
packages/core/api/src/oauth/api.ts+6−0
@@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({ · 51 unmodified lines
5252 oauthClientOwner: Schema.NullOr(Owner),
5353 oauthScope: Schema.NullOr(Schema.String),
5454 missingOAuthScopes: Schema.Array(Schema.String),
55+ // True when this connect took the enterprise-managed branch (the ID-JAG
56+ // chain) rather than the interactive one. A `start` against an `id_jag`
57+ // client can still land here as `false` — the profile falls back when the
58+ // server does not advertise it — so the caller reads the outcome, not its
59+ // own intent.
60+ enterpriseManaged: Schema.Boolean,
5561 });
5662
5763 // ---------------------------------------------------------------------------
packages/core/sdk/src/connection.ts+15−0
@@ -61,6 +61,21 @@ export interface Connection { · 60 unmodified lines
6161 * "has this expired?" at a glance in the connections list without probing.
6262 * Null/absent = never checked. */
6363 readonly lastHealth?: HealthCheckResult | null;
64+ /** True when this connection was minted through MCP Enterprise-Managed
65+ * Authorization and renews itself from the enterprise identity assertion
66+ * persisted alongside it (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`).
67+ *
68+ * A BOOLEAN, deliberately: the wiring behind it (which IdP registration,
69+ * which audience) is a server concern, and a console needs exactly one fact
70+ * from it — that this connection mirrors organization policy rather than a
71+ * grant the user holds. That single fact changes what the UI may offer:
72+ * there is no durable local grant to delete, so a local "remove" would be a
73+ * lie, and revocation belongs at the identity provider.
74+ *
75+ * It is NOT derivable client-side from `oauthClient`: a client whose grant
76+ * is `id_jag` still falls back to the interactive flow against a server that
77+ * does not advertise the profile, and such a connection is ordinary. */
78+ readonly enterpriseManaged?: boolean;
6479 }
6580
6681 /** Identify one connection — unique by (owner, integration, name). */
packages/core/sdk/src/executor.ts+22−0
@@ -818,6 +818,22 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] · 817 unmodified lines
818818 : [];
819819 };
820820
821+/** Project a credential-resolution failure's ADMINISTRATOR verdict onto the
822+ * health result, so the console reads "your organization declined this" as
823+ * structure rather than parsing the sentence in `detail`. Empty for every
824+ * ordinary failure — an expired grant, a dead refresh token — which keeps the
825+ * blocked branch impossible to reach by accident. */
826+export const healthAdministratorVerdict = (failure: {
827+ readonly blockedByAdmin?: boolean;
828+ readonly oauthErrorCode?: string;
829+}): { readonly blockedByAdmin?: true; readonly oauthErrorCode?: string } =>
830+ failure.blockedByAdmin === true
831+ ? {
832+ blockedByAdmin: true,
833+ ...(failure.oauthErrorCode === undefined ? {} : { oauthErrorCode: failure.oauthErrorCode }),
834+ }
835+ : {};
836+
821837 /** The definitive refresh rejection recorded on `provider_state`, or null.
822838 * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying
823839 * cannot change the verdict); cleared by the reconnect mint, which rewrites
@@ -852,6 +868,10 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 28 unmodified lines
852868 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
853869 missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state),
854870 lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
871+ // Read from the SAME persisted state the renewal path follows, so the
872+ // console and the credential lifecycle can never disagree about which
873+ // connections are enterprise-managed.
874+ enterpriseManaged: enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)) !== null,
855875 };
856876 };
857877
@@ -3455,6 +3475,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2597 unmodified lines
34553475 checkedAt: Date.now(),
34563476 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
34573477 detail: err.message,
3478+ ...healthAdministratorVerdict(err),
34583479 })
34593480 : Effect.fail(
34603481 new StorageError({
@@ -3473,6 +3494,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines
34733494 checkedAt: Date.now(),
34743495 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
34753496 detail: failure.message,
3497+ ...healthAdministratorVerdict(failure),
34763498 }
34773499 : {
34783500 status: "degraded",
packages/core/sdk/src/health-check.ts+13−0
@@ -81,6 +81,19 @@ export const HealthCheckResult = Schema.Struct({ · 80 unmodified lines
8181 /** Bounded sample of scalar fields from the response body, for the live
8282 * preview ("show me what this operation returns"). */
8383 responseSample: Schema.optional(Schema.Array(HealthCheckResponseSample)),
84+ /** True when an enterprise identity provider declined to authorize this
85+ * connection under administrator policy while resolving its credential.
86+ *
87+ * Carried as a FIELD beside `status` rather than folded into `detail`,
88+ * because the two verdicts demand different products: an expired grant says
89+ * "sign in again", an administrator decision says "your organization
90+ * decided this", and a console that offered the interactive flow for the
91+ * second would walk the user around the control the IdP just exercised. A
92+ * console must branch on this, never on the wording of `detail`. */
93+ blockedByAdmin: Schema.optional(Schema.Boolean),
94+ /** The authorization server's RFC 6749 §5.2 error code, when the failure came
95+ * from a token-endpoint refusal. Shown for support traceability. */
96+ oauthErrorCode: Schema.optional(Schema.String),
8497 });
8598 export type HealthCheckResult = typeof HealthCheckResult.Type;
8699
packages/react/src/api/atoms.tsx+3−0
@@ -400,6 +400,9 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 399 unmodified lines
400400 oauthScope: null,
401401 missingOAuthScopes: [],
402402 lastHealth: null,
403+ // A pasted credential is never enterprise-managed: that state is
404+ // only ever written by the ID-JAG connect path.
405+ enterpriseManaged: false,
403406 };
404407 return [optimistic, ...rows];
405408 }),