Skip to content

Commit 5c6d955

Browse files
authored
Merge branch 'master' into dependabot/npm_and_yarn/ui/webpack-dev-server-5.2.1
2 parents 81a20df + 5d230e1 commit 5c6d955

File tree

24 files changed

+1099
-79
lines changed

24 files changed

+1099
-79
lines changed

api/azimuth/authentication.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def authenticate(self, request):
4141
session = cloud_settings.PROVIDER.from_auth_session(auth_session)
4242
except errors.AuthenticationError as exc:
4343
# If a session cannot be resolved from the token, then it has expired
44-
logger.exception('Authentication failed: %s', str(exc))
44+
logger.warning('Authentication failed: %s', str(exc))
4545
raise AuthenticationFailed(str(exc))
4646
else:
4747
# If the token resolved, return an authenticated user

api/azimuth/keystore/provider.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
to store public keys.
44
"""
55

6-
from ..provider.errors import ObjectNotFoundError
6+
from ..provider import errors as provider_errors
77

8-
from .base import KeyStore
9-
from .errors import KeyNotFound
8+
from . import base, errors
109

1110

12-
class ProviderKeyStore(KeyStore):
11+
class ProviderKeyStore(base.KeyStore):
1312
"""
1413
Key store implementation that consumes keypairs using provider functionality.
1514
"""
@@ -19,8 +18,10 @@ def get_key(self, username, *, unscoped_session, **kwargs):
1918
# Just return the SSH public key from the provider session
2019
try:
2120
return unscoped_session.ssh_public_key()
22-
except ObjectNotFoundError:
23-
raise KeyNotFound(username)
21+
except provider_errors.UnsupportedOperationError as exc:
22+
raise errors.UnsupportedOperation(str(exc))
23+
except provider_errors.ObjectNotFoundError:
24+
raise errors.KeyNotFound(username)
2425

2526
def update_key(self, username, public_key, *, unscoped_session, **kwargs):
2627
# Just use the provider session to update the public key

api/azimuth/provider/base.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def wrapper(*args, **kwargs):
3131
raise errors.ObjectNotFoundError(str(exc))
3232
except auth_errors.InvalidOperationError as exc:
3333
raise errors.InvalidOperationError(str(exc))
34+
except auth_errors.UnsupportedOperationError as exc:
35+
raise errors.UnsupportedOperationError(str(exc))
3436
except auth_errors.CommunicationError as exc:
3537
raise errors.CommunicationError(str(exc)) from exc
3638
except auth_errors.Error as exc:
@@ -129,7 +131,7 @@ def _scoped_session(
129131
self,
130132
auth_user: auth_dto.User,
131133
tenancy: dto.Tenancy,
132-
credential_data: Any
134+
credential_data: str
133135
) -> 'ScopedSession':
134136
"""
135137
Private method that creates a scoped session for the given tenancy.
@@ -152,11 +154,12 @@ def scoped_session(self, tenancy: Union[dto.Tenancy, str]) -> 'ScopedSession':
152154
raise errors.ObjectNotFoundError(
153155
"Could not find tenancy with ID {}.".format(tenancy)
154156
)
155-
# Get the credential from the auth session
156-
credential = self.auth_session.credential(tenancy.id)
157-
# Verify that the provider matches this provider
158-
if credential.provider != self.provider_name:
159-
raise errors.InvalidOperationError("credential is for a different provider")
157+
# Get the credential from the auth session for this provider
158+
credential = self.auth_session.credential(tenancy.id, self.provider_name)
159+
# If the auth session is unable to supply a credential for the provider, bail
160+
if not credential:
161+
msg = f"no credentials available for {self.provider_name} provider"
162+
raise errors.InvalidOperationError(msg)
160163
return self._scoped_session(self.auth_user, tenancy, credential.data)
161164

162165
def close(self):

