Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Identity] Support Workload Identity for service connections #28628

Closed
wants to merge 18 commits into from

Conversation

KarishmaGhiya
Copy link
Member

@KarishmaGhiya KarishmaGhiya commented Feb 21, 2024

Packages impacted by this PR

Issues associated with this PR

Fixes #27093
Gist

Describe the problem that is addressed by this PR

  • WorkloadIdentityCredential is extended to support the WI federation for servie connections in Azure Devops.
  • For complete details on the design please review Gist
  • We are not supporting this in DAC at the moment because of introduction of complicated design issues in DAC. Also we don't see this niche scenario that should be added to DAC. We can always make additional changes in the future to DAC with minimal design changes once Devops service provides better environment variable support for this scenario. But if we introduce the support now, we'll have to expose the parameter through DAC Options bag and it can be disruptive to remove in the future.

Work TBD:

  • Update TS guide
  • Update error messages
  • Test it out for all scenarios

Checklists

  • Added impacted package name to the issue description
  • Added a changelog (if necessary)

@azure-sdk
Copy link
Collaborator

API change check

APIView has identified API level changes in this PR and created following API reviews.

@azure/identity

@KarishmaGhiya KarishmaGhiya changed the title [Identity] update the api view for service connections Workload Identity [Identity] Support Workload Identity for service connections Apr 12, 2024
@KarishmaGhiya KarishmaGhiya marked this pull request as ready for review April 12, 2024 22:59
Copy link
Member

@maorleger maorleger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a few questions / comments / nits - coming together though!

tenantId?: string;
tokenFilePath?: string;
}

// @public
export type WorkloadIdentityCredentialOptions = WorkloadIdentityCredentialKubernetesOptions | WorkloadIdentityCredentialServiceConnectionOptions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between WorkloadIdentityCredentialKubernetesOptions and WorkloadIdentityCredentialServiceConnectionOptions ? They look identical

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One has federatedTokenFilePath required and the other has serviceConnectionId required

Copy link
Member

@maorleger maorleger Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! I missed the never on each side.

This makes more sense now, but I wonder if undefined is a better type than never here - because the union of <type> and never is just <type> this ends up being undefined anyway.

What I mean is, try this in a typescript playground:

type Foo = string | never

If you hover over Foo in the editor you'll notice that the type is just string - TypeScript is smart enough to know that never can not be in the set of values here. When a user tries to set both, the error message will say:

Type 'string' is not assignable to type 'undefined'.ts(2322)

Which can be confusing.

So what I am suggesting is to change never to explicit undefined as a starting point. It'll make for less confusing error messages. Does that make sense? Happy to discuss more or brainstorm an API shape that provides better devex!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tagging @bterlson to discuss which type should we use here for forbidden fields.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Karishma - happy to learn one way or the other 👍

