Skip to content

Commit c3c9ea1

Browse files
authored
feat: new account loader plugin with http auth (#32)
The existing account loaders in the awsrun.acctload and their respective CLI-counterparts in awsrun.plugins.accts do not support HTTP auth: - awsrun.acctload.JSONAccountLoader (awsrun.plugins.accts.JSON) - awsrun.acctload.YAMLAccountLoader (awsrun.plugins.accts.YAML) - awsrun.acctload.CSVAccountLoader (awsrun.plugins.accts.CSV) This change introduces a new account loader, which will eventually replace those mentioned above: - awsrun.acctload.URLAccountLoader (awsrun.plugins.accts.URLLoader) The new loader can parse JSON, YAML, and CSV data depending on the parser provided (library usage) or parser specified via the `parser` configuration key (CLI usage). In addition, the new loader supports HTTP basic, digest, ntlm, and oauth2 authentication methods depending on the auth method provided (library usage) or auth method specified via the `auth` configuration key (CLI usage). Sample usage via the library: # Library usage from awsrun.acctload import * # JSON example with basic HTTP auth loader = URLAccountLoader( "https://example.com/data.json", parser=JSONFormatter(), auth=HTTPBasic(user, pw), ) # CSV example with OAuth2 HTTP auth loader = URLAccountLoader( "https://example.com/data.csv", parser=CSVFormatter(delimiter=","), auth=HTTPOAuth2("https://token.example.com", user, pw), ) Sample usage via the CLI configuration file: Accounts: plugin: awsrun.plugins.accts.URLLoader options: url: https://example.com/data.json parser: json auth: oauth2 auth_options: token_url: https://token.example.com
1 parent 1dd643e commit c3c9ea1

File tree

4 files changed

+574
-3
lines changed

4 files changed

+574
-3
lines changed

src/awsrun/acctload.py

+169
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
import requests
6262
import yaml
63+
from requests.auth import AuthBase
6364
from requests_file import FileAdapter
6465

6566
from awsrun.cache import PersistentExpiringValue
@@ -916,6 +917,174 @@ def __init__(self, name_regexp=None):
916917
super().__init__(accts)
917918

918919

920+
class CSVParser:
921+
"""Returns a list of dicts from a buffer of CSV text.
922+
923+
To override options passed to `csv.DictReader`, specify them as keyword
924+
arguments in the constructor. By default, the `delimiter` is `","` and
925+
`skipinitialspace` is `True`.
926+
"""
927+
928+
def __init__(self, **kwargs):
929+
self.kwargs = kwargs
930+
self.kwargs.setdefault("delimiter", ",")
931+
self.kwargs.setdefault("skipinitialspace", True)
932+
933+
def __call__(self, text):
934+
buf = io.StringIO(text.strip())
935+
return list(csv.DictReader(buf, **self.kwargs))
936+
937+
938+
class JSONParser:
939+
"""Returns a list or dict from a buffer of JSON-formatted text.
940+
941+
To override options passed to `json.loads`, specify them as keyword
942+
arguments in the constructor.
943+
"""
944+
945+
def __init__(self, **kwargs):
946+
self.kwargs = kwargs
947+
948+
def __call__(self, text):
949+
return json.loads(text, **self.kwargs)
950+
951+
952+
class YAMLParser:
953+
"""Returns a list or dict from a buffer of YAML-formatted text.
954+
955+
To override options passed to `yaml.safe_load`, specify them as keyword
956+
arguments in the constructor.
957+
"""
958+
959+
def __init__(self, **kwargs):
960+
self.kwargs = kwargs
961+
962+
def __call__(self, text):
963+
return yaml.safe_load(text, **self.kwargs)
964+
965+
966+
class HTTPOAuth2(AuthBase):
967+
"""Attaches an OAuth2 bearer token to the given `requests.Request` object.
968+
969+
Use `token_url` to specify the token provider's URL. The `client_id` and
970+
`client_secret` specify the credentials used to authenticate with the
971+
token provider. Three additional keyword parameters are accepted:
972+
973+
`scope`
974+
: Default is "AppIdClaimsTrust".
975+
976+
`grant_type`
977+
: Default is "client_credentials".
978+
979+
`intent`
980+
: Default is "awsrun account loader plugin"
981+
"""
982+
983+
def __init__(
984+
self,
985+
token_url,
986+
username,
987+
password,
988+
scope="AppIdClaimsTrust",
989+
grant_type="client_credentials",
990+
intent="awsrun account loader plugin",
991+
):
992+
self.url = token_url
993+
self.data = {}
994+
self.data["client_id"] = username
995+
self.data["client_secret"] = password
996+
self.data["scope"] = scope
997+
self.data["grant_type"] = grant_type
998+
self.data["intent"] = intent
999+
1000+
def _get_token(self):
1001+
resp = requests.post(self.url, data=self.data)
1002+
resp.raise_for_status()
1003+
return resp.json()["access_token"]
1004+
1005+
def __call__(self, req):
1006+
req.headers["Authorization"] = f"Bearer {self._get_token()}"
1007+
return req
1008+
1009+
1010+
class URLAccountLoader(MetaAccountLoader):
1011+
"""Returns an `AccountLoader` with accounts loaded from a URL.
1012+
1013+
Loaded accounts will include metadata associated with each account in the
1014+
document retrieved from the `url`. File based URLs can be used to load
1015+
data from a local file. This data will be parsed as JSON by default. To
1016+
override, use `parser` to specify a callable that accepts the text and
1017+
returns a list or dict of accounts (see `MetaAccountLoader`). To cache the
1018+
results, specify a non-zere number of seconds in `max_age`. The default
1019+
location on disk is the system temp directory in a file called
1020+
`awsrun.dat`, which can be overrided via `cache_path`.
1021+
1022+
Given the following JSON:
1023+
1024+
{
1025+
"Accounts": [
1026+
{"id": "100200300400", "env": "prod", "status": "active"},
1027+
{"id": "200300400100", "env": "non-prod", "status": "active"},
1028+
{"id": "300400100200", "env": "non-prod", "status": "suspended"}
1029+
]
1030+
}
1031+
1032+
The account loader will build account objects with the following attribute
1033+
names: `id`, `env`, `status`. Assume the above JSON is returned from
1034+
http://example.com/accts.json:
1035+
1036+
loader = URLAccountLoader('http://example.com/accts.json', path=['Accounts'])
1037+
accts = loader.accounts()
1038+
1039+
# Let's inspect the 1st account object and its metadata
1040+
assert accts[0].id == '100200300400'
1041+
assert accts[0].env == 'prod'
1042+
assert accts[0].status == 'active'
1043+
1044+
URLAccountLoader is a subclass of the `MetaAccountLoader`, which loads
1045+
accounts from a set of dicts. As such, the remainder of the parameters in
1046+
the constructor -- `id_attr`, `path`, `str_template`, `include_attrs`, and
1047+
`exclude_attrs` -- are defined in the constructor of `MetaAccountLoader`.
1048+
"""
1049+
1050+
def __init__(
1051+
self,
1052+
url,
1053+
parser=JSONParser(),
1054+
auth=None,
1055+
max_age=0,
1056+
id_attr="id",
1057+
path=None,
1058+
str_template=None,
1059+
include_attrs=None,
1060+
exclude_attrs=None,
1061+
no_verify=False,
1062+
cache_path=None,
1063+
):
1064+
1065+
session = requests.Session()
1066+
session.mount("file://", FileAdapter())
1067+
1068+
def load_cache():
1069+
r = session.get(url, auth=auth, verify=not no_verify)
1070+
r.raise_for_status()
1071+
return parser(r.text)
1072+
1073+
if not cache_path:
1074+
cache_path = Path(tempfile.gettempdir(), "awsrun.dat")
1075+
1076+
accts = PersistentExpiringValue(load_cache, cache_path, max_age=max_age)
1077+
1078+
super().__init__(
1079+
accts.value(),
1080+
id_attr=id_attr,
1081+
path=[] if path is None else path,
1082+
str_template=str_template,
1083+
include_attrs=[] if include_attrs is None else include_attrs,
1084+
exclude_attrs=[] if exclude_attrs is None else exclude_attrs,
1085+
)
1086+
1087+
9191088
class AbstractAccount:
9201089
"""Abstract base class used by `MetaAccountLoader` to represent an account and its metadata.
9211090

src/awsrun/cloudwatch.py

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
print(datetime, value)
3030
3131
"""
32+
3233
import logging
3334
import math
3435
from collections import defaultdict

src/awsrun/commands/aws/list_lambdas.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def regional_execute(self, session, acct, region):
102102
total = len(by_role[role])
103103
public = len([fn for fn in by_role[role] if _is_public(fn)])
104104
print(
105-
f"{acct}/{region}: role={role} total={total} private={total-public} public={public}",
105+
f"{acct}/{region}: role={role} total={total} private={total - public} public={public}",
106106
file=out,
107107
)
108108

0 commit comments

Comments
 (0)