api/azimuth/provider/openstack/api/core.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,31 @@ def from_clouds(cls, data):
265265
token = cloud_data["auth"]["token"]
266266
response = requests.get(
267267
f"{auth_url}/auth/tokens",
268-
headers = {"X-Auth-Token": token, "X-Subject-Token": token}
268+
headers = {"X-Auth-Token": token, "X-Subject-Token": token},
269+
verify = verify
269270
)
270271
response.raise_for_status()
271272
token_data = response.json()["token"]
273+
elif cloud_data["auth_type"] == "v3applicationcredential":
274+
response = requests.post(
275+
f"{auth_url}/auth/tokens",
276+
json = {
277+
"auth": {
278+
"identity": {
279+
"methods": ["application_credential"],
280+
"application_credential": {
281+
"id": cloud_data["auth"]["application_credential_id"],
282+
"secret": cloud_data["auth"]["application_credential_secret"],
283+
},
284+
},
285+
},
286+
},
287+
verify = verify
288+
)
289+
response.raise_for_status()
290+
token = response.headers["X-Subject-Token"]
291+
token_data = response.json()["token"]
272292
else:
273-
# TODO(mkjpryor)
274-
# For other credential types, exchange the credential for a token
275293
raise UnsupportedAuthType(cloud_data["auth_type"])
276294
# Extract the endpoints from the catalog for the correct interface and region
277295
endpoints = {}

