Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(chart): enable device auth grant #2491

Merged
merged 8 commits into from
Mar 16, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions helm-chart/renku/templates/_keycloak-clients-users.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Define clients and users for Keycloak
"clientId": "renku-cli",
"baseUrl": "{{ template "http" . }}://{{ .Values.global.renku.domain }}",
"secret": "{{ required "Fill in .Values.global.gateway.cliClientSecret with `uuidgen -r`" .Values.global.gateway.cliClientSecret }}",
"publicClient": true,
"attributes": {
"access.token.lifespan": "86400",
"oauth2.device.authorization.grant.enabled": true
},
"redirectUris": [
"{{ template "http" . }}://{{ .Values.global.renku.domain }}/*"
],
Expand Down
3 changes: 2 additions & 1 deletion helm-chart/renku/templates/post-install-job-keycloak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ spec:
"--admin-password=$(KEYCLOAK_ADMIN_PASSWORD)",
"--keycloak-url=$(KEYCLOAK_URL)",
"--users-file=/app/data/users",
"--clients-file=/app/data/clients"
"--clients-file=/app/data/clients",
"--apply-changes"
]

volumeMounts:
Expand Down
133 changes: 64 additions & 69 deletions scripts/init-realm/init-realm.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,90 +28,87 @@


# Helper functions which are called by the script.
from typing import Dict, List


def _check_existing(existing_object, new_object, case, idKey):
def _check_existing(existing_object: Dict, new_object: Dict, case, id_key) -> bool:
"""
Compare the new object to the existing one, warn
about mismatches.
Compare the new object to the existing one, warn about mismatches. Return True if there are mismatches.
"""

def sorted_list(data: List) -> List:
return sorted(data, key=lambda e: json.dumps(e, sort_keys=True))

changed: bool = False

for key in new_object.keys():
if key not in existing_object:
warning = "Found missing key '{}' at {} '{}'!".format(
key, case, new_object[idKey]
)
changed = True
warning = f"Found missing key '{key}' at {case} '{new_object[id_key]}'!"
warnings.warn(warning)

elif new_object[key] != existing_object[key]:
warning = "Found mismatch for key '{}' at {} '{}'!".format(
key, case, new_object[idKey]
)
# If element is a list then sort and compare again
if isinstance(new_object[key], list):
if sorted_list(new_object[key]) == sorted_list(existing_object[key]):
continue

changed = True
warning = f"Found mismatch for key '{key}' at {case} '{new_object[id_key]}'!"
warnings.warn(warning)
warnings.warn("To be created: \n{}".format(json.dumps(new_object[key])))
warnings.warn("Existing: \n{}".format(json.dumps(existing_object[key])))
warnings.warn(f"To be created: \n{json.dumps(new_object[key])}")
warnings.warn(f"Existing: \n{json.dumps(existing_object[key])}")

return changed


def _check_and_create_client(keycloak_admin, new_client):
def _fix_json_values(data: Dict) -> Dict:
"""
Fix quoted booleans in the JSON document from the Keycloak API.
"""
return json.loads(json.dumps(data).replace('"true"', "true").replace('"false"', "false"))


def _check_and_create_client(keycloak_admin, new_client, apply_changes: bool):
"""
Check if a client exists. Create it if not. Alert if
it exists but with different details than what is provided.
"""

sys.stdout.write("Checking if {} client exists...".format(new_client["clientId"]))
realm_clients = keycloak_admin.get_clients()
clientIds = [c["clientId"] for c in realm_clients]
if new_client["clientId"] in clientIds:
client_ids = [c["clientId"] for c in realm_clients]
if new_client["clientId"] in client_ids:
sys.stdout.write("found\n")
realm_client = realm_clients[clientIds.index(new_client["clientId"])]
realm_client = realm_clients[client_ids.index(new_client["clientId"])]

# We have to separately query the secret as it is not part of
# the original respone
# the original response
secret = keycloak_admin.get_client_secrets(realm_client["id"])
# public clients don't have secrets so default to None
realm_client["secret"] = secret.get("value", None)

# We have to remove the auto-generated IDs of the protocol mapper(s)
# before comparing to the to-be-created client.
# Also, we have to fix quoted booleans in the JSON document from
# the Keycloak API.
if "protocolMappers" in realm_client:
for mapper in realm_client["protocolMappers"]:
del mapper["id"]
mapper["config"] = json.loads(
json.dumps(mapper["config"])
.replace('"true"', "true")
.replace('"false"', "false")
)

_check_existing(realm_client, new_client, "client", "clientId")