if (tenantId) {
checkTenantId(logger, tenantId);
}
if (this.federatedTokenFilePath && this.serviceConnectionId) {
const errorMessage = `${credentialName}: ambiguous. serviceConnectionId and federatedTokenFilePath cannot be provided at the same time. These are used for supporting WorkloadIdentity for different environments.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think federatedTokenFilePath is what we name the variable internally but the param is called tokenFilePath - if that's the case we should change the error message to specify the correct param name (otherwise it may be confusing to the user).

Also, is it an error to have AZURE_FEDERATED_TOKEN_FILE env var set and pass in serviceConnectionId via the params? Just curious

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is going to be an error scenario.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks!

},
};

const response = await fetch(oidcRequestUrl, requestOptions);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should, ideally, use the core-rest-pipeline primitives unless there's a reason to use fetch directly

return true;
}
if (!process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) {
this.systemError = "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI,";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that we're building this systemError and then setting it as an instance variable - where do we actually use it / throw it?

I also wonder if it would be cleaner to collect missing env var names in an array and then creating the error if the array is not empty (pseudocode below)

const missingEnvVars = []
if (!process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) missingEnvVars.push("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI")
...
...
if (missingEnvVars.length > 0) this.systemError = "Missing system variable(s) - ${missingEnvVars.join(', ')}"

Last question here is: is this a developer error or an SDK error for these to be missing? They're set by Azure DevOps right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are set by Azure Devops. So definitely not an error by developer. but by Azure Devops

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a better strategy using the array

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@maorleger
Copy link
Member

Apologies for the PR noise 😄 but the new code should have test coverage - could you add tests?

Copy link
Member

@maorleger maorleger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a JS perspective this looks much better, thank you for indulging me! I'd like someone from the Identity crew to validate the logic as well 😇

throw new CredentialUnavailableError(
`${credentialName}: is Unavailable. clientId and tenantId are required parameters/ environment varables - AZURE_TENANT_ID, AZURE_CLIENT_ID`
);
}
if (tenantId) {
checkTenantId(logger, tenantId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could move outside the if and remove the if entirely since you are validating tenantId exists 👍

workloadIdentityCredentialOptions.tokenFilePath || process.env.AZURE_FEDERATED_TOKEN_FILE;
workloadIdentityCredentialOptions?.tokenFilePath || process.env.AZURE_FEDERATED_TOKEN_FILE;
this.serviceConnectionId = workloadIdentityCredentialOptions.serviceConnectionId;
if (!tenantId || !clientId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would move this to line 65 to keep it next to the code that sets clientId and tenantId

logger.error(errorMessage);
throw new CredentialUnavailableError(errorMessage);
}

if (clientId && tenantId && this.federatedTokenFilePath) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientId and tenantId should always be set by now, you can remove check for them

);
} else {
if (clientId && tenantId && this.serviceConnectionId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientId and tenantId should always be set by now, you can remove check for them

if (clientId && tenantId && this.serviceConnectionId) {
//Ensure all system env vars are there to form the request uri for OIDC token
this.ensurePipelinesSystemVars();
const oidcRequestUrl = `${process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${process.env.SYSTEM_TEAMPROJECTID}/_apis/distributedtask/hubs/build/plans/${process.env.SYSTEM_PLANID}/jobs/${process.env.SYSTEM_JOBID}/oidctoken?api-version=7.1-preview.1&serviceConnectionId=${this.serviceConnectionId}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should we always hit 7.1-preview.1 API version? If not how do we move forward?

this.federatedTokenFilePath =
workloadIdentityCredentialOptions.tokenFilePath || process.env.AZURE_FEDERATED_TOKEN_FILE;
workloadIdentityCredentialOptions?.tokenFilePath || process.env.AZURE_FEDERATED_TOKEN_FILE;
this.serviceConnectionId = workloadIdentityCredentialOptions.serviceConnectionId;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check that this is guid-like?

if (!process.env.SYSTEM_ACCESSTOKEN) missingEnvVars.push("SYSTEM_ACCESSTOKEN");
if (missingEnvVars.length > 0)
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. Missing system variable(s) - ${missingEnvVars.join(
Copy link

@Penguinwizzard Penguinwizzard Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a great error message - yes. the variables are missing, but we have a good idea as to why they might be, and what the user should do instead. For the first set, you can generate something like The environment variables SYSTEM_JOBID and SYSTEM_PLANID aren't defined - please ensure that you're running this task in an Azure Pipeline.

We can do more for undefined/non-null check for SYSTEM_ACCESSTOKEN with a message like:

Please ensure that the system access token is available in the SYSTEM_ACCESSTOKEN value; this is often most easily achieved by adding a block to the end of your pipeline yaml for the task with:
env:
- SYSTEM_ACCESSTOKEN: $(System.AccessToken)


/**
* Options for the {@link WorkloadIdentityCredential}
*/
export interface WorkloadIdentityCredentialOptions
export type WorkloadIdentityCredentialOptions =

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're doing this, why not do it by inheritance?

export interface WorkloadIdentityCredentialOptions
  extends MultiTenantTokenCredentialOptions,
    AuthorityValidationOptions {
    tenantId?: string,
    clientId?: string
}

export interface WorkloadIdentityCredentialKubernetesOptions
  extends WorkloadIdentityCredentialOptions {
    tokenFilePath?: string;
}

export interface WorkloadIdentityCredentialServiceConnectionOptions
  extends WorkloadIdentityCredentialOptions {
    serviceConnectionId?: string;
}

@KarishmaGhiya
Copy link
Member Author

Closing in favor of #29392

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Development

Successfully merging this pull request may close these issues.

Add Support for Workload Identity Federation for Azure Service Connections
5 participants