Skip to content

Commit 7306764

Browse files
authored
Merge pull request #11 from csgroup-oss/copy-keycloak-attributes-to-config
Copy OAuth2 attributes to config
2 parents 36d69fb + c8bffe4 commit 7306764

File tree

13 files changed

+149
-46
lines changed

13 files changed

+149
-46
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ repos:
4848
- id: prettier
4949
files: \.(js|ts|jsx|tsx|css|less|html|json|markdown|md|yaml|yml)$
5050
- repo: https://github.com/hadialqattan/pycln
51-
rev: v2.2.1
51+
rev: v2.6.0
5252
hooks:
5353
- id: pycln
5454
args: [--config=pyproject.toml]

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ uvicorn app.main:app --host localhost --port 9999 --reload --log-config=log_conf
6666
| APIKM_CONTACT_URL | Contact url displayed in the swagger front page | `"https://github.com/csgroup-oss/apikey-manager/"` |
6767
| APIKM_CONTACT_EMAIL | Contact email displayed in the swagger front page | `"support@csgroup.space"` |
6868
| APIKM_OPENAPI_URL | The URL where the OpenAPI schema will be served from | `"/openapi.json"` |
69+
| APIKM_OAUTH2_ATTRIBUTES | OAuth2 attributes to save as key/values in the API key "config" dict | `"attr1,attr2"` |
6970

7071
### Endpoints
7172

