Skip to content

Commit 3fd5f3b

Browse files
seanstoryclaudekibanamachine
authored
[EARS] Make EARS feature-flag gate functionality per-provider (elastic#270426)
## Summary Closes elastic/search-team#14522 Adds per-provider EARS feature flagging via a two-tier system: - **Stable providers** (Microsoft, Slack): enabled whenever `xpack.actions.auth.ears.enabled: true` - **Experimental providers** (Google): only enabled when *both* `ears.enabled: true` **and** `ears.enableExperimental: true` This allows us to ship EARS for verified OAuth providers while keeping unverified ones (Google, pending app verification) available only for internal dogfooding. ### How it works - Each connector spec's EARS auth type entry can declare `experimental: true` (Google Calendar, Gmail, Google Drive do this) - A new `xpack.actions.auth.ears.enableExperimental` boolean config controls whether experimental EARS providers are available - The filtering happens at schema generation time (`generateSecretsSchemaFromSpec`), so both the UI and API are gated - Existing EARS connectors for experimental providers show as disabled in the connectors table when `enableExperimental` is off ### Promotion flow When Google's OAuth app verification completes: 1. Remove `experimental: true` from the 3 Google specs (one-line diff each) 2. **No deployment config changes needed** — Google EARS "just works" for everyone ### Config ```yaml # kibana.yml xpack.actions.auth.ears: enabled: true # global EARS gate (existing) enableExperimental: true # opt-in for unverified providers (new) ``` ### Changes | Area | What | |------|------| | `kbn-connector-specs` | `AuthTypeDef.experimental` flag, filtering in `generateSecretsSchemaFromSpec`, `isEarsExperimentalConnector` helper | | `actions` plugin | `ears.enableExperimental` config, `isEarsExperimentalEnabled()` utility, exposed to browser | | `stack_connectors` | Thread `isEarsExperimentalEnabled` through client-side schema generation | | `agent_builder` | Per-provider disabled check in connectors table | | `triggers_actions_ui` | Per-provider disabled check in connectors list | | Google specs | `experimental: true` on EARS auth type (google_calendar, gmail, google_drive) | ## Test plan - [ ] With `ears.enabled: true` and no `enableExperimental`: Microsoft/Slack connectors show EARS option, Google connectors do not - [ ] With `ears.enabled: true` and `enableExperimental: true`: all connectors show EARS option - [ ] With `ears.enabled: false`: no connectors show EARS option regardless of `enableExperimental` - [ ] Creating a Google EARS connector via API fails when `enableExperimental` is off - [ ] Previously created Google EARS connectors show as disabled when `enableExperimental` is turned off - [ ] Unit tests pass: `node scripts/jest src/platform/packages/shared/kbn-connector-specs/` - [ ] Unit tests pass: `node scripts/jest x-pack/platform/plugins/shared/actions/server/` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent ea95b23 commit 3fd5f3b

35 files changed

Lines changed: 527 additions & 40 deletions

File tree

.github/CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2533,6 +2533,10 @@ x-pack/platform/plugins/shared/actions/server/lib/ears @elastic/workchat-eng
25332533
x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts @elastic/workchat-eng @elastic/response-ops
25342534
x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts @elastic/workchat-eng @elastic/response-ops
25352535
src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts @elastic/workchat-eng
2536+
src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts @elastic/workchat-eng
2537+
src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.test.ts @elastic/workchat-eng
2538+
src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts @elastic/workchat-eng @elastic/response-ops
2539+
src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts @elastic/workchat-eng @elastic/response-ops
25362540

25372541

25382542
# Connector Specs

src/platform/packages/shared/kbn-connector-specs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export {
3434
ESTIMATED_JSON_OUTPUT_OVERHEAD_BYTES,
3535
} from './src/connector_utils';
3636
export { normalizeAuthorizationHeaderValue } from './src/auth_types/oauth_authz_code_and_ears_helpers';
37+
export { isEarsExperimentalConnector } from './src/lib/ears_experimental_utils';
3738

3839
export { ConnectorAuthorizationError, isConnectorAuthorizationError } from './src/errors';
3940
export type { ConnectorAuthorizationReason } from './src/errors';

src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export interface ConnectorTest {
267267

268268
export interface AuthTypeDef {
269269
type: string;
270+
isExperimental?: boolean;
270271
defaults: Record<string, unknown>;
271272
overrides?: {
272273
meta?: Record<string, Record<string, unknown>>;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { isEarsExperimentalConnector } from './ears_experimental_utils';
11+
12+
describe('isEarsExperimentalConnector', () => {
13+
test('returns true for connector types whose EARS auth is marked experimental', () => {
14+
// Google connectors have experimental: true on their EARS auth type
15+
expect(isEarsExperimentalConnector('.google_calendar')).toBe(true);
16+
expect(isEarsExperimentalConnector('.gmail')).toBe(true);
17+
expect(isEarsExperimentalConnector('.google_drive')).toBe(true);
18+
});
19+
20+
test('returns false for connector types whose EARS auth is stable', () => {
21+
// Microsoft and Slack connectors have EARS without experimental flag
22+
expect(isEarsExperimentalConnector('.microsoft_teams')).toBe(false);
23+
expect(isEarsExperimentalConnector('.slack')).toBe(false);
24+
expect(isEarsExperimentalConnector('.sharepoint_online')).toBe(false);
25+
});
26+
27+
test('returns false for connector types with no EARS auth', () => {
28+
expect(isEarsExperimentalConnector('.alienvault-otx')).toBe(false);
29+
});
30+
31+
test('returns false for unknown connector types', () => {
32+
expect(isEarsExperimentalConnector('.nonexistent')).toBe(false);
33+
});
34+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { isString } from 'lodash';
11+
import * as allSpecs from '../all_specs';
12+
import { EARS_AUTH_ID } from '../auth_types/ears';
13+
import type { AuthTypeDef } from '../connector_spec';
14+
15+
export const isEarsExperimentalAuthType = (
16+
authType: string | AuthTypeDef
17+
): authType is AuthTypeDef =>
18+
!isString(authType) && authType.type === EARS_AUTH_ID && authType.isExperimental === true;
19+
20+
const experimentalEarsConnectorIds = new Set(
21+
Object.values(allSpecs)
22+
.filter((spec) => spec.auth?.types.some(isEarsExperimentalAuthType))
23+
.map((spec) => spec.metadata.id)
24+
);
25+
26+
export const isEarsExperimentalConnector = (connectorTypeId: string): boolean =>
27+
experimentalEarsConnectorIds.has(connectorTypeId);

src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,97 @@ describe('generateSecretsSchemaFromSpec', () => {
126126
expect(authTypes).toContain('oauth_authorization_code');
127127
});
128128

129+
describe('experimental EARS filtering', () => {
130+
const authSpecWithExperimentalEars = {
131+
types: [
132+
'bearer',
133+
{
134+
type: 'ears',
135+
isExperimental: true,
136+
defaults: { provider: 'google', scope: 'https://www.googleapis.com/auth/calendar' },
137+
},
138+
],
139+
};
140+
141+
const authSpecWithStableEars = {
142+
types: [
143+
'bearer',
144+
{
145+
type: 'ears',
146+
defaults: { provider: 'slack', scope: 'channels:read' },
147+
},
148+
],
149+
};
150+
151+
test('excludes experimental EARS when isEarsEnabled but isEarsExperimentalEnabled is false', () => {
152+
const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
153+
isEarsEnabled: true,
154+
isEarsExperimentalEnabled: false,
155+
});
156+
const jsonSchema = z.toJSONSchema(schema) as {
157+
oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
158+
};
159+
const oneOfOptions = jsonSchema.oneOf || [];
160+
const authTypes = oneOfOptions
161+
.map((opt) => opt.properties?.authType?.const)
162+
.filter(Boolean) as string[];
163+
164+
expect(authTypes).toContain('bearer');
165+
expect(authTypes).not.toContain('ears');
166+
});
167+
168+
test('includes experimental EARS when both isEarsEnabled and isEarsExperimentalEnabled are true', () => {
169+
const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
170+
isEarsEnabled: true,
171+
isEarsExperimentalEnabled: true,
172+
});
173+
const jsonSchema = z.toJSONSchema(schema) as {
174+
oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
175+
};
176+
const oneOfOptions = jsonSchema.oneOf || [];
177+
const authTypes = oneOfOptions
178+
.map((opt) => opt.properties?.authType?.const)
179+
.filter(Boolean) as string[];
180+
181+
expect(authTypes).toContain('bearer');
182+
expect(authTypes).toContain('ears');
183+
});
184+
185+
test('includes stable EARS when isEarsEnabled is true regardless of isEarsExperimentalEnabled', () => {
186+
const schema = generateSecretsSchemaFromSpec(authSpecWithStableEars, {
187+
isEarsEnabled: true,
188+
isEarsExperimentalEnabled: false,
189+
});
190+
const jsonSchema = z.toJSONSchema(schema) as {
191+
oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
192+
};
193+
const oneOfOptions = jsonSchema.oneOf || [];
194+
const authTypes = oneOfOptions
195+
.map((opt) => opt.properties?.authType?.const)
196+
.filter(Boolean) as string[];
197+
198+
expect(authTypes).toContain('bearer');
199+
expect(authTypes).toContain('ears');
200+
});
201+
202+
test('excludes all EARS when isEarsEnabled is false even if isEarsExperimentalEnabled is true', () => {
203+
const schema = generateSecretsSchemaFromSpec(authSpecWithExperimentalEars, {
204+
isEarsEnabled: false,
205+
isEarsExperimentalEnabled: true,
206+
});
207+
const jsonSchema = z.toJSONSchema(schema) as {
208+
oneOf?: Array<{ properties?: { authType?: { const?: string } } }>;
209+
};
210+
const oneOfOptions = jsonSchema.oneOf || [];
211+
const authTypes = oneOfOptions
212+
.map((opt) => opt.properties?.authType?.const)
213+
.filter(Boolean) as string[];
214+
215+
expect(authTypes).toContain('bearer');
216+
expect(authTypes).not.toContain('ears');
217+
});
218+
});
219+
129220
describe('runtime parse behavior', () => {
130221
test('parses valid secrets for none auth type', () => {
131222
const schema = generateSecretsSchemaFromSpec({ types: ['none'] });

src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@ import { z } from '@kbn/zod/v4';
1111
import type { AuthMode, ConnectorSpec } from '../connector_spec';
1212
import * as authTypeSpecs from '../all_auth_types';
1313
import { getSchemaForAuthType } from '.';
14+
import { isEarsExperimentalAuthType } from './ears_experimental_utils';
1415

1516
interface GenerateOptions {
1617
isPfxEnabled?: boolean;
1718
isEarsEnabled?: boolean;
19+
isEarsExperimentalEnabled?: boolean;
1820
authMode?: AuthMode | '';
1921
}
2022

2123
export const generateSecretsSchemaFromSpec = (
2224
authSpec: ConnectorSpec['auth'],
23-
{ isPfxEnabled, isEarsEnabled, authMode }: GenerateOptions = {
25+
{ isPfxEnabled, isEarsEnabled, isEarsExperimentalEnabled, authMode }: GenerateOptions = {
2426
isPfxEnabled: true,
2527
isEarsEnabled: false,
28+
isEarsExperimentalEnabled: false,
2629
}
2730
) => {
2831
const secretSchemas: z.core.$ZodTypeDiscriminable[] = [];
@@ -31,8 +34,13 @@ export const generateSecretsSchemaFromSpec = (
3134
if (schema.id === 'pfx_certificate' && !isPfxEnabled) {
3235
continue;
3336
}
34-
if (schema.id === 'ears' && !isEarsEnabled) {
35-
continue;
37+
if (schema.id === 'ears') {
38+
if (!isEarsEnabled) {
39+
continue;
40+
}
41+
if (isEarsExperimentalAuthType(authType) && !isEarsExperimentalEnabled) {
42+
continue;
43+
}
3644
}
3745

3846
const authTypeSpec = Object.values(authTypeSpecs).find((spec) => spec.id === schema.id);

src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,16 @@ describe('serializeConnectorSpec', () => {
267267

268268
const spy = jest.spyOn(generateSecretsModule, 'generateSecretsSchemaFromSpec');
269269

270-
serializeConnectorSpec(spec, { isPfxEnabled: false, isEarsEnabled: false });
270+
serializeConnectorSpec(spec, {
271+
isPfxEnabled: false,
272+
isEarsEnabled: false,
273+
isEarsExperimentalEnabled: false,
274+
});
271275

272276
expect(spy).toHaveBeenCalledWith(spec.auth, {
273277
isPfxEnabled: false,
274278
isEarsEnabled: false,
279+
isEarsExperimentalEnabled: false,
275280
});
276281

277282
spy.mockRestore();
@@ -298,7 +303,11 @@ describe('serializeConnectorSpec', () => {
298303
};
299304

300305
const defaultEars = serializeConnectorSpec(spec);
301-
const earsOn = serializeConnectorSpec(spec, { isPfxEnabled: true, isEarsEnabled: true });
306+
const earsOn = serializeConnectorSpec(spec, {
307+
isPfxEnabled: true,
308+
isEarsEnabled: true,
309+
isEarsExperimentalEnabled: false,
310+
});
302311
interface SecretBranch {
303312
properties?: { authType?: { const?: string } };
304313
}
@@ -392,4 +401,53 @@ describe('serializeConnectorSpec', () => {
392401
}
393402
});
394403
});
404+
405+
describe('experimental EARS filtering', () => {
406+
test('excludes experimental EARS auth when isEarsExperimentalEnabled is false', () => {
407+
const testSpec = {
408+
metadata: {
409+
id: '.test-experimental-ears',
410+
displayName: 'Test',
411+
description: 'Test connector',
412+
minimumLicense: 'basic' as const,
413+
supportedFeatureIds: ['alerting' as const],
414+
},
415+
auth: {
416+
types: [
417+
'bearer',
418+
{
419+
type: 'ears',
420+
isExperimental: true,
421+
defaults: { provider: 'google', scope: 'test-scope' },
422+
},
423+
],
424+
},
425+
actions: {
426+
test: {
427+
input: z.object({}),
428+
handler: async () => ({ success: true }),
429+
},
430+
},
431+
};
432+
433+
const result = serializeConnectorSpec(testSpec, {
434+
isPfxEnabled: true,
435+
isEarsEnabled: true,
436+
isEarsExperimentalEnabled: false,
437+
});
438+
439+
const schemaJson = result.schema as {
440+
properties?: {
441+
secrets?: { oneOf?: Array<{ properties?: { authType?: { const?: string } } }> };
442+
};
443+
};
444+
const secretsOneOf = schemaJson.properties?.secrets?.oneOf || [];
445+
const authTypes = secretsOneOf
446+
.map((opt) => opt.properties?.authType?.const)
447+
.filter(Boolean) as string[];
448+
449+
expect(authTypes).toContain('bearer');
450+
expect(authTypes).not.toContain('ears');
451+
});
452+
});
395453
});

src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { generateSecretsSchemaFromSpec } from './generate_secrets_schema_from_sp
1414
export interface SerializeConnectorSpecOptions {
1515
isPfxEnabled: boolean;
1616
isEarsEnabled: boolean;
17+
isEarsExperimentalEnabled: boolean;
1718
}
1819

1920
export function serializeConnectorSpec(

src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const GmailConnector: ConnectorSpec = {
5858
},
5959
{
6060
type: 'ears',
61+
isExperimental: true,
6162
overrides: {
6263
meta: { scope: { disabled: true } },
6364
},

0 commit comments

Comments
 (0)