# If we're dealing with the gateway client which is missing the
# necessary protocol mapper to add the client-id to the audience
# claim, we add it specifically.
mappers_client_ids = ["gateway"]
if realm_client["clientId"] in mappers_client_ids:
mappers_missing = (
"protocolMappers" not in realm_client
and "protocolMappers" in new_client
)
audience_mapper_missing = "audience for gateway" not in [
mapper["name"] for mapper in realm_client.get("protocolMappers", [])
]
if mappers_missing or audience_mapper_missing:
sys.stdout.write(
"found, but without the necessary protocol mapper. Adding it now..."
)
realm_client["protocolMappers"] = new_client[
"protocolMappers"
] + realm_client.get("protocolMappers", [])

# We use DELETE followed by POST since PUT does not add the
# provided protocolMappers to the client and PATCH is not
# supported by the API.
keycloak_admin.delete_client(realm_client["id"])
keycloak_admin.create_client(realm_client)

sys.stdout.write("done\n")
mapper["config"] = _fix_json_values(mapper["config"])

if "attributes" in realm_client:
realm_client["attributes"] = _fix_json_values(realm_client["attributes"])

changed = _check_existing(realm_client, new_client, "client", "clientId")

if not apply_changes or not changed:
return

sys.stdout.write(f"Recreating modified client '{realm_client['clientId']}'...")

keycloak_admin.delete_client(realm_client["id"])
keycloak_admin.create_client(new_client)

sys.stdout.write("done\n")

else:
sys.stdout.write("not found\n")
Expand Down Expand Up @@ -143,9 +140,7 @@ def _check_and_create_user(keycloak_admin, new_user):
sys.stdout.write("Creating user {} ...".format(new_user["username"]))
keycloak_admin.create_user(payload=new_user)
new_user_id = keycloak_admin.get_user_id(new_user["username"])
keycloak_admin.set_user_password(
new_user_id, new_user_password, temporary=False
)
keycloak_admin.set_user_password(new_user_id, new_user_password, temporary=False)
sys.stdout.write("done\n")


Expand All @@ -161,8 +156,7 @@ def _check_and_create_user(keycloak_admin, new_user):
parser.add_argument("--admin-password", help="Keycloak admin password")
parser.add_argument(
"--realm",
help="Name of the Keycloak realm to create or configure. "
+ 'The default is "Renku".',
help='Name of the Keycloak realm to create or configure. The default is "Renku".',
default="Renku",
)
parser.add_argument(
Expand All @@ -175,10 +169,15 @@ def _check_and_create_user(keycloak_admin, new_user):
help="""Path to a json file containing the clients to be created""",
default=None,
)
parser.add_argument(
"--apply-changes",
help="""Copy changes from configured clients to existing KeyCloak clients""",
action="store_true",
)
args = parser.parse_args()


# Check if the file containting the user information is ok.
# Check if the file containing the user information is ok.
if args.users_file:
try:
with open(args.users_file, "r") as f:
Expand All @@ -192,7 +191,7 @@ def _check_and_create_user(keycloak_admin, new_user):
else:
new_users = []

# Check if the file containting the client information is ok.
# Check if the file containing the client information is ok.
if args.clients_file:
try:
with open(args.clients_file, "r") as f:
Expand All @@ -201,9 +200,7 @@ def _check_and_create_user(keycloak_admin, new_user):
sys.stderr.write("No clients-file found at {}.".format(args.clients_file))
exit(1)
except json.JSONDecodeError:
sys.stderr.write(
"Could not parse clients-file at {}.".format(args.clients_file)
)
sys.stderr.write("Could not parse clients-file at {}.".format(args.clients_file))
exit(1)
else:
new_clients = []
Expand All @@ -217,9 +214,9 @@ def _check_and_create_user(keycloak_admin, new_user):
prompt="Password for user '{}' (will not be stored):".format(args.admin_user)
)

# Acquire a admin access token for the kecyloak API. On timeout
# Acquire a admin access token for the keycloak API. On timeout
# or 503 we follow the kubernetes philosophy of just retrying until
# the service is eventuelly up. After 5 minutes we give up and leave
# the service is eventually up. After 5 minutes we give up and leave
# it to K8s to restart the job.
n_attempts = 0
success = False
Expand Down Expand Up @@ -251,9 +248,7 @@ def _check_and_create_user(keycloak_admin, new_user):

# Now that we obviously have all we need, let's create the
# realm, clients and users, skipping what already exists.
sys.stdout.write(
"Creating {} realm, skipping if it already exists...".format(args.realm)
)
sys.stdout.write("Creating {} realm, skipping if it already exists...".format(args.realm))
keycloak_admin.create_realm(
payload={
"realm": args.realm,
Expand All @@ -275,7 +270,7 @@ def _check_and_create_user(keycloak_admin, new_user):


for new_client in new_clients:
_check_and_create_client(keycloak_admin, new_client)
_check_and_create_client(keycloak_admin, new_client, args.apply_changes)

for new_user in new_users:
_check_and_create_user(keycloak_admin, new_user)