Skip to content

Commit

Permalink
Merge pull request taskcluster#1128 from djmitche/gcp-credentials-mul…
Browse files Browse the repository at this point in the history
…ti-project

Refactor configuration of GCP to support multiple projects
  • Loading branch information
djmitche authored Aug 2, 2019
2 parents a25d165 + 54c5a63 commit f030d45
Show file tree
Hide file tree
Showing 9 changed files with 108 additions and 106 deletions.
6 changes: 4 additions & 2 deletions changelog/bug-1552970.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
level: major
reference: bug 1552970
---
The `gcpCredentials` doesn't set the IAM policy for the given service account.
The caller is now responsible to handle it.
The `auth.gcpCredentials` method no longer modifies the *granting* service account.
Instead, that service account must be configured with the "Service Account Token Creator" role prior to deployment of Taskcluster.
The format of configuration for these credentials has changed as well, now taking `GCP_CREDENTIALS_ALLOWED_PROJECTS`.
See the deployment documentation for more information.
32 changes: 32 additions & 0 deletions deployment-docs/gcp-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# GCP Credentials

The Auth service's `auth.gcpCredentials` method distributes credentials for GCP service accounts to callers, governed by scopes.
It takes a GCP project and a service account email.

By default, this method always fails, as no GCP projects are configured.

The projects and accounts for which the service can issue credentials are governed by the `GCP_CREDENTIALS_ALLOWED_PROJECTS` configuration.
This is a JSON string of the form

```
{
"project-name": {
"credentials": {..},
"allowedServiceAccounts": [..],
}, ..
}
```

The allowed projects are defined by the keys of the outer object, in this case just `project-name`.
The `credentials` property gives the "key" for a service account in that project that has the "Service Account Token Creator" role.
The `allowedServiceAccounts` property is a list of service account emails in that project for which the `auth` service can distribute credentials.
The API method will reject any requests for unknown projects, or for service accounts in a project that are not listed in `allowedServiceAccounts`.

The "Service Account Token Creator" role allows a service account to create tokens for *all* service account in the project.
The recommended approach is to isolate work into dedicated projects such that this restriction isn't problematic.
It is possible to create more narrowly-focused IAM policies, but this is not currently supported by the GCP console and must be done with manual calls to the GCP `setIamPolicy` API endpoint.

*NOTE*:

