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
22 changes: 22 additions & 0 deletions packages/auth/src/credentials/default/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {DefaultCredentialsError} from './errors';
export interface Strategy {
/** Short identifier, e.g. `pat`, `oauth-m2m`, or `databricks-cli`. */
readonly name: string;
/** Whether this strategy can request credentials for an assumed group. */
readonly supportsGroupAssumption: boolean;
readonly configure: (profile: Profile) => Credentials | undefined;
}

Expand Down Expand Up @@ -57,6 +59,13 @@ export class DefaultCredentials implements Credentials {
return this.resolveByAuthType(profile, profile.authType);
}
for (const strategy of this.strategies) {
if (
profile.groupId !== undefined &&
profile.groupId !== '' &&
!strategy.supportsGroupAssumption
) {
continue;
}
const built = strategy.configure(profile);
if (built !== undefined) {
return built;
Expand All @@ -76,6 +85,16 @@ export class DefaultCredentials implements Credentials {
`auth type "${authType}" not found, please check ${AUTH_DOC_URL} for a list of supported auth types`
);
}
if (
profile.groupId !== undefined &&
profile.groupId !== '' &&
!strategy.supportsGroupAssumption
) {
throw new DefaultCredentialsError(
'GROUP_ROLE_UNSUPPORTED',
`auth type "${authType}" does not support group role assumption. Use OAuth M2M or Workload Identity Federation`
);
}
const built = strategy.configure(profile);
if (built === undefined) {
throw new DefaultCredentialsError(
Expand All @@ -90,6 +109,7 @@ export class DefaultCredentials implements Credentials {
/** PAT strategy: configured when `token` is set in the profile. */
export const patStrategy: Strategy = {
name: 'pat',
supportsGroupAssumption: false,
configure: profile => {
if (profile.host === undefined) return undefined;
if (profile.token === undefined) return undefined;
Expand All @@ -103,6 +123,7 @@ export const patStrategy: Strategy = {
*/
export const m2mStrategy: Strategy = {
name: 'oauth-m2m',
supportsGroupAssumption: true,
configure: profile => {
if (profile.host === undefined) return undefined;
if (profile.clientId === undefined) return undefined;
Expand All @@ -112,6 +133,7 @@ export const m2mStrategy: Strategy = {
clientId: profile.clientId,
clientSecret: profile.clientSecret.value,
...(profile.accountId !== undefined && {accountId: profile.accountId}),
...(profile.groupId !== undefined && {groupId: profile.groupId}),
});
},
};
4 changes: 4 additions & 0 deletions packages/auth/src/credentials/default/default-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ interface DefaultCredentialsOptions {
* 2. OAuth M2M (`oauth-m2m`).
* 3. Databricks CLI (`databricks-cli`).
*
* When the resolved profile contains a non-empty group ID, strategies that
* cannot assume a group are skipped. Explicitly selecting such a strategy
* through `authType` returns an error.
*
* When no profile is provided via `options.profile`, the profile is
* resolved on first use from the default config file (~/.databrickscfg)
* and environment variables.
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/credentials/default/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** Discriminant codes for {@link DefaultCredentialsError}. */
export type DefaultCredentialsErrorCode =
| 'NO_AUTH_CONFIGURED'
| 'AUTH_TYPE_NOT_FOUND';
| 'AUTH_TYPE_NOT_FOUND'
| 'GROUP_ROLE_UNSUPPORTED';

/**
* Error thrown when the default credentials chain cannot resolve a
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/credentials/default/u2m-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {Strategy} from './chain';
*/
export const u2mStrategy: Strategy = {
name: 'databricks-cli',
supportsGroupAssumption: false,
configure: profile => {
if (profile.host === undefined) return undefined;
if (profile.name === undefined) return undefined;
Expand Down
6 changes: 6 additions & 0 deletions packages/auth/src/credentials/m2m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export interface M2mCredentialsOptions {
*/
accountId?: string;

/**
* ID of the group whose role is assumed by the issued token. When omitted
* or empty, no group role is assumed.
*/
groupId?: string;

/**
* OAuth scopes to request. When omitted or empty, defaults to
* `['all-apis']`.
Expand Down
136 changes: 116 additions & 20 deletions packages/auth/tests/credentials/default/chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {describe, expect, it} from 'vitest';
import {Secret} from '@databricks/sdk-core/profiles/browser';
import type {Profile} from '@databricks/sdk-core/profiles/browser';

import type {Header} from '../../../src/auth';
import type {Credentials, Header} from '../../../src/auth';
import {
DefaultCredentials,
m2mStrategy,
Expand All @@ -18,19 +18,38 @@ import type {DefaultCredentialsErrorCode} from '../../../src/credentials/default

const HOST = 'https://workspace.example';

function configuredStrategy(label: string): Strategy {
function configuredStrategy(
label: string,
supportsGroupAssumption = true,
onConfigure?: (profile: Profile) => void
): Strategy {
return {
name: label,
configure: () => ({
name: () => label,
authHeaders: () =>
Promise.resolve([{key: 'X-Test-Strategy', value: label}]),
}),
supportsGroupAssumption,
configure: (profile): Credentials => {
onConfigure?.(profile);
return {
name: () => label,
authHeaders: () =>
Promise.resolve([{key: 'X-Test-Strategy', value: label}]),
};
},
};
}

function unconfiguredStrategy(label: string): Strategy {
return {name: label, configure: () => undefined};
function unconfiguredStrategy(
label: string,
supportsGroupAssumption = true,
onConfigure?: () => void
): Strategy {
return {
name: label,
supportsGroupAssumption,
configure: (): undefined => {
onConfigure?.();
return undefined;
},
};
}

const loaderFor =
Expand All @@ -39,17 +58,20 @@ const loaderFor =
Promise.resolve(profile);

describe('DefaultCredentials chain', () => {
const selectedError = new Error('selected provider failed');
const resolutionCases: {
name: string;
strategies: readonly Strategy[];
profile: Profile;
wantHeaders: Header[];
want: {headers: Header[]} | {error: Error};
}[] = [
{
name: 'returns the first configured strategy',
strategies: [patStrategy, configuredStrategy('oauth-m2m')],
profile: {host: HOST, token: new Secret('dapi-abc')},
wantHeaders: [{key: 'Authorization', value: 'Bearer dapi-abc'}],
want: {
headers: [{key: 'Authorization', value: 'Bearer dapi-abc'}],
},
},
{
name: 'falls through to the next strategy when earlier ones are unconfigured',
Expand All @@ -58,7 +80,9 @@ describe('DefaultCredentials chain', () => {
configuredStrategy('oauth-m2m'),
],
profile: {host: HOST},
wantHeaders: [{key: 'X-Test-Strategy', value: 'oauth-m2m'}],
want: {
headers: [{key: 'X-Test-Strategy', value: 'oauth-m2m'}],
},
},
{
// PAT is configured and comes first, but authType pins oauth-m2m, so
Expand All @@ -70,23 +94,64 @@ describe('DefaultCredentials chain', () => {
token: new Secret('dapi-abc'),
authType: 'oauth-m2m',
},
wantHeaders: [{key: 'X-Test-Strategy', value: 'oauth-m2m'}],
want: {
headers: [{key: 'X-Test-Strategy', value: 'oauth-m2m'}],
},
},
{
name: 'skips unsupported strategies when a group is configured',
strategies: [
configuredStrategy('pat', false),
configuredStrategy('oauth-m2m'),
],
profile: {host: HOST, groupId: 'group-123'},
want: {
headers: [{key: 'X-Test-Strategy', value: 'oauth-m2m'}],
},
},
{
name: 'preserves normal strategy ordering when the group is empty',
strategies: [
configuredStrategy('pat', false),
configuredStrategy('oauth-m2m'),
],
profile: {host: HOST, groupId: ''},
want: {headers: [{key: 'X-Test-Strategy', value: 'pat'}]},
},
{
name: 'does not configure a fallback after the selected strategy fails',
strategies: [
{
name: 'oauth-m2m',
supportsGroupAssumption: true,
configure: () => ({
name: () => 'oauth-m2m',
authHeaders: () => Promise.reject(selectedError),
}),
},
configuredStrategy('fallback', true, () => {
expect.fail('fallback strategy should not be configured');
}),
],
profile: {host: HOST, groupId: 'group-123'},
want: {error: selectedError},
},
];

it.each(resolutionCases)(
'$name',
async ({strategies, profile, wantHeaders}) => {
const creds = new DefaultCredentials(strategies, loaderFor(profile));
const headers = await creds.authHeaders();
expect(headers).toEqual(wantHeaders);
it.each(resolutionCases)('$name', async ({strategies, profile, want}) => {
const creds = new DefaultCredentials(strategies, loaderFor(profile));
if ('error' in want) {
await expect(creds.authHeaders()).rejects.toBe(want.error);
} else {
await expect(creds.authHeaders()).resolves.toEqual(want.headers);
}
);
});

it('caches the resolved strategy across calls', async () => {
let buildCount = 0;
const strategy: Strategy = {
name: 'counting',
supportsGroupAssumption: true,
configure: () => {
buildCount += 1;
return {
Expand Down Expand Up @@ -141,6 +206,37 @@ describe('DefaultCredentials chain', () => {
profile: {host: HOST, authType: 'pat'},
wantCode: 'NO_AUTH_CONFIGURED',
},
{
name: 'throws GROUP_ROLE_UNSUPPORTED for an explicitly selected PAT strategy',
strategies: [patStrategy, m2mStrategy],
profile: {
host: HOST,
token: new Secret('dapi-abc'),
groupId: 'group-123',
authType: 'pat',
},
wantCode: 'GROUP_ROLE_UNSUPPORTED',
},
{
name: 'throws GROUP_ROLE_UNSUPPORTED for an explicitly selected CLI strategy',
strategies: [configuredStrategy('databricks-cli', false)],
profile: {
host: HOST,
groupId: 'group-123',
authType: 'databricks-cli',
},
wantCode: 'GROUP_ROLE_UNSUPPORTED',
},
{
name: 'throws NO_AUTH_CONFIGURED when grouped strategies are exhausted',
strategies: [
configuredStrategy('pat', false),
unconfiguredStrategy('oauth-m2m'),
configuredStrategy('databricks-cli', false),
],
profile: {host: HOST, groupId: 'group-123'},
wantCode: 'NO_AUTH_CONFIGURED',
},
];

it.each(errorCases)('$name', async ({strategies, profile, wantCode}) => {
Expand Down
Loading
Loading