api/azimuth/provider/openstack/provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def _scoped_session(self, auth_user, tenancy, credential_data):
214214
return ScopedSession(
215215
auth_user,
216216
tenancy,
217-
api.Connection.from_clouds(credential_data),
217+
api.Connection.from_clouds(yaml.safe_load(credential_data)),
218218
self._metadata_prefix,
219219
self._internal_net_template,
220220
self._external_net_template,

api/azimuth/settings.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,8 @@ class ZenithSetting(Setting):
169169
def __init__(self):
170170
super().__init__(dict)
171171

172-
def __get__(self, instance, owner):
173-
user_settings = super().__get__(instance, owner)
174-
apps_settings = AppsSettings(self.name, user_settings)
172+
def _transform(self, instance, value):
173+
apps_settings = AppsSettings(self.name, value)
175174
if apps_settings.ENABLED:
176175
return Zenith(
177176
apps_settings.BASE_DOMAIN,

api/azimuth/utils.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itertools
12
import logging
23
import re
34

@@ -7,12 +8,22 @@
78

89

910
MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"
10-
TENANCY_ID_LABEL = "azimuth.stackhpc.com/tenant-id"
11+
# A legacy stackhpc.com label and a new-style azimuth-cloud.io label are both supported
12+
TENANCY_ID_LABEL = "tenant.azimuth-cloud.io/id"
13+
TENANCY_ID_LABEL_LEGACY = "azimuth.stackhpc.com/tenant-id"
1114

1215

1316
logger = logging.getLogger(__name__)
1417

1518

19+
class DuplicateTenancyIDError(Exception):
20+
"""
21+
Raised when there are multiple namespaces with the same tenancy ID.
22+
"""
23+
def __init__(self, tenancy_id: str):
24+
super().__init__(f"multiple tenancy namespaces found with ID '{tenancy_id}'")
25+
26+
1627
class NamespaceOwnershipError(Exception):
1728
"""
1829
Raised when there is a conflict in the namespace ownership.
@@ -31,6 +42,28 @@ def sanitise(value):
3142
return re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-")
3243

3344

45+
def unique_namespaces(ekresource, tenancy_id):
46+
"""
47+
Returns an iterator over the unique namespaces for the given tenancy ID.
48+
"""
49+
seen_namespaces = set()
50+
for namespace in ekresource.list(labels = {TENANCY_ID_LABEL: tenancy_id}):
51+
# We won't see any duplicate namespaces in this first loop
52+
seen_namespaces.add(namespace["metadata"]["name"])
53+
yield namespace
54+
for namespace in ekresource.list(labels = {TENANCY_ID_LABEL_LEGACY: tenancy_id}):
55+
# We might see namespaces in this loop that appeared in the previous loop, if a
56+
# namespace has both labels
57+
ns_name = namespace["metadata"]["name"]
58+
if ns_name in seen_namespaces:
59+
logger.warning(
60+
f"namespace '{ns_name}' has both new-style and legacy tenancy ID labels"
61+
)
62+
continue
63+
else:
64+
yield namespace
65+
66+
3467
def get_namespace(ekclient, tenancy: dto.Tenancy) -> str:
3568
"""
3669
Returns the correct namespace to use for the given tenancy.
@@ -40,19 +73,21 @@ def get_namespace(ekclient, tenancy: dto.Tenancy) -> str:
4073
ekresource = ekclient.api("v1").resource("namespaces")
4174
expected_namespace = f"az-{tenancy_name}"
4275
# Try to find the namespace that is labelled with the tenant ID
43-
try:
44-
namespace = next(ekresource.list(labels = {TENANCY_ID_LABEL: tenancy_id}))
45-
except StopIteration:
46-
pass
47-
else:
48-
found_namespace = namespace["metadata"]["name"]
76+
# We require that the namespace is unique
77+
namespaces = list(unique_namespaces(ekresource, tenancy_id))
78+
# If there is exactly one namespace, return it
79+
if len(namespaces) == 1:
80+
found_namespace = namespaces[0]["metadata"]["name"]
4981
logger.info(f"using namespace '{found_namespace}' for tenant '{tenancy_id}'")
5082
if found_namespace != expected_namespace:
51-
logger.warn(
83+
logger.warning(
5284
f"expected namespace '{expected_namespace}' for "
5385
f"tenant '{tenancy_id}', but found '{found_namespace}'"
5486
)
5587
return found_namespace
88+
# If there are multiple namespaces with the ID, bail
89+
elif len(namespaces) > 1:
90+
raise DuplicateTenancyIDError(tenancy_id)
5691
# If there is no namespace labelled with the tenant ID, find the namespace
5792
# that uses the standard naming convention
5893
try:
@@ -65,7 +100,8 @@ def get_namespace(ekclient, tenancy: dto.Tenancy) -> str:
65100
else:
66101
raise
67102
# Before returning it, verify that it isn't labelled with another tenancy ID
68-
owner_id = namespace["metadata"].get("labels", {}).get(TENANCY_ID_LABEL)
103+
labels = namespace["metadata"].get("labels", {})
104+
owner_id = labels.get(TENANCY_ID_LABEL, labels.get(TENANCY_ID_LABEL_LEGACY))
69105
if not owner_id or owner_id == tenancy_id:
70106
logger.info(f"using namespace '{expected_namespace}' for tenant '{tenancy_id}'")
71107
return expected_namespace

api/azimuth/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,7 +1171,10 @@ def clusters(request, tenant):
11711171
unscoped_session = request.auth,
11721172
scoped_session = session
11731173
)
1174-
except keystore_errors.KeyNotFound:
1174+
except (
1175+
keystore_errors.UnsupportedOperation,
1176+
keystore_errors.KeyNotFound
1177+
):
11751178
ssh_key = None
11761179
cluster = cluster_manager.create_cluster(
11771180
input_serializer.validated_data["name"],
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import base64
2+
import json
3+
import logging
4+
import urllib.parse
5+
6+
import httpx
7+
8+
import oauthlib.common
9+
import oauthlib.oauth2
10+
11+
from . import redirect
12+
13+
14+
class AuthorizationCodeAuthenticator(redirect.RedirectAuthenticator):
15+
"""
16+
Authenticator that uses the OIDC authorization flow to obtain a token.
17+
18+
The OAuth2 flow is implemented here rather than using an ingress auth callout with
19+
oauth2-proxy primarily because we need to be able to have some requests that are
20+
"optionally authenticated".
21+
22+
This makes it possible to do things like render the Azimuth homepage to unauthenticated
23+
users so that they can discover docs on how to register while also understanding when a
24+
user is authenticated, and to present nicer messages to API consumers when unauthenticated
25+
requests are made.
26+
27+
This kind of optional authentication is extremely difficult to configure with the ingress
28+
callout as implemented by the NGINX ingress controller.
29+
30+
Potential issues with writing our own authentication code are mitigated by using the
31+
oauthlib library to generate OAuth2 request URIs/bodies and to parse the responses.
32+
"""
33+
authenticator_type = "oidc_authcode"
34+
35+
def __init__(
36+
self,
37+
authorization_url,
38+
token_url,
39+
client_id,
40+
client_secret,
41+
scope,
42+
state_session_key,
43+
verify_ssl
44+
):
45+
self.authorization_url = authorization_url
46+
self.token_url = token_url
47+
self.client_id = client_id
48+
self.client_secret = client_secret
49+
self.scope = scope
50+
self.state_session_key = state_session_key
51+
self.verify_ssl = verify_ssl
52+
self.logger = logging.getLogger(__name__)
53+
54+
def prepare_redirect_url(self, redirect_url):
55+
# Strip parameters from the URL
56+
components = urllib.parse.urlsplit(redirect_url)
57+
components = components._replace(query = "", fragment = "")
58+
return urllib.parse.urlunsplit(components)
59+
60+
def get_redirect_to(self, request, auth_complete_url, selected_option = None):
61+
client = oauthlib.oauth2.WebApplicationClient(self.client_id)
62+
# Generate new state parameter and stash it in the session
63+
state = request.session[self.state_session_key] = oauthlib.common.generate_token()
64+
# Generate the full authorization URL with parameters
65+
return client.prepare_request_uri(
66+
self.authorization_url,
67+
redirect_uri = self.prepare_redirect_url(auth_complete_url),
68+
scope = self.scope,
69+
state = state
70+
)
71+
72+
def auth_complete(self, request, selected_option = None):
73+
client = oauthlib.oauth2.WebApplicationClient(self.client_id)
74+
# Pull the state from the session
75+
# If it fails, log the error and try again
76+
try:
77+
state = request.session.pop(self.state_session_key)
78+
except KeyError:
79+
self.logger.warning("no OIDC state in session")
80+
return None
81+
# Extract the code from the URL
82+
# If it fails, log the error and try again
83+
request_uri = request.build_absolute_uri()
84+
try:
85+
code = client.parse_request_uri_response(request_uri, state)["code"]
86+
except (oauthlib.oauth2.OAuth2Error, KeyError):
87+
self.logger.exception("error extracting authcode")
88+
return None
89+
# Make the token request
90+
# If it fails, log the error and try again
91+
try:
92+
response = httpx.post(
93+
self.token_url,
94+
data = dict(
95+
oauthlib.common.urldecode(
96+
client.prepare_request_body(
97+
code,
98+
self.prepare_redirect_url(request_uri),
99+
include_client_id = True,
100+
client_secret = self.client_secret
101+
)
102+
)
103+
),
104+
headers = {
105+
"Accept": "application/json",
106+
"Content-Type": "application/x-www-form-urlencoded",
107+
},
108+
verify = self.verify_ssl
109+
)
110+
except httpx.RequestError:
111+
self.logger.exception("error fetching token")
112+
return None
113+
if not response.is_success:
114+
self.logger.error(
115+
f"error fetching token "
116+
f"\"{response.status_code} {response.reason_phrase}\" "
117+
f"{response.text}"
118+
)
119+
return None
120+
# Parse the token from the response
121+
# If it fails, log the error and try again
122+
try:
123+
token_data = client.parse_request_body_response(response.text, scope = self.scope)
124+
except oauthlib.oauth2.OAuth2Error:
125+
self.logger.exception("error extracting token from response")
126+
return None
127+
# The token that we return is a base64-encoded JSON dump of the token data
128+
# This means that the OIDC session can consume both the access and refresh tokens
129+
return base64.b64encode(json.dumps(token_data).encode()).decode()

0 commit comments

Comments
 (0)