The current implementation only supports one project, with any number of allowed service accounts.
Future work will allow multiple projects.
3 changes: 1 addition & 2 deletions dev-docs/dev-config-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,7 @@ auth:
aws_access_key_id: ...
aws_secret_access_key: ...
aws_region: ...
gcp_credentials: {}
gcp_allowed_service_accounts: {}
gcp_credentials_allowed_projects: {}
test_bucket: ...
built_in_workers:
taskcluster_access_token: ...
Expand Down
3 changes: 1 addition & 2 deletions infrastructure/k8s/templates/taskcluster-auth-secret.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 1 addition & 12 deletions infrastructure/k8s/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,7 @@
"docs_bucket": {
"type": "string"
},
"gcp_allowed_service_accounts": {
"oneOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "array"
}
]
},
"gcp_credentials": {
"gcp_credentials_allowed_projects": {
"oneOf": [
{
"additionalProperties": true,
Expand Down
33 changes: 33 additions & 0 deletions services/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,36 @@ Taskcluster team members can provide you with some testing-only credentials -- j
You can get your own pulse credentials at https://pulseguardian.mozilla.org.

The taskcluster team has a series of [best practices](../../dev-docs/best-practices) which may help guide you in modifying the source code and making a pull request.

### GCP Credentials

To test the `gcpCredentials` endpoint, you will need a GCP project (we'll use "test-proj" here) with at least two service accounts.
These should be in a dedicated GCP project that does nothing else, to eliminate risk of damage or disclosure due to misconfiguration.
You'll also need to enable the IAM Service Account Credentials API under "APIs & Services" in the GCP Console.

The first is the service account that grants the credentials, called "credsgranter" here.
It must be created with the "Service Account Token Creator" role.
The second is the service account for which credentials will be generated, called "target" here.
It does not need any specific roles, but the IAM UI will not display it without a role, so pick a random role for it.

Get the "key" for the credsgranter.
Then set `gcpCredentials.alloewdProjects` in `user-config.yml` as follows (including the `invalid` account, which should not exist):

```yaml
gcpCredentials:
allowedProjects:
test-proj:
credentials: {
"type": "service_account",
"project_id": "dustin-svc-account-experiments",
"private_key_id": "99b2a524554e2450aa23cb9d73d076b19173da5a",
"client_email": "credgranter@test-proj.iam.gserviceaccount.com",
...
}
allowedServiceAccounts:
- "target@test-proj.iam.gserviceaccount.com"
- "invalid@mozilla.com"
```
alternately, you can set env var `GCP_CREDENTIALS_ALLOWED_PROJECTS` to a string
containing the JSON encoding, e.g., `{"test-proj": {"credentials": ..}}`.
23 changes: 18 additions & 5 deletions services/auth/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,24 @@ defaults:
# but it does the trick...
apiVersion: '2014-01-01'

# Google Cloud Platform service account credentials. This is the content
# of the json file you download when you create a key for a service account
gcp:
credentials: !env:json:optional GCP_CREDENTIALS
allowedServiceAccounts: !env:json:optional GCP_ALLOWED_SERVICE_ACCOUNTS
# Configuration for the GCP serviceAccounts to which the `gcpCredentials`
# endpoint can grant access.
gcpCredentials:
# This has the form {
# "projectName": {
# # credentials for a serviceAccount in this project that has
# # roles/iam.serviceAccountTokenCreator.
# "credentials": {
# "type": ..,
# "project_id": ..,
# ..
# },
# # allowed service accounts in this project
# "allowedServiceAccounts": ["accountName1", ..]
# }, ..
# }
# If omitted, it defaults to {}
allowedProjects: !env:json:optional GCP_CREDENTIALS_ALLOWED_PROJECTS

production:
app:
Expand Down
17 changes: 15 additions & 2 deletions services/auth/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,21 @@ const load = Loader({
gcp: {
requires: ['cfg'],
setup: ({cfg}) => {
const credentials = cfg.gcp.credentials;
const allowedServiceAccounts = cfg.gcp.allowedServiceAccounts;
const projects = cfg.gcpCredentials.allowedProjects || {};
const projectIds = Object.keys(projects);

// NOTE: this is a temporary limit to avoid more massive refactoring, while
// supporting a future-compatible configuration format. There's no other, hidden
// reason for this limitation.
assert(projectIds.length <= 1, "at most one GCP project is supported");

if (projectIds.length === 0) {
return {googleapis, auth: {}, credentials: {}, allowedServiceAccounts: []};
}

const project = projects[projectIds[0]];
const {credentials, allowedServiceAccounts} = project;
assert.equal(projectIds[0], credentials.project_id, "credentials must be for the given project");

assert(Array.isArray(allowedServiceAccounts));

Expand Down
84 changes: 3 additions & 81 deletions services/auth/test/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ exports.secrets = new Secrets({
{env: 'SENTRY_HOSTNAME', cfg: 'sentry.hostname'},
],
gcp: [
{env: 'GCP_CREDENTIALS', cfg: 'gcp.credentials', name: 'credentials'},
{env: 'GCP_ALLOWED_SERVICE_ACCOUNTS', cfg: 'gcp.allowedServiceAccounts', name: 'allowedServiceAccounts', mock: []},
{env: 'GCP_CREDENTIALS_ALLOWED_PROJECTS', cfg: 'gcpCredentials.allowedProjects', name: 'allowedProjects', mock: []},
],
},
load: exports.load,
Expand Down Expand Up @@ -330,8 +329,6 @@ exports.withServers = (mock, skipping) => {
* using real credentials.
*/
exports.withGcp = (mock, skipping) => {
const accountId = slugid.nice().replace(/_/g, '').toLowerCase();
let auth, iam;
let policy = {};

const fakeGoogleApis = {
Expand Down Expand Up @@ -420,89 +417,14 @@ exports.withGcp = (mock, skipping) => {

exports.gcpAccount = {
email: 'test_client@example.com',
name: 'testAccount',
project_id: credentials.project_id,
};
} else {
const {credentials, auth, googleapis, allowedServiceAccounts} = await exports.load('gcp');
const projectId = credentials.project_id;

iam = googleapis.iam({version: 'v1', auth});

const res = await iam.projects.serviceAccounts.create({
auth,
name: `projects/${projectId}`,
resource: {
accountId,
serviceAccount: {
// This is a testing account and will be deleted by
// the end of the tests. If the test crashes, these
// accounts maybe left in IAM. Any account starting
// with taskcluster-auth-test- can be safely removed.
displayName: `taskcluster-auth-test-${accountId}`,
},
},
});

const serviceAccount = res.data.email;
allowedServiceAccounts.push(serviceAccount);

// to understand the {get/set}IamPolicy calls, look at
// https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials
//
// Notice that might happen that between the getIamPolicy and setIamPolicy calls,
// a third party might change the etag, making the call to setIamPolicy fail.
const response = await iam.projects.serviceAccounts.getIamPolicy({
// NOTE: the `-` here represents the projectId, and uses the projectId
// from this.gcp.auth, which is why we verified those match above.
resource_: `projects/-/serviceAccounts/${serviceAccount}`,
});

const data = response.data;
if (data.bindings === undefined) {
data.bindings = [];
}

let binding = data.bindings.find(b => b.role === 'roles/iam.serviceAccountTokenCreator');
if (!binding) {
binding = {
role: 'roles/iam.serviceAccountTokenCreator',
members: [],
};

data.bindings.push(binding);
}

const myServiceAccount = credentials.client_email;
if (!binding.members.includes(`serviceAccount:${myServiceAccount}`)) {
binding.members.push(`serviceAccount:${myServiceAccount}`);
await iam.projects.serviceAccounts.setIamPolicy({
// NOTE: the `-` here represents the projectId, and uses the projectId
// from this.gcp.auth, which is why we verified those match above.
resource: `projects/-/serviceAccounts/${serviceAccount}`,
requestBody: {
policy: data,
updateMask: 'bindings',
},
});
}

const {credentials, allowedServiceAccounts} = await exports.load('gcp');
exports.gcpAccount = {
email: res.data.email,
name: res.data.name,
email: allowedServiceAccounts[0],
project_id: credentials.project_id,
};
}
});

suiteTeardown(async () => {
if (skipping()) {
return;
}

if (!mock) {
await iam.projects.serviceAccounts.delete({name: exports.gcpAccount.name, auth});
}
});

};

0 comments on commit f030d45

Please sign in to comment.