| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803 |
- # -*- coding: utf-8 -*-
- # MinIO Python Library for Amazon S3 Compatible Cloud Storage,
- # (C) 2020 MinIO, Inc.
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
- # pylint: disable=too-many-branches
- """Credential providers."""
- from __future__ import annotations
- import configparser
- import ipaddress
- import json
- import os
- import re
- import socket
- import sys
- import time
- from abc import ABC, abstractmethod
- from datetime import timedelta
- from pathlib import Path
- from typing import Callable, Optional, cast
- from urllib.parse import urlencode, urlsplit, urlunsplit
- from xml.etree import ElementTree as ET
- import certifi
- from urllib3.poolmanager import PoolManager
- try:
- from urllib3.response import BaseHTTPResponse # type: ignore[attr-defined]
- except ImportError:
- from urllib3.response import HTTPResponse as BaseHTTPResponse
- from urllib3.util import Retry, parse_url
- from minio.helpers import sha256_hash, url_replace
- from minio.signer import sign_v4_sts
- from minio.time import from_iso8601utc, to_amz_date, utcnow
- from minio.xml import find, findtext
- from .credentials import Credentials
- _MIN_DURATION_SECONDS = int(timedelta(minutes=15).total_seconds())
- _MAX_DURATION_SECONDS = int(timedelta(days=7).total_seconds())
- _DEFAULT_DURATION_SECONDS = int(timedelta(hours=1).total_seconds())
- def _parse_credentials(data: str, name: str) -> Credentials:
- """Parse data containing credentials XML."""
- element = ET.fromstring(data)
- element = cast(ET.Element, find(element, name, True))
- element = cast(ET.Element, find(element, "Credentials", True))
- expiration = from_iso8601utc(findtext(element, "Expiration", True))
- return Credentials(
- access_key=cast(str, findtext(element, "AccessKeyId", True)),
- secret_key=cast(str, findtext(element, "SecretAccessKey", True)),
- session_token=findtext(element, "SessionToken", True),
- expiration=expiration,
- )
- def _urlopen(
- http_client: PoolManager,
- method: str,
- url: str,
- body: Optional[str | bytes] = None,
- headers: Optional[dict[str, str | list[str] | tuple[str]]] = None,
- ) -> BaseHTTPResponse:
- """Wrapper of urlopen() handles HTTP status code."""
- res = http_client.urlopen(method, url, body=body, headers=headers)
- if res.status not in [200, 204, 206]:
- safe_url = re.sub(
- r"LDAPPassword=([^&]+)", "LDAPPassword=*REDACTED*", url,
- )
- raise ValueError(
- f"{safe_url} failed with HTTP status code {res.status}",
- )
- return res
- def _user_home_dir() -> str:
- """Return current user home folder."""
- return (
- os.environ.get("HOME") or
- os.environ.get("UserProfile") or
- str(Path.home())
- )
- class Provider(ABC): # pylint: disable=too-few-public-methods
- """Credential retriever."""
- @abstractmethod
- def retrieve(self) -> Credentials:
- """Retrieve credentials and its expiry if available."""
- class AssumeRoleProvider(Provider):
- """Assume-role credential provider."""
- def __init__(
- self,
- *,
- sts_endpoint: str,
- access_key: str,
- secret_key: str,
- duration_seconds: int = 0,
- policy: Optional[str] = None,
- region: Optional[str] = None,
- role_arn: Optional[str] = None,
- role_session_name: Optional[str] = None,
- external_id: Optional[str] = None,
- http_client: Optional[PoolManager] = None,
- ):
- self._sts_endpoint = sts_endpoint
- self._access_key = access_key
- self._secret_key = secret_key
- self._region = region or ""
- self._http_client = http_client or PoolManager(
- retries=Retry(
- total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504],
- ),
- )
- query_params = {
- "Action": "AssumeRole",
- "Version": "2011-06-15",
- "DurationSeconds": str(
- duration_seconds
- if duration_seconds > _DEFAULT_DURATION_SECONDS
- else _DEFAULT_DURATION_SECONDS
- ),
- }
- if role_arn:
- query_params["RoleArn"] = role_arn
- if role_session_name:
- query_params["RoleSessionName"] = role_session_name
- if policy:
- query_params["Policy"] = policy
- if external_id:
- query_params["ExternalId"] = external_id
- self._body = urlencode(query_params)
- self._content_sha256 = sha256_hash(self._body)
- url = urlsplit(sts_endpoint)
- self._url = url
- self._host = url.netloc
- if (
- (url.scheme == "http" and url.port == 80) or
- (url.scheme == "https" and url.port == 443)
- ):
- self._host = cast(str, url.hostname)
- self._credentials: Optional[Credentials] = None
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- utctime = utcnow()
- headers = sign_v4_sts(
- method="POST",
- url=self._url,
- region=self._region,
- headers={
- "Content-Type": "application/x-www-form-urlencoded",
- "Host": self._host,
- "X-Amz-Date": to_amz_date(utctime),
- },
- credentials=Credentials(
- access_key=self._access_key,
- secret_key=self._secret_key,
- ),
- content_sha256=self._content_sha256,
- date=utctime,
- )
- res = _urlopen(
- self._http_client,
- "POST",
- self._sts_endpoint,
- body=self._body,
- headers=headers,
- )
- self._credentials = _parse_credentials(
- res.data.decode(), "AssumeRoleResult",
- )
- return self._credentials
- class ChainedProvider(Provider):
- """Chained credential provider."""
- def __init__(self, providers: list[Provider]):
- self._providers = providers
- self._provider: Optional[Provider] = None
- self._credentials: Optional[Credentials] = None
- def retrieve(self) -> Credentials:
- """Retrieve credentials from one of available provider."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- if self._provider:
- try:
- self._credentials = self._provider.retrieve()
- return self._credentials
- except ValueError:
- # Ignore this error and iterate other providers.
- pass
- for provider in self._providers:
- try:
- self._credentials = provider.retrieve()
- self._provider = provider
- return self._credentials
- except ValueError:
- # Ignore this error and iterate other providers.
- pass
- raise ValueError("All providers fail to fetch credentials")
- class EnvAWSProvider(Provider):
- """Credential provider from AWS environment variables."""
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- return Credentials(
- access_key=(
- cast(
- str,
- os.environ.get("AWS_ACCESS_KEY_ID") or
- os.environ.get("AWS_ACCESS_KEY"),
- )
- ),
- secret_key=(
- cast(
- str,
- os.environ.get("AWS_SECRET_ACCESS_KEY") or
- os.environ.get("AWS_SECRET_KEY"),
- )
- ),
- session_token=os.environ.get("AWS_SESSION_TOKEN"),
- )
- class EnvMinioProvider(Provider):
- """Credential provider from MinIO environment variables."""
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- return Credentials(
- access_key=os.environ.get("MINIO_ACCESS_KEY") or "",
- secret_key=os.environ.get("MINIO_SECRET_KEY") or "",
- )
- class AWSConfigProvider(Provider):
- """Credential provider from AWS credential file."""
- def __init__(
- self,
- filename: Optional[str] = None,
- profile: Optional[str] = None,
- ):
- self._filename = (
- filename or
- os.environ.get("AWS_SHARED_CREDENTIALS_FILE") or
- os.path.join(_user_home_dir(), ".aws", "credentials")
- )
- self._profile = profile or os.environ.get("AWS_PROFILE") or "default"
- def retrieve(self) -> Credentials:
- """Retrieve credentials from AWS configuration file."""
- parser = configparser.ConfigParser()
- parser.read(self._filename)
- access_key = parser.get(
- self._profile,
- "aws_access_key_id",
- fallback=None,
- )
- secret_key = parser.get(
- self._profile,
- "aws_secret_access_key",
- fallback=None,
- )
- session_token = parser.get(
- self._profile,
- "aws_session_token",
- fallback=None,
- )
- if not access_key:
- raise ValueError(
- f"access key does not exist in profile "
- f"{self._profile} in AWS credential file {self._filename}"
- )
- if not secret_key:
- raise ValueError(
- f"secret key does not exist in profile "
- f"{self._profile} in AWS credential file {self._filename}"
- )
- return Credentials(
- access_key,
- secret_key,
- session_token=session_token,
- )
- class MinioClientConfigProvider(Provider):
- """Credential provider from MinIO Client configuration file."""
- def __init__(
- self,
- filename: Optional[str] = None,
- alias: Optional[str] = None,
- ):
- self._filename = (
- filename or
- os.path.join(
- _user_home_dir(),
- "mc" if sys.platform == "win32" else ".mc",
- "config.json",
- )
- )
- self._alias = alias or os.environ.get("MINIO_ALIAS") or "s3"
- def retrieve(self) -> Credentials:
- """Retrieve credential value from MinIO client configuration file."""
- try:
- with open(self._filename, encoding="utf-8") as conf_file:
- config = json.load(conf_file)
- aliases = config.get("hosts") or config.get("aliases")
- if not aliases:
- raise ValueError(
- f"invalid configuration in file {self._filename}",
- )
- creds = aliases.get(self._alias)
- if not creds:
- raise ValueError(
- f"alias {self._alias} not found in MinIO client"
- f"configuration file {self._filename}"
- )
- return Credentials(creds.get("accessKey"), creds.get("secretKey"))
- except (IOError, OSError) as exc:
- raise ValueError(
- f"error in reading file {self._filename}",
- ) from exc
- def _check_loopback_host(url: str):
- """Check whether host in url points only to localhost."""
- host = cast(str, parse_url(url).host)
- try:
- addrs = set(info[4][0] for info in socket.getaddrinfo(host, None))
- for addr in addrs:
- if not ipaddress.ip_address(addr).is_loopback:
- raise ValueError(host + " is not loopback only host")
- except socket.gaierror as exc:
- raise ValueError("Host " + host + " is not loopback address") from exc
- def _get_jwt_token(token_file: str) -> dict[str, str]:
- """Read and return content of token file. """
- try:
- with open(token_file, encoding="utf-8") as file:
- return {"access_token": file.read(), "expires_in": "0"}
- except (IOError, OSError) as exc:
- raise ValueError(f"error in reading file {token_file}") from exc
- class IamAwsProvider(Provider):
- """Credential provider using IAM roles for Amazon EC2/ECS."""
- def __init__(
- self,
- *,
- custom_endpoint: Optional[str] = None,
- http_client: Optional[PoolManager] = None,
- auth_token: Optional[str] = None,
- relative_uri: Optional[str] = None,
- full_uri: Optional[str] = None,
- token_file: Optional[str] = None,
- role_arn: Optional[str] = None,
- role_session_name: Optional[str] = None,
- region: Optional[str] = None,
- ):
- self._custom_endpoint = custom_endpoint
- self._http_client = http_client or PoolManager(
- retries=Retry(
- total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504],
- ),
- )
- self._token = (
- os.environ.get("AWS_CONTAINER_AUTHORIZATION_TOKEN") or
- auth_token
- )
- self._token_file = (
- os.environ.get("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") or
- auth_token
- )
- self._identity_file = (
- os.environ.get("AWS_WEB_IDENTITY_TOKEN_FILE") or token_file
- )
- self._aws_region = os.environ.get("AWS_REGION") or region
- self._role_arn = os.environ.get("AWS_ROLE_ARN") or role_arn
- self._role_session_name = (
- os.environ.get("AWS_ROLE_SESSION_NAME") or role_session_name
- )
- self._relative_uri = (
- os.environ.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") or
- relative_uri
- )
- if self._relative_uri and not self._relative_uri.startswith("/"):
- self._relative_uri = "/" + self._relative_uri
- self._full_uri = (
- os.environ.get("AWS_CONTAINER_CREDENTIALS_FULL_URI") or
- full_uri
- )
- self._credentials: Optional[Credentials] = None
- def fetch(
- self,
- url: str,
- headers: Optional[dict[str, str | list[str] | tuple[str]]] = None,
- ) -> Credentials:
- """Fetch credentials from EC2/ECS."""
- res = _urlopen(self._http_client, "GET", url, headers=headers)
- data = json.loads(res.data)
- if data.get("Code", "Success") != "Success":
- raise ValueError(
- f"{url} failed with code {data['Code']} "
- f"message {data.get('Message')}"
- )
- data["Expiration"] = from_iso8601utc(data["Expiration"])
- return Credentials(
- data["AccessKeyId"],
- data["SecretAccessKey"],
- data["Token"],
- data["Expiration"],
- )
- def retrieve(self) -> Credentials:
- """Retrieve credentials from WebIdentity/EC2/ECS."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- url = self._custom_endpoint
- if self._identity_file:
- if not url:
- url = "https://sts.amazonaws.com"
- if self._aws_region:
- url = f"https://sts.{self._aws_region}.amazonaws.com"
- if self._aws_region.startswith("cn-"):
- url += ".cn"
- provider = WebIdentityProvider(
- jwt_provider_func=lambda: _get_jwt_token(
- cast(str, self._identity_file),
- ),
- sts_endpoint=url,
- role_arn=self._role_arn,
- role_session_name=self._role_session_name,
- http_client=self._http_client,
- )
- self._credentials = provider.retrieve()
- return cast(Credentials, self._credentials)
- headers: Optional[dict[str, str | list[str] | tuple[str]]] = None
- if self._relative_uri:
- if not url:
- url = "http://169.254.170.2" + self._relative_uri
- headers = {"Authorization": self._token} if self._token else None
- elif self._full_uri:
- token = self._token
- if self._token_file:
- url = self._full_uri
- with open(self._token_file, encoding="utf-8") as file:
- token = file.read()
- else:
- if not url:
- url = self._full_uri
- _check_loopback_host(url)
- headers = {"Authorization": token} if token else None
- else:
- if not url:
- url = "http://169.254.169.254"
- # Get IMDS Token
- res = _urlopen(
- self._http_client,
- "PUT",
- url+"/latest/api/token",
- headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"},
- )
- token = res.data.decode("utf-8")
- headers = {"X-aws-ec2-metadata-token": token} if token else None
- # Get role name
- url = urlunsplit(
- url_replace(
- url=urlsplit(url),
- path="/latest/meta-data/iam/security-credentials/",
- ),
- )
- res = _urlopen(self._http_client, "GET", url, headers=headers)
- role_names = res.data.decode("utf-8").split("\n")
- if not role_names:
- raise ValueError(f"no IAM roles attached to EC2 service {url}")
- url += role_names[0].strip("\r")
- if not url:
- raise ValueError("url is empty; this should not happen")
- self._credentials = self.fetch(url, headers=headers)
- return self._credentials
- class LdapIdentityProvider(Provider):
- """Credential provider using AssumeRoleWithLDAPIdentity API."""
- def __init__( # pylint: disable=too-many-positional-arguments
- self,
- sts_endpoint: str,
- ldap_username: str,
- ldap_password: str,
- duration_seconds: Optional[int] = None,
- policy: Optional[str] = None,
- token_revoke_type: Optional[str] = None,
- http_client: Optional[PoolManager] = None,
- ):
- query_params = {
- "Action": "AssumeRoleWithLDAPIdentity",
- "Version": "2011-06-15",
- "LDAPUsername": ldap_username,
- "LDAPPassword": ldap_password,
- }
- if duration_seconds:
- query_params["DurationSeconds"] = str(duration_seconds)
- if policy:
- query_params["Policy"] = policy
- if token_revoke_type:
- query_params["TokenRevokeType"] = token_revoke_type
- self._sts_endpoint = sts_endpoint + "?" + urlencode(query_params)
- self._http_client = http_client or PoolManager(
- retries=Retry(
- total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504],
- ),
- )
- self._credentials: Optional[Credentials] = None
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- res = _urlopen(
- self._http_client,
- "POST",
- self._sts_endpoint,
- )
- self._credentials = _parse_credentials(
- res.data.decode(), "AssumeRoleWithLDAPIdentityResult",
- )
- return self._credentials
- class StaticProvider(Provider):
- """Fixed credential provider."""
- def __init__(
- self,
- access_key: str,
- secret_key: str,
- session_token: Optional[str] = None,
- ):
- self._credentials = Credentials(access_key, secret_key, session_token)
- def retrieve(self) -> Credentials:
- """Return passed credentials."""
- return self._credentials
- class WebIdentityClientGrantsProvider(Provider, ABC):
- """Base class for WebIdentity and ClientGrants credentials provider."""
- def __init__(
- self,
- *,
- jwt_provider_func: Callable[[], dict[str, str]],
- sts_endpoint: str,
- duration_seconds: int = 0,
- policy: Optional[str] = None,
- role_arn: Optional[str] = None,
- role_session_name: Optional[str] = None,
- http_client: Optional[PoolManager] = None,
- ):
- self._jwt_provider_func = jwt_provider_func
- self._sts_endpoint = sts_endpoint
- self._duration_seconds = duration_seconds
- self._policy = policy
- self._role_arn = role_arn
- self._role_session_name = role_session_name
- self._http_client = http_client or PoolManager(
- retries=Retry(
- total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504],
- ),
- )
- self._credentials: Optional[Credentials] = None
- @abstractmethod
- def _is_web_identity(self) -> bool:
- """Check if derived class deal with WebIdentity."""
- def _get_duration_seconds(self, expiry: int) -> int:
- """Get DurationSeconds optimal value."""
- if self._duration_seconds:
- expiry = self._duration_seconds
- if expiry > _MAX_DURATION_SECONDS:
- return _MAX_DURATION_SECONDS
- if expiry <= 0:
- return expiry
- return (
- _MIN_DURATION_SECONDS if expiry < _MIN_DURATION_SECONDS else expiry
- )
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- jwt = self._jwt_provider_func()
- query_params = {"Version": "2011-06-15"}
- duration_seconds = self._get_duration_seconds(
- int(jwt.get("expires_in", "0")),
- )
- if duration_seconds:
- query_params["DurationSeconds"] = str(duration_seconds)
- if self._policy:
- query_params["Policy"] = self._policy
- access_token = jwt.get("access_token") or jwt.get("id_token", "")
- if self._is_web_identity():
- query_params["Action"] = "AssumeRoleWithWebIdentity"
- query_params["WebIdentityToken"] = access_token
- if self._role_arn:
- query_params["RoleArn"] = self._role_arn
- query_params["RoleSessionName"] = (
- self._role_session_name
- if self._role_session_name
- else str(time.time()).replace(".", "")
- )
- else:
- query_params["Action"] = "AssumeRoleWithClientGrants"
- query_params["Token"] = access_token
- url = self._sts_endpoint + "?" + urlencode(query_params)
- res = _urlopen(self._http_client, "POST", url)
- self._credentials = _parse_credentials(
- res.data.decode(),
- (
- "AssumeRoleWithWebIdentityResult"
- if self._is_web_identity()
- else "AssumeRoleWithClientGrantsResult"
- ),
- )
- return self._credentials
- class ClientGrantsProvider(WebIdentityClientGrantsProvider):
- """Credential provider using AssumeRoleWithClientGrants API."""
- def __init__(
- self,
- *,
- jwt_provider_func: Callable[[], dict[str, str]],
- sts_endpoint: str,
- duration_seconds: int = 0,
- policy: Optional[str] = None,
- http_client: Optional[PoolManager] = None,
- ):
- super().__init__(
- jwt_provider_func=jwt_provider_func,
- sts_endpoint=sts_endpoint,
- duration_seconds=duration_seconds,
- policy=policy,
- http_client=http_client,
- )
- def _is_web_identity(self) -> bool:
- return False
- class WebIdentityProvider(WebIdentityClientGrantsProvider):
- """Credential provider using AssumeRoleWithWebIdentity API."""
- def _is_web_identity(self) -> bool:
- return True
- class CertificateIdentityProvider(Provider):
- """Credential provider using AssumeRoleWithCertificate API."""
- def __init__(
- self,
- *,
- sts_endpoint: str,
- cert_file: Optional[str] = None,
- key_file: Optional[str] = None,
- key_password: Optional[str] = None,
- ca_certs: Optional[str] = None,
- duration_seconds: int = 0,
- http_client: Optional[PoolManager] = None,
- ):
- if urlsplit(sts_endpoint).scheme != "https":
- raise ValueError("STS endpoint scheme must be HTTPS")
- if not bool(http_client) != (cert_file and key_file):
- raise ValueError(
- "either cert/key file or custom http_client must be provided",
- )
- self._sts_endpoint = sts_endpoint + "?" + urlencode(
- {
- "Action": "AssumeRoleWithCertificate",
- "Version": "2011-06-15",
- "DurationSeconds": str(
- duration_seconds
- if duration_seconds > _DEFAULT_DURATION_SECONDS
- else _DEFAULT_DURATION_SECONDS
- ),
- },
- )
- self._http_client = http_client or PoolManager(
- maxsize=10,
- cert_file=cert_file,
- cert_reqs='CERT_REQUIRED',
- key_file=key_file,
- key_password=key_password,
- ca_certs=ca_certs or certifi.where(),
- retries=Retry(
- total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504],
- ),
- )
- self._credentials: Optional[Credentials] = None
- def retrieve(self) -> Credentials:
- """Retrieve credentials."""
- if self._credentials and not self._credentials.is_expired():
- return self._credentials
- res = _urlopen(
- self._http_client,
- "POST",
- self._sts_endpoint,
- )
- self._credentials = _parse_credentials(
- res.data.decode(), "AssumeRoleWithCertificateResult",
- )
- return self._credentials
|