Skip to content

Commit

Permalink
Support Google Cloud impersonation (#91)
Browse files Browse the repository at this point in the history
* Add support for GCP impersonation

* Add Google Cloud Build configuration & test

* Add docs
  • Loading branch information
di committed May 17, 2022
1 parent e3ced88 commit ccb00ad
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 27 deletions.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Options:
```
<!-- @end-sigstore-sign-help@ -->

Verifying
Verifying:

<!-- @begin-sigstore-verify-help@ -->
```
Expand All @@ -83,6 +83,18 @@ Options:
```
<!-- @end-sigstore-verify-help@ -->

### Ambient credential detection

For environments that support OIDC natively, `sigstore` supports automatic ambient credential detection:

- GitHub:
- Actions: requires setting the `id-token` permission, see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect. An example is [here](https://github.com/sigstore/sigstore-python/blob/main/.github/workflows/release.yml).
- Google Cloud:
- Compute Engine: automatic
- Cloud Build: requires setting `GOOGLE_SERVICE_ACCOUNT_NAME` to an appropriately configured service account name, see https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-direct. An example is [here](https://github.com/sigstore/sigstore-python/blob/main/cloudbuild.yaml)
- GitLab: planned, see https://github.com/sigstore/sigstore-python/issues/31
- CircleCI: planned, see https://github.com/sigstore/sigstore-python/issues/31

## Licensing

`sigstore` is licensed under the Apache 2.0 License.
Expand Down
12 changes: 12 additions & 0 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
steps:
# Install dependencies
- name: python
entrypoint: python
args: ["-m", "pip", "install", ".", "--user"]

# Sign with ambient GCP credentials
- name: python
entrypoint: python
args: ["-m", "sigstore", "sign", "README.md"]
env:
- "GOOGLE_SERVICE_ACCOUNT_NAME=sigstore-python-test@projectsigstore.iam.gserviceaccount.com"
102 changes: 78 additions & 24 deletions sigstore/_internal/oidc/ambient.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
logger = logging.getLogger(__name__)

GCP_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name"
GCP_ID_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa # nosec B105
GCP_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/token" # noqa # nosec B105
GCP_IDENTITY_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa # nosec B105
GCP_GENERATEIDTOKEN_REQUEST_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa


class AmbientCredentialError(IdentityError):
Expand Down Expand Up @@ -107,31 +109,83 @@ def detect_github() -> Optional[str]:

def detect_gcp() -> Optional[str]:
logger.debug("GCP: looking for OIDC credentials")
try:
with open(GCP_PRODUCT_NAME_FILE) as f:
name = f.read().strip()
except OSError:
logger.debug("GCP: environment doesn't have GCP product name file; giving up")
return None

if name not in {"Google", "Google Compute Engine"}:
raise AmbientCredentialError(
f"GCP: product name file exists, but product name is {name!r}; giving up"
service_account_name = os.getenv("GOOGLE_SERVICE_ACCOUNT_NAME")
if service_account_name:
logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation")

logger.debug("GCP: requesting access token")
resp = requests.get(
GCP_TOKEN_REQUEST_URL,
params={"scopes": "https://www.googleapis.com/auth/cloud-platform"},
headers={"Metadata-Flavor": "Google"},
)
try:
resp.raise_for_status()
except requests.HTTPError as http_error:
raise AmbientCredentialError(
f"GCP: access token request failed (code={resp.status_code})"
) from http_error

access_token = resp.json().get("access_token")

if not access_token:
raise AmbientCredentialError("GCP: access token missing from response")

resp = requests.post(
GCP_GENERATEIDTOKEN_REQUEST_URL.format(service_account_name),
json={"audience": "sigstore", "includeEmail": True},
headers={
"Authorization": f"Bearer {access_token}",
},
)

logger.debug("GCP: requesting OIDC token")
resp = requests.get(
GCP_ID_TOKEN_REQUEST_URL,
params={"audience": DEFAULT_AUDIENCE, "format": "full"},
headers={"Metadata-Flavor": "Google"},
)
logger.debug("GCP: requesting OIDC token")
try:
resp.raise_for_status()
except requests.HTTPError as http_error:
raise AmbientCredentialError(
f"GCP: OIDC token request failed (code={resp.status_code})"
) from http_error

oidc_token: str = resp.json().get("token")

if not oidc_token:
raise AmbientCredentialError("GCP: OIDC token missing from response")

logger.debug("GCP: successfully requested OIDC token")
return oidc_token

else:
logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation")

try:
with open(GCP_PRODUCT_NAME_FILE) as f:
name = f.read().strip()
except OSError:
logger.debug(
"GCP: environment doesn't have GCP product name file; giving up"
)
return None

if name not in {"Google", "Google Compute Engine"}:
raise AmbientCredentialError(
f"GCP: product name file exists, but product name is {name!r}; giving up"
)

logger.debug("GCP: requesting OIDC token")
resp = requests.get(
GCP_IDENTITY_REQUEST_URL,
params={"audience": DEFAULT_AUDIENCE, "format": "full"},
headers={"Metadata-Flavor": "Google"},
)

try:
resp.raise_for_status()
except requests.HTTPError as http_error:
raise AmbientCredentialError(
f"GCP: OIDC token request failed (code={resp.status_code})"
) from http_error
try:
resp.raise_for_status()
except requests.HTTPError as http_error:
raise AmbientCredentialError(
f"GCP: OIDC token request failed (code={resp.status_code})"
) from http_error

logger.debug("GCP: successfully requested OIDC token")
return resp.text
logger.debug("GCP: successfully requested OIDC token")
return resp.text
169 changes: 167 additions & 2 deletions test/internal/oidc/test_ambient.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,162 @@ def test_detect_github(monkeypatch):
assert resp.json.calls == [pretend.call()]


def test_gcp_impersonation_access_token_request_fail(monkeypatch):
monkeypatch.setenv(
"GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com"
)

logger = pretend.stub(debug=pretend.call_recorder(lambda s: None))
monkeypatch.setattr(ambient, "logger", logger)

resp = pretend.stub(raise_for_status=pretend.raiser(HTTPError), status_code=999)
requests = pretend.stub(
get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError
)
monkeypatch.setattr(ambient, "requests", requests)

with pytest.raises(
ambient.AmbientCredentialError,
match=r"GCP: access token request failed \(code=999\)",
):
ambient.detect_gcp()

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"),
pretend.call("GCP: requesting access token"),
]


def test_gcp_impersonation_access_token_missing(monkeypatch):
monkeypatch.setenv(
"GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com"
)

logger = pretend.stub(debug=pretend.call_recorder(lambda s: None))
monkeypatch.setattr(ambient, "logger", logger)

resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {})
requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp))
monkeypatch.setattr(ambient, "requests", requests)

with pytest.raises(
ambient.AmbientCredentialError,
match=r"GCP: access token missing from response",
):
ambient.detect_gcp()

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"),
pretend.call("GCP: requesting access token"),
]


def test_gcp_impersonation_identity_token_request_fail(monkeypatch):
monkeypatch.setenv(
"GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com"
)

logger = pretend.stub(debug=pretend.call_recorder(lambda s: None))
monkeypatch.setattr(ambient, "logger", logger)

access_token = pretend.stub()
get_resp = pretend.stub(
raise_for_status=lambda: None, json=lambda: {"access_token": access_token}
)
post_resp = pretend.stub(
raise_for_status=pretend.raiser(HTTPError), status_code=999
)
requests = pretend.stub(
get=pretend.call_recorder(lambda url, **kw: get_resp),
post=pretend.call_recorder(lambda url, **kw: post_resp),
HTTPError=HTTPError,
)
monkeypatch.setattr(ambient, "requests", requests)

with pytest.raises(
ambient.AmbientCredentialError,
match=r"GCP: OIDC token request failed \(code=999\)",
):
ambient.detect_gcp()

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"),
pretend.call("GCP: requesting access token"),
pretend.call("GCP: requesting OIDC token"),
]


def test_gcp_impersonation_identity_token_missing(monkeypatch):
monkeypatch.setenv(
"GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com"
)

logger = pretend.stub(debug=pretend.call_recorder(lambda s: None))
monkeypatch.setattr(ambient, "logger", logger)

access_token = pretend.stub()
get_resp = pretend.stub(
raise_for_status=lambda: None, json=lambda: {"access_token": access_token}
)
post_resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {})
requests = pretend.stub(
get=pretend.call_recorder(lambda url, **kw: get_resp),
post=pretend.call_recorder(lambda url, **kw: post_resp),
HTTPError=HTTPError,
)
monkeypatch.setattr(ambient, "requests", requests)

with pytest.raises(
ambient.AmbientCredentialError,
match=r"GCP: OIDC token missing from response",
):
ambient.detect_gcp()

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"),
pretend.call("GCP: requesting access token"),
pretend.call("GCP: requesting OIDC token"),
]


def test_gcp_impersonation_succeeds(monkeypatch):
monkeypatch.setenv(
"GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com"
)

logger = pretend.stub(debug=pretend.call_recorder(lambda s: None))
monkeypatch.setattr(ambient, "logger", logger)

access_token = pretend.stub()
oidc_token = pretend.stub()
get_resp = pretend.stub(
raise_for_status=lambda: None, json=lambda: {"access_token": access_token}
)
post_resp = pretend.stub(
raise_for_status=lambda: None, json=lambda: {"token": oidc_token}
)
requests = pretend.stub(
get=pretend.call_recorder(lambda url, **kw: get_resp),
post=pretend.call_recorder(lambda url, **kw: post_resp),
HTTPError=HTTPError,
)
monkeypatch.setattr(ambient, "requests", requests)

assert ambient.detect_gcp() == oidc_token

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"),
pretend.call("GCP: requesting access token"),
pretend.call("GCP: requesting OIDC token"),
pretend.call("GCP: successfully requested OIDC token"),
]


def test_gcp_bad_env(monkeypatch):
oserror = pretend.raiser(OSError)
monkeypatch.setitem(ambient.__builtins__, "open", oserror) # type: ignore
Expand All @@ -149,6 +305,9 @@ def test_gcp_bad_env(monkeypatch):
assert ambient.detect_gcp() is None
assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call(
"GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation"
),
pretend.call("GCP: environment doesn't have GCP product name file; giving up"),
]

Expand All @@ -171,6 +330,9 @@ def test_gcp_wrong_product(monkeypatch):

assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call(
"GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation"
),
]


Expand All @@ -194,7 +356,7 @@ def test_detect_gcp_request_fails(monkeypatch):
ambient.detect_gcp()
assert requests.get.calls == [
pretend.call(
ambient.GCP_ID_TOKEN_REQUEST_URL,
ambient.GCP_IDENTITY_REQUEST_URL,
params={"audience": "sigstore", "format": "full"},
headers={"Metadata-Flavor": "Google"},
)
Expand Down Expand Up @@ -222,13 +384,16 @@ def test_detect_gcp(monkeypatch, product_name):
assert ambient.detect_gcp() == "fakejwt"
assert requests.get.calls == [
pretend.call(
ambient.GCP_ID_TOKEN_REQUEST_URL,
ambient.GCP_IDENTITY_REQUEST_URL,
params={"audience": "sigstore", "format": "full"},
headers={"Metadata-Flavor": "Google"},
)
]
assert logger.debug.calls == [
pretend.call("GCP: looking for OIDC credentials"),
pretend.call(
"GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation"
),
pretend.call("GCP: requesting OIDC token"),
pretend.call("GCP: successfully requested OIDC token"),
]

0 comments on commit ccb00ad

Please sign in to comment.