app/auth/apikey_crud.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def renew_key(
128128
with self.engine.connect() as conn:
129129
t = self.t_apitoken
130130
resp = conn.execute(
131-
select(t.c["is_active", "expiration_date"]).where(
131+
select(t.c["is_active", "config"]).where(
132132
(t.c.api_key == self.__hash(api_key)) & (t.c.user_id == user_id)
133133
)
134134
).first()
@@ -139,10 +139,12 @@ def renew_key(
139139
status_code=HTTP_404_NOT_FOUND, detail="API key not found"
140140
)
141141

142+
old_active = resp[0]
143+
old_config = resp[1]
142144
response_lines = []
143145

144146
# Previously revoked key. Issue a text warning and reactivate it.
145-
if not resp[0]:
147+
if not old_active:
146148
response_lines.append(
147149
"This API key was revoked and has been reactivated."
148150
)
@@ -167,6 +169,10 @@ def renew_key(
167169
LOGGER.debug("Sync user info of `user_id` with KeyCLoak")
168170
kc_info = self.kcutil.get_user_info(user_id)
169171

172+
# Merge the old config with the new valuesread from the oauth2 attributes.
173+
# NOTE: the new values have higher priority than the old config.
174+
new_config = old_config | kc_info.attributes
175+
170176
conn.execute(
171177
t.update()
172178
.where(t.c.api_key == self.__hash(api_key))
@@ -176,6 +182,7 @@ def renew_key(
176182
latest_sync_date=datetime.now(UTC),
177183
is_active=True,
178184
expiration_date=parsed_expiration_date,
185+
config=new_config,
179186
)
180187
)
181188

@@ -248,11 +255,18 @@ def check_key(self, api_key: str) -> dict | None:
248255
if latest_sync_date.utcoffset() is None:
249256
latest_sync_date = latest_sync_date.replace(tzinfo=timezone.utc)
250257

258+
# If the apikey info has not been updated from keycloak for a long time
251259
if settings.keycloak_sync_freq > 0 and datetime.now(UTC) > (
252260
latest_sync_date + timedelta(seconds=settings.keycloak_sync_freq)
253261
):
254262
LOGGER.debug(f"Sync user info of `{response['user_id']}` with KeyCLoak")
255263
kc_info = self.kcutil.get_user_info(response["user_id"])
264+
265+
# Merge the old config with the new valuesread from the oauth2
266+
# attributes.
267+
# NOTE: the new values have higher priority than the old config.
268+
new_config = response["config"] | kc_info.attributes
269+
256270
# Update the database
257271
conn.execute(
258272
t.update()
@@ -261,12 +275,14 @@ def check_key(self, api_key: str) -> dict | None:
261275
user_active=kc_info.is_enabled,
262276
iam_roles=kc_info.roles,
263277
latest_sync_date=datetime.now(UTC),
278+
config=new_config,
264279
)
265280
)
266281
conn.commit()
267282

268283
response["user_active"] = kc_info.is_enabled
269284
response["iam_roles"] = kc_info.roles
285+
response["config"] = new_config
270286

271287
if not response["user_active"]:
272288
# If user is not active anymore

app/auth/authlib_oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ async def authlib_oauth(request: Request) -> AuthInfo:
132132
user_info = kcutil.get_user_info(user_id)
133133

134134
if user_info.is_enabled:
135-
return AuthInfo(user_id, user_login, user_info.roles)
135+
return AuthInfo(user_id, user_login, user_info.roles, user_info.attributes)
136136
else:
137137
raise HTTPException(
138138
status_code=status.HTTP_401_UNAUTHORIZED,

app/auth/keycloak_util.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import logging
2020
from dataclasses import dataclass
21+
from typing import Any
2122

2223
from keycloak import KeycloakAdmin, KeycloakError, KeycloakOpenIDConnection
2324
from keycloak.exceptions import KeycloakGetError
@@ -31,6 +32,7 @@
3132
class KCInfo:
3233
is_enabled: bool
3334
roles: list[str]
35+
attributes: dict[str, Any]
3436

3537

3638
class KCUtil:
@@ -66,14 +68,18 @@ def get_user_info(self, user_id: str) -> KCInfo:
6668
iam_roles = [
6769
role["name"] for role in kadm.get_composite_realm_roles_of_user(user_id)
6870
]
69-
return KCInfo(user["enabled"], iam_roles)
71+
user_attributes = {
72+
attr: user.get("attributes", {}).get(attr)
73+
for attr in settings.oauth2_attributes
74+
}
75+
return KCInfo(user["enabled"], iam_roles, user_attributes)
7076
except KeycloakGetError as error:
7177
# If the user is not found, this means he was removed from keycloak.
7278
# Thus we must remove all his api keys from the database.
7379
if (error.response_code == 404) and (
7480
"User not found" in error.response_body.decode("utf-8")
7581
):
7682
LOGGER.warning(f"User '{user_id}' not found in keycloak.")
77-
return KCInfo(False, [])
83+
return KCInfo(False, [], {})
7884

7985
raise

app/controllers/auth_controller.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,14 @@ async def oidc_auth(token: str | None = Depends(oidc)) -> AuthInfo:
8484
audience=api_settings.oidc_client_id,
8585
algorithms=["RS256"],
8686
)
87+
user_attributes = {
88+
attr: decoded.get(attr) for attr in api_settings.oauth2_attributes
89+
}
8790
return AuthInfo(
8891
decoded.get("sub"),
8992
decoded.get("preferred_username"),
9093
decoded.get("roles"),
94+
user_attributes,
9195
)
9296
except PyJWTError as e:
9397
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=str(e))
@@ -139,6 +143,7 @@ async def show_me(auth_info: Annotated[AuthInfo, Depends(oidc_auth)]):
139143
"user_id": auth_info.user_id,
140144
"user_login": auth_info.user_login,
141145
"roles": auth_info.roles,
146+
"attributes": auth_info.attributes,
142147
}
143148

144149

