forked from c.ellrich/cloaksmith
re-initialised repo to correct author
This commit is contained in:
commit
31a5cce1bc
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
KEYCLOAK_URL=https://your-keycloak/
|
||||||
|
KEYCLOAK_REALM=your-realm
|
||||||
|
KEYCLOAK_CLIENT_ID=your-app-client-id
|
||||||
178
.gitignore
vendored
Normal file
178
.gitignore
vendored
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
.env
|
||||||
|
.keycloak_token.json
|
||||||
|
role_mappings.csv
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [2025] [Chris Ellrich]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
84
README.md
Normal file
84
README.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Cloaksmith
|
||||||
|
|
||||||
|
Cloaksmith is a CLI tool designed to interact with a Keycloak server using OAuth 2.0 Device Authorization Grant. It allows you to import roles from a CSV file and create role mappings, with a focus on simplicity and extensibility.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.7 or higher
|
||||||
|
- Keycloak server
|
||||||
|
- Keycloak client with OAuth 2.0 Device Authorization Grant enabled
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git clone https://git.ellri.ch/c.ellrich/cloaksmith
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Change to the project directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd cloaksmith
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install Cloaksmith using `pip`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Create a Keycloak client with OAuth 2.0 Device Authorization Grant enabled. No other features are required. Refer to the Keycloak documentation for detailed instructions.
|
||||||
|
|
||||||
|
2. Initialize the configuration by running:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cloaksmith init-env
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prompt you to enter the following values:
|
||||||
|
|
||||||
|
- `KEYCLOAK_URL` (e.g. `https://your-keycloak/`)
|
||||||
|
- `KEYCLOAK_REALM` (e.g. `master`)
|
||||||
|
- `KEYCLOAK_CLIENT_ID` (e.g. `your-app-client-id`)
|
||||||
|
|
||||||
|
The `.env` file will be saved to the appropriate config directory:
|
||||||
|
|
||||||
|
- **Linux/macOS:** `~/.config/cloaksmith/.env`
|
||||||
|
- **Windows:** `%APPDATA%\cloaksmith\.env`
|
||||||
|
|
||||||
|
3. Alternatively, you can specify a custom `.env` file using the `--env-file` option for any command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cloaksmith import-roles --env-file /path/to/.env ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once installed, you can use the `cloaksmith` command to interact with your Keycloak server.
|
||||||
|
|
||||||
|
### General Help
|
||||||
|
|
||||||
|
To see the available commands and options, run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cloaksmith --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Roles and Role Mappings to a Client
|
||||||
|
|
||||||
|
Create a CSV file based on the `role_mappings.csv.example` file provided.
|
||||||
|
|
||||||
|
Run the following command to import roles and map them to groups:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cloaksmith import-roles --client-id <target_client_id> --realm <target_client_realm> <path_to_csv>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extensibility
|
||||||
|
Cloaksmith is designed to be easily extensible. You can add new commands or functionality by modifying the CLI or the underlying modules.
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is licensed under the terms specified in the LICENSE file.
|
||||||
1
cloaksmith/__init__.py
Normal file
1
cloaksmith/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.1"
|
||||||
157
cloaksmith/auth.py
Normal file
157
cloaksmith/auth.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
from cloaksmith.log import get_logger
|
||||||
|
|
||||||
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSession:
|
||||||
|
def __init__(self, base_url, realm, client_id, no_cache=False):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.realm = realm
|
||||||
|
self.client_id = client_id
|
||||||
|
self.no_cache = no_cache
|
||||||
|
if os.name == "nt":
|
||||||
|
cache_dir = Path(os.getenv("LOCALAPPDATA")) / "cloaksmith"
|
||||||
|
else:
|
||||||
|
cache_dir = Path.home() / ".cache" / "cloaksmith"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.cache_path = cache_dir / "token.json"
|
||||||
|
self.token_set = None
|
||||||
|
if not self.no_cache:
|
||||||
|
self._load_cached_token()
|
||||||
|
|
||||||
|
def _cache_token(self):
|
||||||
|
"""
|
||||||
|
Cache the token to a file.
|
||||||
|
"""
|
||||||
|
if self.no_cache:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.cache_path.open("w") as f:
|
||||||
|
json.dump({
|
||||||
|
"realm": self.realm,
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"token": self.token_set
|
||||||
|
}, f)
|
||||||
|
log.info("Token cached successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to cache token: {e}")
|
||||||
|
|
||||||
|
def _load_cached_token(self):
|
||||||
|
"""
|
||||||
|
Load the cached token from a file.
|
||||||
|
"""
|
||||||
|
if not self.cache_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with self.cache_path.open() as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if data["realm"] != self.realm or data["client_id"] != self.client_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
token = data["token"]
|
||||||
|
issued_at = token.get("timestamp", 0)
|
||||||
|
expires_in = token.get("expires_in", 0)
|
||||||
|
|
||||||
|
if time.time() < issued_at + expires_in - 10:
|
||||||
|
self.token_set = token
|
||||||
|
log.info("Loaded cached access token.")
|
||||||
|
else:
|
||||||
|
self.token_set = token
|
||||||
|
log.info(
|
||||||
|
"Cached token expired. Will attempt refresh on first request.")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to load cached token: {e}")
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
"""
|
||||||
|
Authenticate the user by obtaining an access token.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.token_set:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_url = f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/auth/device"
|
||||||
|
token_url = f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
|
||||||
|
|
||||||
|
res = requests.post(device_url, data={"client_id": self.client_id})
|
||||||
|
res.raise_for_status()
|
||||||
|
device = res.json()
|
||||||
|
|
||||||
|
direct_link = f"{device['verification_uri']}?user_code={device['user_code']}"
|
||||||
|
log.info(f"Go to the following URL to authenticate: {direct_link}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
poll = requests.post(token_url, data={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"device_code": device["device_code"]
|
||||||
|
})
|
||||||
|
if poll.status_code == 200:
|
||||||
|
self.token_set = poll.json()
|
||||||
|
self.token_set["timestamp"] = int(time.time())
|
||||||
|
self._cache_token()
|
||||||
|
log.info("Authentication successful.")
|
||||||
|
return
|
||||||
|
elif poll.status_code in (400, 428):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
log.error(f"Authentication failed: {poll.text}")
|
||||||
|
raise Exception(f"Authentication failed: {poll.text}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error during authentication: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def refresh_token(self):
|
||||||
|
"""
|
||||||
|
Refresh the access token using the refresh token.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
token_url = f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
|
||||||
|
res = requests.post(token_url, data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"refresh_token": self.token_set["refresh_token"],
|
||||||
|
})
|
||||||
|
res.raise_for_status()
|
||||||
|
self.token_set = res.json()
|
||||||
|
self.token_set["timestamp"] = int(time.time())
|
||||||
|
self._cache_token()
|
||||||
|
log.info("Token refreshed.")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to refresh token: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def request(self, method, url, **kwargs):
|
||||||
|
"""
|
||||||
|
Make an authenticated request to the specified URL.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.token_set:
|
||||||
|
raise Exception("Authentication required.")
|
||||||
|
|
||||||
|
headers = kwargs.pop("headers", {})
|
||||||
|
headers["Authorization"] = f"Bearer {self.token_set['access_token']}"
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
kwargs["headers"] = headers
|
||||||
|
|
||||||
|
res = requests.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if res.status_code == 401:
|
||||||
|
log.warning("Access token expired. Refreshing.")
|
||||||
|
self.refresh_token()
|
||||||
|
headers["Authorization"] = f"Bearer {self.token_set['access_token']}"
|
||||||
|
kwargs["headers"] = headers
|
||||||
|
res = requests.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Request failed: {e}")
|
||||||
|
raise
|
||||||
109
cloaksmith/cli.py
Normal file
109
cloaksmith/cli.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import click
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from cloaksmith.log import setup_logging, get_logger
|
||||||
|
from cloaksmith.auth import AuthSession
|
||||||
|
from cloaksmith.keycloak_roles import KeycloakClientRoleManager
|
||||||
|
from cloaksmith import __version__
|
||||||
|
|
||||||
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def load_default_env():
|
||||||
|
if os.name == "nt":
|
||||||
|
config_path = Path(os.getenv("APPDATA")) / "cloaksmith" / ".env"
|
||||||
|
else:
|
||||||
|
config_path = Path.home() / ".config" / "cloaksmith" / ".env"
|
||||||
|
|
||||||
|
if config_path.exists():
|
||||||
|
load_dotenv(dotenv_path=config_path)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"No .env file found at {config_path}. Please run 'cloaksmith init-env' to create one.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(ctx):
|
||||||
|
env_file = ctx.obj.get("env_file")
|
||||||
|
if env_file:
|
||||||
|
load_dotenv(dotenv_path=env_file)
|
||||||
|
log.info("Loaded environment variables from provided .env file.")
|
||||||
|
else:
|
||||||
|
load_default_env()
|
||||||
|
log.info("Loaded environment variables from default .env file.")
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.help_option("-h", "--help")
|
||||||
|
@click.version_option(__version__)
|
||||||
|
@click.option('--log-level', default="INFO", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), help="Set the log level")
|
||||||
|
@click.option('--env-file', type=click.Path(exists=True), help="Path to a .env file")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, log_level, env_file):
|
||||||
|
"""
|
||||||
|
A command-line interface for performing various Keycloak administration tasks.
|
||||||
|
"""
|
||||||
|
setup_logging(log_level)
|
||||||
|
ctx.obj = {"env_file": env_file}
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.help_option("-h", "--help")
|
||||||
|
def init_env():
|
||||||
|
"""
|
||||||
|
Initialize a .env file in the appropriate config directory by prompting for values.
|
||||||
|
"""
|
||||||
|
url = click.prompt(
|
||||||
|
"KEYCLOAK_URL", prompt_suffix=" (e.g. https://your-keycloak/): ")
|
||||||
|
realm = click.prompt(
|
||||||
|
"KEYCLOAK_REALM", prompt_suffix=" (e.g. your-realm): ")
|
||||||
|
client_id = click.prompt("KEYCLOAK_CLIENT_ID",
|
||||||
|
prompt_suffix=" (e.g. your-app-client-id): ")
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
config_dir = Path(os.getenv("APPDATA")) / "cloaksmith"
|
||||||
|
else:
|
||||||
|
config_dir = Path.home() / ".config" / "cloaksmith"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env_path = config_dir / ".env"
|
||||||
|
with env_path.open("w") as f:
|
||||||
|
f.write(f"KEYCLOAK_URL={url}\n")
|
||||||
|
f.write(f"KEYCLOAK_REALM={realm}\n")
|
||||||
|
f.write(f"KEYCLOAK_CLIENT_ID={client_id}\n")
|
||||||
|
|
||||||
|
log.info(f".env file written to: {env_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.help_option("-h", "--help")
|
||||||
|
@click.argument("csv_path", type=click.Path(exists=True))
|
||||||
|
@click.option("--client-id", required=True, help="Target client ID")
|
||||||
|
@click.option("--realm", required=True, help="Target realm to modify")
|
||||||
|
@click.option("--no-cache", is_flag=True, help="Disable token caching")
|
||||||
|
@click.pass_context
|
||||||
|
def import_roles(ctx, csv_path, client_id, realm, no_cache):
|
||||||
|
"""
|
||||||
|
Import roles and map them to groups using a CSV file.
|
||||||
|
|
||||||
|
CSVPATH: Path to the CSV file containing role and group mappings.
|
||||||
|
"""
|
||||||
|
load_env(ctx)
|
||||||
|
|
||||||
|
base_url = os.getenv("KEYCLOAK_URL")
|
||||||
|
login_realm = os.getenv("KEYCLOAK_REALM")
|
||||||
|
client_id_env = os.getenv("KEYCLOAK_CLIENT_ID")
|
||||||
|
|
||||||
|
auth = AuthSession(base_url=base_url, realm=login_realm,
|
||||||
|
client_id=client_id_env, no_cache=no_cache)
|
||||||
|
auth.authenticate()
|
||||||
|
|
||||||
|
kc = KeycloakClientRoleManager(auth, target_realm=realm)
|
||||||
|
kc.import_roles_and_mappings(client_id, csv_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
89
cloaksmith/keycloak_roles.py
Normal file
89
cloaksmith/keycloak_roles.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import csv
|
||||||
|
from cloaksmith.log import get_logger
|
||||||
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakClientRoleManager:
|
||||||
|
def __init__(self, auth_session, target_realm):
|
||||||
|
self.auth = auth_session
|
||||||
|
self.target_realm = target_realm
|
||||||
|
self.base_url = auth_session.base_url
|
||||||
|
|
||||||
|
def get_client_id(self, client_id):
|
||||||
|
url = f"{self.base_url}/admin/realms/{self.target_realm}/clients"
|
||||||
|
res = self.auth.request("GET", url)
|
||||||
|
clients = res.json()
|
||||||
|
client = next((c for c in clients if c["clientId"] == client_id), None)
|
||||||
|
if not client:
|
||||||
|
log.error(
|
||||||
|
f"Client '{client_id}' not found in realm '{self.target_realm}'")
|
||||||
|
raise ValueError(
|
||||||
|
f"Client '{client_id}' not found in realm '{self.target_realm}'")
|
||||||
|
log.info(f"Found client ID for '{client_id}': {client['id']}")
|
||||||
|
return client["id"]
|
||||||
|
|
||||||
|
def create_role(self, client_internal_id, role_name):
|
||||||
|
url = f"{self.base_url}/admin/realms/{self.target_realm}/clients/{client_internal_id}/roles"
|
||||||
|
res = self.auth.request("POST", url, json={"name": role_name})
|
||||||
|
if res.status_code not in (201, 409):
|
||||||
|
log.error(f"Failed to create role '{role_name}': {res.text}")
|
||||||
|
raise Exception(f"Failed to create role '{role_name}': {res.text}")
|
||||||
|
log.info(f"Role '{role_name}' created successfully or already exists.")
|
||||||
|
|
||||||
|
def get_role(self, client_internal_id, role_name):
|
||||||
|
url = f"{self.base_url}/admin/realms/{self.target_realm}/clients/{client_internal_id}/roles/{role_name}"
|
||||||
|
res = self.auth.request("GET", url)
|
||||||
|
if res.status_code == 404:
|
||||||
|
log.error(
|
||||||
|
f"Role '{role_name}' not found in client '{client_internal_id}'")
|
||||||
|
raise ValueError(f"Role '{role_name}' not found")
|
||||||
|
log.info(f"Found role '{role_name}' in client '{client_internal_id}'")
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def get_group_id(self, group_name):
|
||||||
|
url = f"{self.base_url}/admin/realms/{self.target_realm}/groups"
|
||||||
|
res = self.auth.request("GET", url)
|
||||||
|
groups = res.json()
|
||||||
|
group = next((g for g in groups if g["name"] == group_name), None)
|
||||||
|
if not group:
|
||||||
|
log.error(
|
||||||
|
f"Group '{group_name}' not found in realm '{self.target_realm}'")
|
||||||
|
raise ValueError(
|
||||||
|
f"Group '{group_name}' not found in realm '{self.target_realm}'")
|
||||||
|
log.info(f"Found group ID for '{group_name}': {group['id']}")
|
||||||
|
return group["id"]
|
||||||
|
|
||||||
|
def map_role_to_group(self, group_id, client_internal_id, role_obj):
|
||||||
|
url = f"{self.base_url}/admin/realms/{self.target_realm}/groups/{group_id}/role-mappings/clients/{client_internal_id}"
|
||||||
|
res = self.auth.request("POST", url, json=[role_obj])
|
||||||
|
if res.status_code not in (204, 409):
|
||||||
|
log.error(f"Failed to map role to group '{group_id}': {res.text}")
|
||||||
|
raise Exception(f"Failed to map role to group: {res.text}")
|
||||||
|
log.info(f"Role mapped to group '{group_id}' successfully.")
|
||||||
|
|
||||||
|
def import_roles_and_mappings(self, client_id, csv_path):
|
||||||
|
client_internal_id = self.get_client_id(client_id)
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
with open(csv_path, newline='') as csvfile:
|
||||||
|
reader = csv.DictReader(csvfile)
|
||||||
|
for row in reader:
|
||||||
|
role = row["role_name"]
|
||||||
|
group = row["group_name"]
|
||||||
|
log.info(f"Processing: Role='{role}' -> Group='{group}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.create_role(client_internal_id, role)
|
||||||
|
group_id = self.get_group_id(group)
|
||||||
|
role_obj = self.get_role(client_internal_id, role)
|
||||||
|
self.map_role_to_group(
|
||||||
|
group_id, client_internal_id, role_obj)
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to process role '{role}' for group '{group}': {e}"
|
||||||
|
log.error(msg)
|
||||||
|
failures.append(msg)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
log.warning(f"Completed with {len(failures)} error(s).")
|
||||||
|
else:
|
||||||
|
log.info("Role cloning and mapping completed successfully.")
|
||||||
48
cloaksmith/log.py
Normal file
48
cloaksmith/log.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# log.py
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class ColorFormatter(logging.Formatter):
|
||||||
|
COLORS = {
|
||||||
|
"DEBUG": "\033[36m", # Cyan
|
||||||
|
"INFO": "\033[32m", # Green
|
||||||
|
"WARNING": "\033[33m", # Yellow
|
||||||
|
"ERROR": "\033[31m", # Red
|
||||||
|
"CRITICAL": "\033[41m", # Red background
|
||||||
|
"GREY": "\033[90m", # Grau
|
||||||
|
"RESET": "\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
# Pad uncolored level name
|
||||||
|
raw_level = record.levelname
|
||||||
|
padded = f"{raw_level:<8}"
|
||||||
|
|
||||||
|
# Add color after padding
|
||||||
|
color = self.COLORS.get(raw_level, "")
|
||||||
|
reset = self.COLORS["RESET"]
|
||||||
|
record.levelname = f"{color}{padded}{reset}"
|
||||||
|
record.name = f"{self.COLORS.get('GREY')}{record.name}{reset}"
|
||||||
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
_logger = None
|
||||||
|
|
||||||
|
def setup_logging(level="INFO"):
|
||||||
|
global _logger
|
||||||
|
if _logger:
|
||||||
|
return _logger
|
||||||
|
|
||||||
|
logger = logging.getLogger("keycloak-scripts")
|
||||||
|
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(ColorFormatter("%(asctime)s [ %(levelname)s | %(name)s ] %(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
_logger = logger
|
||||||
|
return logger
|
||||||
|
|
||||||
|
def get_logger():
|
||||||
|
return _logger or setup_logging()
|
||||||
5
role_mappings.csv.example
Normal file
5
role_mappings.csv.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
role_name,group_name
|
||||||
|
admin,admins_group
|
||||||
|
user,users_group
|
||||||
|
moderator,moderators_group
|
||||||
|
developer,developers_group
|
||||||
23
setup.py
Normal file
23
setup.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
# Read version from __init__.py
|
||||||
|
version = {}
|
||||||
|
with open("cloaksmith/__init__.py") as f:
|
||||||
|
exec(f.read(), version)
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='cloaksmith',
|
||||||
|
version=version["__version__"],
|
||||||
|
license='MIT',
|
||||||
|
packages=find_packages(),
|
||||||
|
install_requires=[
|
||||||
|
'Click',
|
||||||
|
'python-dotenv',
|
||||||
|
'requests',
|
||||||
|
],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'cloaksmith = cloaksmith.cli:cli',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user