@@ -172,13 +177,18 @@ async def get_new_api_key(
172177
Returns:
173178
api_key: a newly generated API key
174179
"""
180+
# Merge the config given by the caller with the oauth2 attributes.
181+
# NOTE: the values read from oauth2 have higher priority than those
182+
# given by the caller.
183+
config = (config or {}) | auth_info.attributes
184+
175185
return apikey_crud.create_key(
176186
name,
177187
auth_info.user_id,
178188
auth_info.user_login,
179189
never_expires,
180190
auth_info.roles,
181-
config or {},
191+
config,
182192
allowed_referers,
183193
)
184194

app/settings.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717

18+
import json
1819
import random
1920
import string
20-
from collections.abc import Callable
21+
from collections.abc import Callable, Sequence
2122
from dataclasses import dataclass
23+
from typing import Annotated, Any
2224

23-
from pydantic import field_validator
24-
from pydantic_settings import BaseSettings, SettingsConfigDict
25+
from pydantic import BeforeValidator, field_validator
26+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
2527
from slowapi import Limiter
2628
from slowapi.util import get_remote_address
2729

@@ -31,6 +33,21 @@ class AuthInfo:
3133
user_id: str
3234
user_login: str
3335
roles: list[str]
36+
attributes: dict[str, Any]
37+
38+
39+
def str_to_list(value: Any) -> list:
40+
"""
41+
Convert into a list a comma-separated str (e.g. 'attr1,attr2') or
42+
json representation str (e.g. '["attr1", "attr2"]')
43+
"""
44+
if isinstance(value, str):
45+
if value.startswith("["):
46+
return json.loads(value)
47+
else:
48+
return [v.strip() for v in value.split(",")]
49+
else:
50+
return value
3451

3552

3653
class ApiSettings(BaseSettings):
@@ -99,6 +116,13 @@ class ApiSettings(BaseSettings):
99116

100117
auth_function: Callable | None = None
101118

119+
# List of optional OAuth2 attributes to save as key/values in the API key "config"
120+
# dict. The list is given as a comma-separated str (e.g. 'attr1,attr2') or json
121+
# representation str (e.g. '["attr1", "attr2"]')
122+
oauth2_attributes: Annotated[
123+
Sequence[str], BeforeValidator(str_to_list), NoDecode
124+
] = []
125+
102126
model_config = SettingsConfigDict(env_prefix="APIKM_", env_file=".env")
103127

104128
@field_validator("cors_allow_methods")

deploy/helm/apikeymanager/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ type: application
3232
# This is the chart version. This version number should be incremented each time you make changes
3333
# to the chart and its templates, including the app version.
3434
# Versions are expected to follow Semantic Versioning (https://semver.org/)
35-
version: 1.0.0
35+
version: 0.1.0-dev0.g3df33f1ac
3636

3737
# This is the version number of the application being deployed. This version number should be
3838
# incremented each time you make changes to the application. Versions are not expected to
3939
# follow Semantic Versioning. They should reflect the version the application is using.
4040
# It is recommended to use it with quotes.
41-
appVersion: 1.0.0
41+
appVersion: 0.1.0-dev0.g3df33f1ac
4242

4343
maintainers:
4444
- name: CS GROUP

deploy/helm/apikeymanager/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# apikeymanager
22

3-
![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.0.0](https://img.shields.io/badge/AppVersion-1.0.0-informational?style=flat-square)
3+
![Version: 0.1.0-dev0.g3df33f1ac](https://img.shields.io/badge/Version-0.1.0--dev0.g3df33f1ac-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.0-dev0.g3df33f1ac](https://img.shields.io/badge/AppVersion-0.1.0--dev0.g3df33f1ac-informational?style=flat-square)
44

55
Helm chart for APIKeyManager
66

@@ -25,6 +25,7 @@ Helm chart for APIKeyManager
2525
| config.debug | bool | `false` | DEBUG mode (display SQL queries) |
2626
| config.default_apikey_ttl_hour | int | `360` | Default lifetime of an API Key (in hour) |
2727
| config.keycloak_sync_freq | int | `300` | Sync frequency of a user with data stored in Keycloak (in seconds) |
28+
| config.oauth2_attributes | string | `""` | OAuth2 attributes to save as key/values in the API key "config" dict |
2829
| config.oidc_client_id | string | `""` | OIDC CLient ID |
2930
| config.oidc_client_secret | string | `""` | OIDC Secret used to sync user info from Keycloak |
3031
| config.oidc_endpoint | string | `""` | OIDC End Point |
@@ -38,7 +39,7 @@ Helm chart for APIKeyManager
3839
| fullnameOverride | string | `""` | |
3940
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
4041
| image.repository | string | `"ghcr.io/csgroup-oss/apikey-manager"` | Image repository |
41-
| image.tag | string | `"1.0.0"` | Image tag |
42+
| image.tag | string | `"0.1.dev0.g3df33f1ac"` | Image tag |
4243
| imagePullSecrets[0] | object | `{"name":"ghcr-k8s"}` | Image pull secrets |
4344
| ingress.annotations | object | `{}` | |
4445
| ingress.className | string | `""` | |

deploy/helm/apikeymanager/templates/deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ spec:
106106
value: {{ .Values.config.contact_email | quote }}
107107
- name: "APIKM_OPENAPI_URL"
108108
value: {{ .Values.config.openapi_url | quote }}
109+
- name: "APIKM_OAUTH2_ATTRIBUTES"
110+
value: {{ .Values.config.oauth2_attributes | quote }}
109111
{{- range $key, $val := .Values.env }}
110112
- name: {{ $key }}
111113
value: {{ $val | quote }}

0 commit comments

Comments
 (0)